├── .gitignore ├── LICENSE ├── README.md ├── app1 ├── __init__.py ├── admin.py ├── apps.py ├── locale │ ├── de │ │ └── LC_MESSAGES │ │ │ └── django.po │ └── es_MX │ │ └── LC_MESSAGES │ │ └── django.po ├── management │ ├── __init__.py │ ├── _helpers.py │ └── commands │ │ ├── __init__.py │ │ ├── cleanmessages.py │ │ ├── extractmessages.py │ │ ├── makemessages.py │ │ ├── mergemessages.py │ │ └── tagmessages.py ├── migrations │ └── __init__.py ├── models.py ├── templates │ └── index.html ├── tests.py ├── urls.py └── views.py ├── db.sqlite3 ├── i18n_and_pofiles ├── __init__.py ├── asgi.py ├── locale │ ├── de │ │ └── LC_MESSAGES │ │ │ └── django.po │ └── es_MX │ │ └── LC_MESSAGES │ │ └── django.po ├── settings.py ├── urls.py └── wsgi.py ├── manage.py ├── poetry.lock ├── pyproject.toml └── workflow-diagram.png /.gitignore: -------------------------------------------------------------------------------- 1 | # If you need to exclude files such as those generated by an IDE, use 2 | # $GIT_DIR/info/exclude or the core.excludesFile configuration variable as 3 | # described in https://git-scm.com/docs/gitignore 4 | 5 | *.egg-info 6 | *.pot 7 | *.py[co] 8 | .tox/ 9 | __pycache__ 10 | MANIFEST 11 | dist/ 12 | docs/_build/ 13 | docs/locale/ 14 | node_modules/ 15 | tests/coverage_html/ 16 | tests/.coverage 17 | build/ 18 | tests/report/ 19 | */locale/*/LC_MESSAGES/*.mo 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Felix Miño 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Better managing i18n and PO files in Django 2 | 3 | This is a proposed workflow to better manage huge PO files in Django apps by using the [polib](link-to-polib) 4 | library, and is mainly intended for the management of PO files that are produced when translating static 5 | pages (one PO file contains all the strings of the templates that are in the [app](django-app)), 6 | Anyway, these concepts could be applied in other scenarios or other frameworks. If you have giant PO 7 | files and you don't know how to deal with them probably this is what you're looking for. 8 | 9 | We assume you're familiar with the basic concepts around PO files and their structure. 10 | If not, you can look at the [official documentation](po-documentation). We assume that 11 | you have at least a little experience around i18n in Django, as well. 12 | 13 | ## A little of the history 14 | 15 | Some time ago, with my team, we were internationalizing our client's website. In the beginning, everything 16 | was pretty simple and straightforward, we had one [locale](what-is-a-locale) and we were translating just 17 | a couple of pages, so our PO files were small enough and we were able to handle them easily. 18 | 19 | A couple of months went by, and we had three locales and way more pages that we were translating, so 20 | the problem started to be noticeable. We were sending these giant PO files (one per locale and app), 21 | around ~30k lines, over and over again for translation. Translators started to complain that they were not 22 | able to identify what strings needed translation and which didn't, or at least not in an easy way. So, 23 | after some thinking and learning about what PO files really are, we came up with this workflow. 24 | The main idea behind it is to break up giant PO files into smaller, more manageable, that just 25 | contains `untranslated` entries into a new (temporal) PO. 26 | 27 | ## The mighty workflow 28 | 29 | Before into details, let's see what we get from using this workflow: 30 | * PO files sent to translation contain just relevant entries (entries that need translation). 31 | * Parallel translation projects, since we won't be sending repeated entries in any of them, avoiding 32 | merge conflicts. 33 | * Translators can focus on translations and they know that all entries contained in the PO file need 34 | attention 35 | 36 | This workflow relies on the [extracted comments](po-documentation) concept that allows developers to 37 | add comments in a programmatic way. This decision was a little tricky since we were thinking of using 38 | `translator comments` but we're developers, not translators, and `extracted comments` were a better fit 39 | in our opinion, this could lead to a whole new debate, but for now, we will stick to `extracted comments` 40 | and that's what you need to know. 41 | 42 | So what are `extracted comments`? 43 | 44 | > Comment lines starting with `#.` contain comments given by the programmer, directed at the translator; 45 | > These comments are called extracted comments because the `xgettext` program extracts them from the program’s 46 | > source code. 47 | 48 | As you can see, these are messages that developers aim to translators. In this specific case, 49 | we will use `extracted comments` to let translators know that a specific `PO Entry` belongs to 50 | a given `project`. For example, an `untranslated` entry with an extracted comment will look like this: 51 | 52 | ```python 53 | #. project=unstranslated_entries 54 | #: app1/templates/index.html:1 55 | msgid "Lorem ipsum" 56 | msgstr "" 57 | ``` 58 | Due to the decision to use `extracted comments`, the `makemessages` Django command had to be monkey patched. 59 | Django pulls `extracted comments` from the code base, but in this case, we're introducing them programmatically 60 | using the `polib`, so we needed to keep these comments even if they're not in the code base. 61 | 62 | `Untranslated` is quoted since this definition could change from project to project. 63 | In this case, our definition of `untranslated` is the following: 64 | * An entry that has `msgstr` empty and does not have an extracted comment that contains the `project` string. 65 | * An entry that contains the `fuzzy` flag. 66 | 67 | So, `extracted comments are added to the PO entries that match these criteria. 68 | 69 | The whole workflow looks like this: 70 | ![workflow-diagram](workflow-diagram.png) 71 | 72 | Now, instead of two CLI commands (`makemessages` and `compilemessages`) we will have six commands, 73 | each is detailed below: 74 | 75 | * `makemessages`: this is the same command as the one in the Django code base, it will accept all the 76 | parameters that are accepted in the original one, and will accept one additional `--allow-obsolete`. 77 | We identified that Django `makemessages` keep PO entries that are no longer used (e.g. From templates 78 | that have been deleted), so we decided to not keep those by default, and give the user the option to 79 | delete or keep them by using this flag. Usage: `python manage.py makemessages -l de`. 80 | 81 | * `tagmessages`: this management command is the first step to creating an independent PO file that can be 82 | sent for parallel translation. When run, this command will add a project name to entries that need 83 | translation, as in the example above. Usage: `python manage.py tagmessages -l de -p jdoe_20220101`. 84 | You could also tag entries for a single template by using the `--file-name` param. When in use, 85 | this command will only tag the entries that are contained in the given path. Usage: 86 | `python manage.py tagmessages -l de -p jdoe_20220101 -f app1/templates/index.html`. 87 | 88 | * `extractmessages`: this command will take the previously tagged entries and will create a temporal PO 89 | file with them. The output of this command is the file you will be using and sending for translation. 90 | Usage: `python manage.py extractmessages -l de -p jdoe_20220101` 91 | 92 | * `mergemessages`: After our translation process has been executed we need a way to put those entries 93 | back in our main PO file. This action will allow us to merge back the already translated entries. In this 94 | case, the usage is a little bit trickier and has a couple of more parameters. The relative path to the PO 95 | that we want to merge back needs to be provided. 96 | Usage: `python manage.py mergemessages -a app1 -l de -p jdoe_20220101 path/to/translated/PO/file`. 97 | 98 | * `cleanmessages`: finally, we need a way to delete the `extracted comments` added for the project and 99 | also the temporary file created in `extractmessages` step. The `cleanmessages` command allows us to do 100 | both things. Usage: `python manage.py cleanmessages -l de -p jdoe_20220101` 101 | 102 | * The `compilemessages` command will need to be run exactly as it is in the original workflow. One last 103 | caveat, whether or not you want to add the temporary PO files to your version control is up to you, just 104 | make sure you run the `cleanmessages` command to delete no longer necessary files. 105 | 106 | 107 | [po-documentation]: https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html 108 | [link-to-polib]: https://polib.readthedocs.io/en/latest/ 109 | [django-app]: https://docs.djangoproject.com/en/4.1/ref/applications/ 110 | [what-is-a-locale]:() 111 | -------------------------------------------------------------------------------- /app1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixminom/better_i18n_django/4dfd7d24381142df605ef1f556aabf27b84345f5/app1/__init__.py -------------------------------------------------------------------------------- /app1/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /app1/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class App1Config(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "app1" 7 | -------------------------------------------------------------------------------- /app1/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2022-09-18 02:02+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: app1/templates/index.html:3 22 | msgid "Hello from file" 23 | msgstr "This should be a de string" 24 | 25 | #: app1/templates/index.html:4 26 | msgid "This is supposed to be a subtitle" 27 | msgstr "Translated string" 28 | -------------------------------------------------------------------------------- /app1/locale/es_MX/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2022-08-08 20:52-0500\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | #: app1/templates/index.html:3 21 | msgid "Hello from file" 22 | msgstr "Hola" 23 | 24 | #: app1/templates/index.html:4 25 | msgid "This is supposed to be a subtitle" 26 | msgstr "subtitulo" 27 | -------------------------------------------------------------------------------- /app1/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixminom/better_i18n_django/4dfd7d24381142df605ef1f556aabf27b84345f5/app1/management/__init__.py -------------------------------------------------------------------------------- /app1/management/_helpers.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List 3 | 4 | from django.apps import AppConfig, apps 5 | from django.conf import settings 6 | from django.core.management import CommandError 7 | from django.utils.translation import to_locale 8 | 9 | from polib import POEntry, POFile, pofile 10 | 11 | ALL_APPS: List[AppConfig] = [ 12 | app 13 | for app in apps.get_app_configs() 14 | if 'django' not in app.name 15 | ] 16 | 17 | SUPPORTED_LANGUAGES: List[str] = [ 18 | code for (code, _) in settings.LANGUAGES if code != settings.LANGUAGE_CODE 19 | ] 20 | 21 | 22 | def get_supported_locale(locale: str) -> str: 23 | if locale in SUPPORTED_LANGUAGES: 24 | return locale 25 | else: 26 | raise CommandError(f"Unsupported locale: [{locale}]") 27 | 28 | 29 | def get_po_project_comment(project_name: str) -> str: 30 | return f'project={project_name}' 31 | 32 | 33 | def get_po_file_path(app_path: str, locale: str, project_name: str = None) -> Path: 34 | po_file_name = validate_project_name(project_name) 35 | return Path(app_path) / 'locale' / to_locale(locale) / 'LC_MESSAGES' / po_file_name 36 | 37 | 38 | def get_po_file_path_general_locale(locale: str, project_name: str = None) -> Path: 39 | po_file_name = validate_project_name(project_name) 40 | return Path() / 'locale' / to_locale(locale) / 'LC_MESSAGES' / po_file_name 41 | 42 | 43 | def validate_project_name(project_name: str = None) -> str: 44 | if project_name: 45 | po_file_name = f'po_project_{project_name}.po' 46 | else: 47 | po_file_name = 'django.po' 48 | return po_file_name 49 | 50 | 51 | def safe_read_pofile(path: str) -> POFile: 52 | try: 53 | return pofile(path) 54 | except (IOError, ValueError) as error: 55 | raise CommandError(error) 56 | 57 | 58 | def has_project(entry: POEntry, project_name_comment: str) -> bool: 59 | return project_name_comment in entry.comment 60 | 61 | 62 | def add_project(entry: POEntry, project_name_comment: str) -> POEntry: 63 | entry.comment = f"{project_name_comment}" 64 | 65 | return entry -------------------------------------------------------------------------------- /app1/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixminom/better_i18n_django/4dfd7d24381142df605ef1f556aabf27b84345f5/app1/management/commands/__init__.py -------------------------------------------------------------------------------- /app1/management/commands/cleanmessages.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from .._helpers import ( 6 | ALL_APPS, 7 | get_po_file_path, 8 | get_po_file_path_general_locale, 9 | get_po_project_comment, 10 | get_supported_locale, 11 | has_project, 12 | safe_read_pofile, 13 | ) 14 | 15 | 16 | class Command(BaseCommand): 17 | """Remove project name tags and delete project po file""" 18 | 19 | help = ( 20 | 'This management command removes po project tags' 21 | 'Usage: python manage.py cleanmessages -l de -p jdoe_20210101' 22 | ) 23 | 24 | def add_arguments(self, parser): 25 | parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter 26 | 27 | parser.add_argument( 28 | '--dry-run', 29 | required=False, 30 | action='store_true', 31 | default=False, 32 | help='Dry run, will not save changes', 33 | ) 34 | 35 | parser.add_argument( 36 | '-l', 37 | '--locale', 38 | required=True, 39 | help='Tag only po files in a specific a locale, e.g. ja', 40 | ) 41 | 42 | parser.add_argument( 43 | '-p', 44 | '--project-name', 45 | required=True, 46 | help='Po Project name, e.g. jdoe_20210101', 47 | ) 48 | 49 | def handle(self, *args, **options): 50 | self.project_name = options.get('project_name') 51 | self.locale = get_supported_locale(options.get('locale')) 52 | self.dry_run = options.get('dry_run') 53 | self.project_comment = get_po_project_comment(self.project_name) 54 | 55 | for app in ALL_APPS: 56 | po_file = get_po_file_path(app.path, self.locale) 57 | self.process_po_file(po_file) 58 | 59 | po_project_file = get_po_file_path(app.path, self.locale, self.project_name) 60 | self.delete_po_project_file(po_project_file) 61 | self.process_po_file(get_po_file_path_general_locale(self.locale)) 62 | self.delete_po_project_file( 63 | get_po_file_path_general_locale(self.locale, self.project_name) 64 | ) 65 | 66 | def process_po_file(self, po_file): 67 | if po_file.exists(): 68 | self.stdout.write(self.style.SUCCESS(f'Processing: {po_file}')) 69 | count = 0 70 | 71 | po = safe_read_pofile(po_file) 72 | for entry in po: 73 | if has_project(entry, self.project_comment): 74 | self.remove_project(entry) 75 | count += 1 76 | 77 | if not self.dry_run and count: 78 | po.save() 79 | self.stdout.write(self.style.SUCCESS(f'Removed {count} occurrence(s)')) 80 | 81 | def delete_po_project_file(self, po_file): 82 | if not self.dry_run and po_file.exists(): 83 | po_file.unlink() 84 | self.stdout.write(self.style.SUCCESS(f'Removed project file: {po_file}')) 85 | 86 | def remove_project(self, entry): 87 | """Remove all occurrences of project name from comment""" 88 | comments = entry.comment.splitlines() 89 | updated_comments = list(filter(self.project_comment.__ne__, comments)) 90 | 91 | if len(updated_comments): 92 | entry.comment = '\n'.join(updated_comments).strip() 93 | else: 94 | entry.comment = None 95 | 96 | return entry 97 | -------------------------------------------------------------------------------- /app1/management/commands/extractmessages.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from polib import POFile 6 | 7 | from .._helpers import ( 8 | ALL_APPS, 9 | get_po_file_path, 10 | get_po_file_path_general_locale, 11 | get_po_project_comment, 12 | get_supported_locale, 13 | has_project, 14 | safe_read_pofile, 15 | ) 16 | 17 | 18 | class Command(BaseCommand): 19 | """Find tagged entries with a given project name and create a new po file""" 20 | 21 | help = ( 22 | 'This management command creates a new PO file from tagged entries' 23 | 'Usage: python manage.py extractmessages -p jdoe_20220101 -l de' 24 | ) 25 | 26 | def add_arguments(self, parser): 27 | parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter 28 | 29 | parser.add_argument( 30 | '--force', 31 | required=False, 32 | action='store_true', 33 | default=False, 34 | help='Force saving when project po file exists', 35 | ) 36 | 37 | parser.add_argument( 38 | '-l', 39 | '--locale', 40 | required=True, 41 | help='Tag only po files in a specific locale, e.g. de', 42 | ) 43 | 44 | parser.add_argument( 45 | '-p', 46 | '--project-name', 47 | required=True, 48 | help='Po Project name, e.g. jdoe_20220101', 49 | ) 50 | 51 | def handle(self, *args, **options): 52 | self.project_name = options.get('project_name') 53 | self.locale = get_supported_locale(options.get('locale')) 54 | self.force = options.get('force') 55 | self.project_comment = get_po_project_comment(self.project_name) 56 | 57 | for app in ALL_APPS: 58 | po_file = get_po_file_path(app.path, self.locale) 59 | po_project_file = get_po_file_path(app.path, self.locale, self.project_name) 60 | self.process_po_file(po_file, po_project_file) 61 | self.process_po_file( 62 | get_po_file_path_general_locale(self.locale), 63 | get_po_file_path_general_locale(self.locale, self.project_name), 64 | ) 65 | 66 | def process_po_file(self, po_file, project_po_file): 67 | if po_file.exists(): 68 | self.stdout.write(self.style.SUCCESS(f'Processing: {po_file}')) 69 | if project_po_file.exists() and not self.force: 70 | self.stdout.write( 71 | f'Project po file exists: {project_po_file}, ' 72 | f'please use -f to override' 73 | ) 74 | return False 75 | 76 | po = safe_read_pofile(po_file) 77 | project_po = POFile() 78 | project_po.metadata = po.metadata 79 | 80 | for entry in po: 81 | if has_project(entry, self.project_comment): 82 | project_po.append(entry) 83 | 84 | if len(project_po): 85 | project_po.save(project_po_file) 86 | self.stdout.write( 87 | self.style.SUCCESS( 88 | f'Wrote {len(project_po)} entries to {project_po_file}' 89 | ) 90 | ) 91 | -------------------------------------------------------------------------------- /app1/management/commands/makemessages.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | from django.core.management.commands import makemessages 4 | from django.utils.translation import to_locale 5 | 6 | from polib import POFile 7 | 8 | from .._helpers import ( 9 | ALL_APPS, 10 | SUPPORTED_LANGUAGES, 11 | get_po_file_path, 12 | get_supported_locale, 13 | safe_read_pofile, 14 | ) 15 | 16 | 17 | class Command(makemessages.Command): 18 | '''Creates messages for all locales languages for every app''' 19 | 20 | def add_arguments(self, parser): 21 | super().add_arguments(parser) 22 | 23 | parser.add_argument( 24 | '--allow-obsolete', 25 | action='store_true', 26 | default=False, 27 | required=False, 28 | help='Does not remove obsolete message strings', 29 | ) 30 | 31 | def handle(self, *args, **options): 32 | valid_locales = map(get_supported_locale, options["locale"]) 33 | 34 | self.locales = ( 35 | list(map(to_locale, valid_locales)) if valid_locales else SUPPORTED_LANGUAGES 36 | ) 37 | 38 | self.stdout.write("Making messages for all apps") 39 | 40 | options["no_obsolete"] = not options["allow_obsolete"] 41 | 42 | if options["all"]: 43 | self.locales = list(map(to_locale, SUPPORTED_LANGUAGES)) 44 | self.all = False 45 | 46 | options["locale"] = self.locales 47 | 48 | backup = self.backup_comments() 49 | 50 | super().handle(*args, **options) 51 | 52 | self.restore_comments(backup) 53 | self.post_process_po_files() 54 | 55 | self.stdout.write(self.style.SUCCESS("All Done! 🎉")) 56 | 57 | def post_process_po_files(self): 58 | for app in ALL_APPS: 59 | for locale in self.locales: 60 | po_path = get_po_file_path(app.path, locale) 61 | 62 | if path.exists(po_path): 63 | self.stdout.write(f"Processing [{locale}] for [{app.label}]:") 64 | 65 | django_po = safe_read_pofile(po_path) 66 | django_po = self.remove_fuzzy_translations(django_po) 67 | 68 | self.stdout.write(' • Writing changes the django.po file...') 69 | django_po.save() 70 | 71 | self.stdout.write(' • Checking for possible duplicates...') 72 | self.check_for_duplicates(po_path) 73 | 74 | self.stdout.write(' • Done!') 75 | self.stdout.write('') 76 | 77 | def remove_fuzzy_translations(self, django_po): 78 | self.stdout.write(" • Removing fuzzy translations...") 79 | 80 | for fuzzy_entry in django_po.fuzzy_entries(): 81 | fuzzy_entry.previous_msgid = None 82 | fuzzy_entry.previous_msgctxt = None 83 | fuzzy_entry.flags.remove('fuzzy') 84 | fuzzy_entry.msgstr = '' 85 | 86 | return django_po 87 | 88 | def check_for_duplicates(self, po_path): 89 | django_po = safe_read_pofile(po_path) 90 | 91 | for (i, entry) in enumerate(django_po): 92 | next_index = i + 1 93 | duplicates = [] 94 | 95 | for compare in django_po[next_index:]: 96 | has_same_id = entry.msgid.strip() == compare.msgid.strip() 97 | has_same_context = entry.msgctxt == compare.msgctxt 98 | 99 | if has_same_id and has_same_context: 100 | duplicates.append(compare.linenum) 101 | 102 | if duplicates: 103 | self.stdout.write( 104 | self.style.WARNING( 105 | f" ⚠️ Possible duplicate(s) of line " 106 | f"[{entry.linenum}]: {duplicates}" 107 | ) 108 | ) 109 | 110 | def backup_comments(self): 111 | backup = {} 112 | 113 | for app in ALL_APPS: 114 | for locale in self.locales: 115 | po_path = get_po_file_path(app.path, locale) 116 | 117 | if path.exists(po_path): 118 | django_po = safe_read_pofile(po_path) 119 | temp_po = POFile() 120 | 121 | for entry in django_po: 122 | if entry.comment: 123 | temp_po.append(entry) 124 | 125 | backup[po_path] = temp_po 126 | 127 | self.stdout.write('PO project comments backed up') 128 | return backup 129 | 130 | def restore_comments(self, backup): 131 | for po_path, backup_po in backup.items(): 132 | django_po = safe_read_pofile(po_path) 133 | 134 | for backup_entry in backup_po: 135 | po_entry = django_po.find(backup_entry.msgid) 136 | 137 | if po_entry: 138 | po_entry.comment = backup_entry.comment 139 | 140 | django_po.save() 141 | 142 | self.stdout.write('PO project comments restored') -------------------------------------------------------------------------------- /app1/management/commands/mergemessages.py: -------------------------------------------------------------------------------- 1 | import csv 2 | 3 | from os import path 4 | 5 | from django.core.management.base import BaseCommand, CommandError 6 | from django.utils.translation import to_locale 7 | 8 | from .._helpers import ( 9 | get_po_project_comment, 10 | get_supported_locale, 11 | has_project, 12 | safe_read_pofile, 13 | ) 14 | 15 | 16 | class Command(BaseCommand): 17 | '''This management command import po projects.''' 18 | 19 | help = ( 20 | 'This management command will merge back translated strings located in the' 21 | 'PO file created with extractmessages.' 22 | 'Usage: python manage.py mergemessages -a app1 -l de -p jdoe_20220101 path/to/translated/PO/file' 23 | ) 24 | 25 | def add_arguments(self, parser): 26 | parser.add_argument( 27 | '-a', 28 | '--app', 29 | required=True, 30 | help='The Django app to import the PO project into', 31 | ) 32 | 33 | parser.add_argument( 34 | '-l', 35 | '--locale', 36 | required=True, 37 | help='The locale of the PO project (e.g. ja)', 38 | ) 39 | 40 | parser.add_argument( 41 | '-p', 42 | '--project', 43 | required=True, 44 | help='The project name of the PO project', 45 | ) 46 | 47 | parser.add_argument( 48 | 'file', 49 | help='The input file to import the PO project', 50 | ) 51 | 52 | parser.add_argument( 53 | '--dry-run', 54 | action='store_true', 55 | default=False, 56 | required=False, 57 | help=( 58 | 'Run the management command without writing any changes to the django.po' 59 | ' files' 60 | ), 61 | ) 62 | 63 | def handle(self, *args, **options): 64 | self.app = options.get('app') 65 | self.locale = get_supported_locale(options.get('locale')) 66 | self.project = options.get('project') 67 | self.file = options.get('file') 68 | self.is_dry = options.get('dry_run') 69 | self.locale_name = to_locale(self.locale) 70 | self.django_po_path = ( 71 | f"{self.app}/locale/{self.locale_name}/LC_MESSAGES/django.po" 72 | ) 73 | self.django_po_path_general_locale = ( 74 | f"locale/{self.locale_name}/LC_MESSAGES/django.po" 75 | ) 76 | 77 | self.affected_pages_templates = [] 78 | self.affected_pages = [] 79 | 80 | self.validate_folder_locale() 81 | self.validate_app() 82 | self.validate_locale() 83 | self.validate_django_po() 84 | self.validate_file() 85 | 86 | self.stdout.write("Importing PO project...") 87 | self.write_project_to_django_po() 88 | 89 | self.stdout.write(self.style.SUCCESS('Import successfull! 🎉')) 90 | 91 | def validate_folder_locale(self): 92 | if self.app == 'locale': 93 | self.django_po_path = self.django_po_path_general_locale 94 | 95 | def validate_app(self): 96 | if not path.exists(f"{self.app}/"): 97 | raise CommandError(f"The app '{self.app}' does not exist!") 98 | 99 | def validate_locale(self): 100 | if self.app == 'locale': 101 | test_path = f"locale/{self.locale_name}/" 102 | else: 103 | test_path = f"{self.app}/locale/{self.locale_name}/" 104 | if not path.exists(test_path): 105 | raise CommandError( 106 | f"The locale '{self.locale}' does not exist in the app '{self.app}'" 107 | ) 108 | 109 | def validate_django_po(self): 110 | if not path.exists(self.django_po_path): 111 | raise CommandError( 112 | f"The app '{self.app}' does not contain a django.po translation file" 113 | ) 114 | 115 | def validate_file(self): 116 | if not path.exists(self.file): 117 | raise CommandError(f"Unable to find the specified file [{self.file}]") 118 | 119 | def write_project_to_django_po(self): 120 | tag = get_po_project_comment(self.project) 121 | django_po = safe_read_pofile(self.django_po_path) 122 | 123 | for project_entry in safe_read_pofile(self.file): 124 | if tag not in project_entry.comment: 125 | self.show_warning( 126 | f"Entry [{project_entry.msgid}] is not part of this project, so it" 127 | " will be ignored!" 128 | ) 129 | else: 130 | for entry in django_po: 131 | matches_id = entry.msgid == project_entry.msgid 132 | 133 | if has_project(entry, tag) and matches_id: 134 | if entry.msgstr: 135 | self.show_warning( 136 | f"Overwriting current translation of [{entry.msgid}]" 137 | ) 138 | self.get_entry_ocurrences(entry) 139 | entry.msgstr = project_entry.msgstr 140 | 141 | if not self.is_dry: 142 | self.stdout.write('Writing changes to django.po file...') 143 | django_po.save() 144 | else: 145 | self.stdout.write( 146 | 'Dry-run complete. No changes were written to the django.po file' 147 | ) 148 | 149 | def show_warning(self, message): 150 | self.stdout.write(self.style.WARNING(f"⚠️ WARNING: {message}")) 151 | 152 | def get_entry_ocurrences(self, entry): 153 | for file, _ in entry.occurrences: 154 | if file not in self.affected_pages_templates: 155 | self.affected_pages_templates.append(file) 156 | -------------------------------------------------------------------------------- /app1/management/commands/tagmessages.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import time 3 | 4 | from django.core.management.base import BaseCommand, CommandError 5 | 6 | from .._helpers import ( 7 | ALL_APPS, 8 | add_project, 9 | get_po_file_path, 10 | get_po_file_path_general_locale, 11 | get_po_project_comment, 12 | get_supported_locale, 13 | safe_read_pofile, 14 | ) 15 | 16 | 17 | class Command(BaseCommand): 18 | """Tag untranslated entries with a project name""" 19 | 20 | help = ( 21 | 'This management command is the first step to create an independent PO file' 22 | 'that can be sent for parallel transalation. When run, this command will add' 23 | 'a project name to entries that need translation.' 24 | 'Usage: python manage.py tagmessages -l de -p' 25 | ) 26 | 27 | def add_arguments(self, parser): 28 | parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter 29 | 30 | parser.add_argument( 31 | '--dry-run', 32 | required=False, 33 | action='store_true', 34 | default=False, 35 | help=( 36 | 'Helpful to see which entries will be tagged in each django.po file, ' 37 | 'before making and actual update.' 38 | ), 39 | ) 40 | 41 | parser.add_argument( 42 | '-f', 43 | '--file-name', 44 | required=False, 45 | help=( 46 | 'Search all untranslated and fuzzy entries by file name.' 47 | 'If not provided all entries (untranslated and fuzzy) will be tagged' 48 | 'e.g. app1/templates/index.html .' 49 | 'Note that the path starts with the app name.' 50 | 'Also if you want to loop over a specific app you could use this param ' 51 | 'in this way, -f app1' 52 | ), 53 | ) 54 | 55 | parser.add_argument( 56 | '-l', 57 | '--locale', 58 | required=True, 59 | help='Tag only po files in a specific a locale, e.g. de', 60 | ) 61 | 62 | parser.add_argument( 63 | '-p', 64 | '--project-name', 65 | required=False, 66 | default=self.generate_project_name(), 67 | help='Project name to tag po files, e.g. jdoe_20220101', 68 | ) 69 | 70 | def handle(self, *args, **options): 71 | 72 | self.project_name = options.get('project_name') 73 | self.dry_run = options.get('dry_run') 74 | self.locale = get_supported_locale(options.get('locale')) 75 | self.project_comment = get_po_project_comment(self.project_name) 76 | 77 | self.tag_po_files(options.get('file_name')) 78 | 79 | if any(self.any_file_changed): 80 | self.stdout.write( 81 | self.style.SUCCESS(f'All done, your tag is: {self.project_name}') 82 | ) 83 | 84 | def tag_po_files(self, file_name): 85 | if self.dry_run: 86 | self.stdout.write( 87 | self.style.NOTICE("Running in --dry-run mode, files won't be affected") 88 | ) 89 | 90 | if file_name: 91 | self.process_by_filename(file_name) 92 | else: 93 | self.process_all_apps() 94 | 95 | def process_by_filename(self, file_name): 96 | app = self.validate_app_in_filename(file_name) 97 | 98 | if app: 99 | po_file = get_po_file_path(app.path, self.locale) 100 | self.process_file(po_file, process_with_filename=True) 101 | else: 102 | self.stdout.write( 103 | self.style.ERROR( 104 | f'{self.app_name} is not a valid app.\n' 105 | 'Remember that the filename must start with the app directory' 106 | ) 107 | ) 108 | 109 | def process_all_apps(self): 110 | for app in ALL_APPS: 111 | po_file = get_po_file_path(app.path, self.locale) 112 | self.process_file(po_file) 113 | self.process_file(get_po_file_path_general_locale(self.locale)) 114 | 115 | def process_file(self, po_file, process_with_filename=False): 116 | if po_file.exists(): 117 | self.stdout.write(self.style.SUCCESS(f'Processing: {po_file}')) 118 | po = safe_read_pofile(po_file) 119 | 120 | self.any_file_changed = [] 121 | self.tagged_entries = 0 122 | self.is_file_changed = False 123 | 124 | if process_with_filename: 125 | self.tag_by_filename(po) 126 | else: 127 | self.tag_all_untranslated_strings(po) 128 | 129 | if self.is_file_changed and not self.dry_run: 130 | po.save() 131 | self.any_file_changed.append(True) 132 | 133 | if not self.dry_run: 134 | self.stdout.write( 135 | self.style.SUCCESS( 136 | f'A total of {self.tagged_entries} entries were tagged' 137 | ) 138 | ) 139 | 140 | else: 141 | # Even if the app is provided there could be some cases 142 | # that the po file for the given locale doesn't exits. 143 | # Or the app doesn't have po files at all 144 | if process_with_filename: 145 | raise CommandError(f'Not found: {po_file}') 146 | 147 | def tag_by_filename(self, po): 148 | for entry in po: 149 | f = self.file_name 150 | if self.is_tagable(entry) and any(f in file for file, _ in entry.occurrences): 151 | self.tag_entry(entry) 152 | 153 | def tag_all_untranslated_strings(self, po): 154 | for entry in po: 155 | if self.is_tagable(entry): 156 | self.tag_entry(entry) 157 | 158 | def tag_entry(self, entry): 159 | if self.dry_run: 160 | self.stdout.write(f'{self.tagged_entries}> {entry.msgid}') 161 | self.tagged_entries += 1 162 | else: 163 | entry = add_project(entry, self.project_comment) 164 | self.is_file_changed = True 165 | self.tagged_entries += 1 166 | 167 | def is_tagable(self, entry): 168 | """Check if a specific entry can have a project_name comment. We check 169 | if it's fuzzy or untranslated, that it's a non obsolete entry and that 170 | it does NOT contain a previous project comment""" 171 | if ( 172 | (entry.fuzzy or not entry.translated()) 173 | and not entry.obsolete 174 | and not entry.comment 175 | ): 176 | return True 177 | 178 | return False 179 | 180 | def generate_project_name(self): 181 | return f'auto_{int(time.time())}' 182 | 183 | def validate_app_in_filename(self, file_name): 184 | self.file_name = file_name 185 | self.app_name = self.file_name.split('/')[0] 186 | 187 | return next((app for app in ALL_APPS if app.name == self.app_name), None) 188 | -------------------------------------------------------------------------------- /app1/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixminom/better_i18n_django/4dfd7d24381142df605ef1f556aabf27b84345f5/app1/migrations/__init__.py -------------------------------------------------------------------------------- /app1/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /app1/templates/index.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |

{% translate "Hello from file" %}

4 |

{% translate "This is supposed to be a subtitle" %}

5 | -------------------------------------------------------------------------------- /app1/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /app1/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from . import views 3 | 4 | urlpatterns = [ 5 | path('', views.index, name='index') 6 | ] 7 | -------------------------------------------------------------------------------- /app1/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | def index(request): 4 | return render(request, 'index.html') 5 | -------------------------------------------------------------------------------- /db.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixminom/better_i18n_django/4dfd7d24381142df605ef1f556aabf27b84345f5/db.sqlite3 -------------------------------------------------------------------------------- /i18n_and_pofiles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixminom/better_i18n_django/4dfd7d24381142df605ef1f556aabf27b84345f5/i18n_and_pofiles/__init__.py -------------------------------------------------------------------------------- /i18n_and_pofiles/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for i18n_and_pofiles project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'i18n_and_pofiles.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /i18n_and_pofiles/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2022-09-18 01:52+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: i18n_and_pofiles/settings.py:123 22 | msgid "English" 23 | msgstr "" 24 | 25 | #: i18n_and_pofiles/settings.py:124 26 | msgid "German" 27 | msgstr "" 28 | 29 | #: i18n_and_pofiles/settings.py:125 30 | msgid "Spanish Mexico" 31 | msgstr "" 32 | -------------------------------------------------------------------------------- /i18n_and_pofiles/locale/es_MX/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2022-08-08 20:52-0500\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | #: i18n_and_pofiles/settings.py:123 21 | msgid "English" 22 | msgstr "" 23 | 24 | #: i18n_and_pofiles/settings.py:124 25 | msgid "German" 26 | msgstr "" 27 | 28 | #: i18n_and_pofiles/settings.py:125 29 | msgid "Spanish Mexico" 30 | msgstr "" 31 | -------------------------------------------------------------------------------- /i18n_and_pofiles/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for i18n_and_pofiles project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | from django.utils.translation import gettext_lazy as _ 15 | 16 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 17 | BASE_DIR = Path(__file__).resolve().parent.parent 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = '8xa7ge06dje+)ap-z%h711z2e_82y*6mom!^23f@1c&-q(z@y(' 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | 'django.contrib.admin', 36 | 'django.contrib.auth', 37 | 'django.contrib.contenttypes', 38 | 'django.contrib.sessions', 39 | 'django.contrib.messages', 40 | 'django.contrib.staticfiles', 41 | 'app1' 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | 'django.middleware.security.SecurityMiddleware', 46 | 'django.contrib.sessions.middleware.SessionMiddleware', 47 | 'django.middleware.common.CommonMiddleware', 48 | 'django.middleware.csrf.CsrfViewMiddleware', 49 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 50 | 'django.contrib.messages.middleware.MessageMiddleware', 51 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 52 | 'django.middleware.locale.LocaleMiddleware', 53 | ] 54 | 55 | ROOT_URLCONF = 'i18n_and_pofiles.urls' 56 | 57 | 58 | TEMPLATES = [ 59 | { 60 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 61 | 'DIRS': [], 62 | 'APP_DIRS': True, 63 | 'OPTIONS': { 64 | 'context_processors': [ 65 | 'django.template.context_processors.debug', 66 | 'django.template.context_processors.request', 67 | 'django.contrib.auth.context_processors.auth', 68 | 'django.contrib.messages.context_processors.messages', 69 | ], 70 | }, 71 | }, 72 | ] 73 | 74 | WSGI_APPLICATION = 'i18n_and_pofiles.wsgi.application' 75 | 76 | 77 | # Database 78 | # https://docs.djangoproject.com/en/3.1/ref/settings/#databases 79 | 80 | DATABASES = { 81 | 'default': { 82 | 'ENGINE': 'django.db.backends.sqlite3', 83 | 'NAME': BASE_DIR / 'db.sqlite3', 84 | } 85 | } 86 | 87 | 88 | # Password validation 89 | # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators 90 | 91 | AUTH_PASSWORD_VALIDATORS = [ 92 | { 93 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 94 | }, 95 | { 96 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 97 | }, 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 100 | }, 101 | { 102 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 103 | }, 104 | ] 105 | 106 | 107 | # Internationalization 108 | # https://docs.djangoproject.com/en/3.1/topics/i18n/ 109 | 110 | LANGUAGE_CODE = 'en' 111 | 112 | TIME_ZONE = 'UTC' 113 | 114 | USE_I18N = True 115 | 116 | USE_L10N = True 117 | 118 | USE_TZ = True 119 | 120 | # Reduce the scope of available languages 121 | 122 | LANGUAGES = [ 123 | ('en', _('English')), 124 | ('de', _('German')), 125 | ('es-mx', _('Spanish Mexico')) 126 | ] 127 | 128 | 129 | # Static files (CSS, JavaScript, Images) 130 | # https://docs.djangoproject.com/en/3.1/howto/static-files/ 131 | 132 | STATIC_URL = '/static/' 133 | -------------------------------------------------------------------------------- /i18n_and_pofiles/urls.py: -------------------------------------------------------------------------------- 1 | """i18n_and_pofiles URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.1/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: path('', 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: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import include, path 18 | from django.conf.urls.i18n import i18n_patterns 19 | 20 | urlpatterns = [ 21 | path('admin/', admin.site.urls), 22 | ] 23 | 24 | urlpatterns += i18n_patterns( 25 | path('app1/', include('app1.urls')) 26 | ) 27 | -------------------------------------------------------------------------------- /i18n_and_pofiles/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for i18n_and_pofiles 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/3.1/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', 'i18n_and_pofiles.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'i18n_and_pofiles.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "asgiref" 3 | version = "3.5.2" 4 | description = "ASGI specs, helper code, and adapters" 5 | category = "main" 6 | optional = false 7 | python-versions = ">=3.7" 8 | 9 | [package.extras] 10 | tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] 11 | 12 | [[package]] 13 | name = "backports.zoneinfo" 14 | version = "0.2.1" 15 | description = "Backport of the standard library zoneinfo module" 16 | category = "main" 17 | optional = false 18 | python-versions = ">=3.6" 19 | 20 | [package.extras] 21 | tzdata = ["tzdata"] 22 | 23 | [[package]] 24 | name = "django" 25 | version = "4.1" 26 | description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." 27 | category = "main" 28 | optional = false 29 | python-versions = ">=3.8" 30 | 31 | [package.dependencies] 32 | asgiref = ">=3.5.2,<4" 33 | "backports.zoneinfo" = {version = "*", markers = "python_version < \"3.9\""} 34 | sqlparse = ">=0.2.2" 35 | tzdata = {version = "*", markers = "sys_platform == \"win32\""} 36 | 37 | [package.extras] 38 | argon2 = ["argon2-cffi (>=19.1.0)"] 39 | bcrypt = ["bcrypt"] 40 | 41 | [[package]] 42 | name = "polib" 43 | version = "1.1.1" 44 | description = "A library to manipulate gettext files (po and mo files)." 45 | category = "main" 46 | optional = false 47 | python-versions = "*" 48 | 49 | [[package]] 50 | name = "sqlparse" 51 | version = "0.4.2" 52 | description = "A non-validating SQL parser." 53 | category = "main" 54 | optional = false 55 | python-versions = ">=3.5" 56 | 57 | [[package]] 58 | name = "tzdata" 59 | version = "2022.1" 60 | description = "Provider of IANA time zone data" 61 | category = "main" 62 | optional = false 63 | python-versions = ">=2" 64 | 65 | [metadata] 66 | lock-version = "1.1" 67 | python-versions = "^3.8" 68 | content-hash = "df2462a0866cb472f8cbc5e101c064839df2f7b2809fe17dce6b459e36e06136" 69 | 70 | [metadata.files] 71 | asgiref = [ 72 | {file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"}, 73 | {file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"}, 74 | ] 75 | "backports.zoneinfo" = [ 76 | {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"}, 77 | {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"}, 78 | {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546"}, 79 | {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win32.whl", hash = "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08"}, 80 | {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7"}, 81 | {file = "backports.zoneinfo-0.2.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac"}, 82 | {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf"}, 83 | {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570"}, 84 | {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win32.whl", hash = "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b"}, 85 | {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582"}, 86 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987"}, 87 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1"}, 88 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9"}, 89 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328"}, 90 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"}, 91 | {file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"}, 92 | ] 93 | django = [ 94 | {file = "Django-4.1-py3-none-any.whl", hash = "sha256:031ccb717782f6af83a0063a1957686e87cb4581ea61b47b3e9addf60687989a"}, 95 | {file = "Django-4.1.tar.gz", hash = "sha256:032f8a6fc7cf05ccd1214e4a2e21dfcd6a23b9d575c6573cacc8c67828dbe642"}, 96 | ] 97 | polib = [ 98 | {file = "polib-1.1.1-py2.py3-none-any.whl", hash = "sha256:d3ee85e0c6788f789353416b1612c6c92d75fe6ccfac0029711974d6abd0f86d"}, 99 | {file = "polib-1.1.1.tar.gz", hash = "sha256:e02c355ae5e054912e3b0d16febc56510eff7e49d60bf22aecb463bd2f2a2dfa"}, 100 | ] 101 | sqlparse = [ 102 | {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, 103 | {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, 104 | ] 105 | tzdata = [ 106 | {file = "tzdata-2022.1-py2.py3-none-any.whl", hash = "sha256:238e70234214138ed7b4e8a0fab0e5e13872edab3be586ab8198c407620e2ab9"}, 107 | {file = "tzdata-2022.1.tar.gz", hash = "sha256:8b536a8ec63dc0751342b3984193a3118f8fca2afe25752bb9b7fffd398552d3"}, 108 | ] 109 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "i18n_and_pofiles" 3 | version = "0.1.0" 4 | description = "Testing i18 project" 5 | authors = ["Felix Miño "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.8" 9 | Django = "^4.1" 10 | polib = "^1.1.1" 11 | 12 | [tool.poetry.dev-dependencies] 13 | 14 | [build-system] 15 | requires = ["poetry-core>=1.0.0"] 16 | build-backend = "poetry.core.masonry.api" 17 | -------------------------------------------------------------------------------- /workflow-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixminom/better_i18n_django/4dfd7d24381142df605ef1f556aabf27b84345f5/workflow-diagram.png --------------------------------------------------------------------------------