├── .github └── workflows │ └── test.yml ├── .gitignore ├── .hgignore ├── .travis.yml ├── AUTHORS ├── CHANGELOG.md ├── Changelog ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── TODO ├── doc ├── INSTALL ├── INSTALL.rus.txt ├── README.rus.txt ├── WEBFRAMEWORKS.rus.txt ├── examples-django │ ├── README │ └── pytilsex │ │ ├── __init__.py │ │ ├── manage.py │ │ ├── settings.py │ │ ├── static │ │ ├── css │ │ │ └── style.css │ │ └── images │ │ │ └── header_inner.png │ │ ├── templates │ │ ├── base.html │ │ ├── dt.html │ │ ├── numeral.html │ │ └── translit.html │ │ ├── tests.py │ │ └── urls.py └── examples │ ├── dt.distance_of_time_in_words.py │ ├── dt.ru_strftime.py │ ├── numeral.choose_plural.py │ ├── numeral.in_words.py │ ├── numeral.rubles.py │ ├── numeral.sum_string.py │ ├── test.py │ └── translit.py ├── poetry.lock ├── pyproject.toml ├── pytest.ini ├── pytils ├── __init__.py ├── dt.py ├── numeral.py ├── templatetags │ ├── __init__.py │ ├── pytils_dt.py │ ├── pytils_numeral.py │ └── pytils_translit.py ├── test │ ├── __init__.py │ ├── templatetags │ │ ├── __init__.py │ │ ├── helpers.py │ │ ├── test_common.py │ │ ├── test_dt.py │ │ ├── test_numeral.py │ │ └── test_translit.py │ ├── test_dt.py │ ├── test_numeral.py │ ├── test_translit.py │ ├── test_typo.py │ └── test_utils.py ├── translit.py ├── typo.py └── utils.py ├── setup.py ├── tox.ini └── ty.toml /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # Docs: 2 | # - https://docs.github.com/en/actions/guides/about-service-containers 3 | name: CI 4 | on: [push, pull_request] 5 | jobs: 6 | Lint: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python: ["3.13"] 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: astral-sh/ruff-action@v3 14 | with: 15 | args: "format --check --diff" 16 | - run: ruff check 17 | Typecheck: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | matrix: 21 | python: ["3.13"] 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Set up Python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python }} 28 | - name: Set up env 29 | run: | 30 | python -m pip install -q poetry 31 | poetry install 32 | - name: Run ty 33 | run: poetry run ty check 34 | Test: 35 | runs-on: ubuntu-latest 36 | strategy: 37 | matrix: 38 | python: 39 | - "3.9" 40 | - "3.10" 41 | - "3.11" 42 | - "3.12" 43 | - "3.13" 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | steps: 47 | - uses: actions/checkout@v2 48 | - name: Set up Python ${{ matrix.python-version }} 49 | uses: actions/setup-python@v2 50 | with: 51 | python-version: ${{ matrix.python }} 52 | - name: Set up env 53 | run: | 54 | python -m pip install -q poetry 55 | poetry install 56 | - name: Run tests 57 | run: | 58 | poetry run tox 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *egg-info/ 3 | .tox/ 4 | .venv 5 | .idea 6 | /mydatabase 7 | /dist 8 | -------------------------------------------------------------------------------- /.hgignore: -------------------------------------------------------------------------------- 1 | \.pyc$ 2 | \.egg-info.* 3 | .*~$ 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | - "3.7" 5 | - "3.8" 6 | - "3.9" 7 | - "3.10" 8 | env: 9 | - TOXENV="py36-django32" 10 | - TOXENV="py37-django32" 11 | - TOXENV="py38-django32" 12 | - TOXENV="py39-django32" 13 | - TOXENV="py38-django40" 14 | - TOXENV="py39-django40" 15 | - TOXENV="py310-django40" 16 | matrix: 17 | exclude: 18 | - python: "3.6" 19 | env: TOXENV="py36-django32" 20 | - python: "3.7" 21 | env: TOXENV="py37-django32" 22 | - python: "3.8" 23 | env: TOXENV="py38-django32" 24 | - python: "3.9" 25 | env: TOXENV="py39-django32" 26 | - python: "3.8" 27 | env: TOXENV="py38-django40" 28 | - python: "3.9" 29 | env: TOXENV="py39-django40" 30 | - python: "3.10" 31 | env: TOXENV="py310-django40" 32 | install: pip install tox 33 | script: tox -e $TOXENV 34 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Yury Yurevich 2 | Mikhail Korobov 3 | Evgeny Generalov 4 | Anton Patrushev 5 | Nikita Hismatov 6 | Andrey Mikhaylenko 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [0.4.4](https://github.com/last-partizan/pytils/compare/v0.4.3...v0.4.4) (2025-07-29) 6 | 7 | ### [0.4.3](https://github.com/last-partizan/pytils/compare/v0.4.2...v0.4.3) (2025-04-02) 8 | 9 | ### [0.4.2](https://github.com/last-partizan/pytils/compare/v0.4.1...v0.4.2) (2025-04-02) 10 | 11 | 12 | ### Bug Fixes 13 | 14 | * Fix invalid escape sequence in numeral.choose_plural.py ([#64](https://github.com/last-partizan/pytils/issues/64)) ([a0cc2fa](https://github.com/last-partizan/pytils/commit/a0cc2fa882bb428feb55da77cead4204db2add8b)) 15 | * RegExp syntax error ([#60](https://github.com/last-partizan/pytils/issues/60)) ([3c9ddbe](https://github.com/last-partizan/pytils/commit/3c9ddbe3cfe9df6d443392d35866f16658bbd12b)) 16 | * Remove redundant (?u) inline flags ([#50](https://github.com/last-partizan/pytils/issues/50)) ([1bb022d](https://github.com/last-partizan/pytils/commit/1bb022dd47be3e1a66ebc05025d3f14f5ed511f0)) 17 | * The u prefix for strings is no longer necessary in Python >=3.0 ([#59](https://github.com/last-partizan/pytils/issues/59)) ([#61](https://github.com/last-partizan/pytils/issues/61)) ([61dc896](https://github.com/last-partizan/pytils/commit/61dc896cd33c3dc3b121f94668527b0e69fcc194)) 18 | -------------------------------------------------------------------------------- /Changelog: -------------------------------------------------------------------------------- 1 | pytils (0.4dev) 2 | 3 | * Fix support Django 3.2, 4 4 | * Remove support Django 1, 2 5 | * Fix test and dependency 6 | 7 | -- Artem Shkapov Thu, 16 Feb 2022 8 | 9 | pytils (0.3) 10 | 11 | * Add typography 12 | * License updated to MIT 13 | * Remove support for old non-unicode Django 14 | * Remove dependency of redudant type checks 15 | 16 | -- Yury Yurevich Thu, 22 Aug 2013 24:10:06 -0700 17 | 18 | pytils (0.2.3) 19 | 20 | * Add support for Django unicode branch (#27) 21 | * Make pytils.test to be optional package, not required (#26) 22 | * Make checks of input parameters by decorators (#18) 23 | * Python-2.3 is not supported now (decorators are used) 24 | * Add custom exception pytils.err.InputParameterError, inherited from TypeError 25 | 26 | -- Yury Yurevich Fri, 15 Aug 2008 20:45:12 +0700 27 | 28 | pytils (0.2.2) 29 | 30 | * fix zeros in dates (#24) 31 | * add 'preposition' option to dt.ru_strftime (#23) 32 | * fix bugs (#20, #22, #25) 33 | * add get_plural to pytils.numeral (#19) 34 | * add escaping in variants (#21) 35 | 36 | -- Yury Yurevich Thu, 12 Jul 2007 17:31:12 +0700 37 | 38 | pytils (0.2.1) 39 | 40 | * fix bugs (#10, #14, #15) 41 | * improve gender manipulation in numeral (issue#13) 42 | * add GPL header 43 | * add unit-tests for templatetags 44 | 45 | -- Yury Yurevich Tue, 27 Feb 2007 21:08:31 +0600 46 | 47 | pytils (0.2.0) 48 | 49 | * integration with Django (templatetags) 50 | * examples for Django and TurboGears 51 | * remove asserts 52 | * add datatime.datetime type for distance_of_time_in_words 53 | * improve docs 54 | * make eggs 55 | 56 | -- Yury Yurevich Sun, 29 Oct 2006 23:30:17 +0600 57 | 58 | pytils (0.1.0) 59 | 60 | * initial release 61 | 62 | -- Yury Yurevich Sat, 2 Sep 2006 22:52:37 +0600 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2006-2012 Yury Yurevich and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include doc * 2 | include AUTHORS 3 | include LICENSE 4 | include TODO 5 | include README 6 | include setup.cfg 7 | include Changelog 8 | 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | check: build 2 | twine check 3 | 4 | publish: build 5 | twine upload dist/* 6 | 7 | build: clean 8 | python -m build 9 | 10 | clean: 11 | rm -rf dist 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Pytils 2 | ------ 3 | 4 | Pytils is a Russian-specific string utils 5 | (transliteration, numeral is words, russian dates, etc) 6 | 7 | ----- 8 | 9 | Pytils это инструменты для работы 10 | с русскими строками (транслитерация, числительные словами, русские даты и т.д.) 11 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | see also http://www.bitbucket.org/j2a/pytils/issues/ 2 | 3 | * add Typografica 4 | -------------------------------------------------------------------------------- /doc/INSTALL: -------------------------------------------------------------------------------- 1 | pytils installation 2 | =================== 3 | 4 | Install via easy_install 5 | ------------------------ 6 | 7 | You can install the most recent pytils version using 8 | [easy_install](http://peak.telecommunity.com/DevCenter/EasyInstall) 9 | 10 | easy_install pytils 11 | 12 | 13 | Install from tarball 14 | ----------------------------- 15 | 16 | 1. Download the recent tarball from http://cheeseshop.python.org/pypi/pytils/ 17 | 2. Unpack the tarball 18 | 3. Run `python setup.py install` 19 | 20 | 21 | Install the development version 22 | -------------------------------- 23 | 24 | If you want to be on a edge, you can install development version via easy_install: 25 | 26 | easy_install pytils==dev 27 | 28 | or get pytils snapshot from repository http://hg.pyobject.ru/pytils/archive/tip.zip 29 | -------------------------------------------------------------------------------- /doc/INSTALL.rus.txt: -------------------------------------------------------------------------------- 1 | Установка pytils 2 | ================ 3 | 4 | Установка с использованием easy_install 5 | --------------------------------------- 6 | 7 | Рекомендуемый способ установки pytils -- при помощи 8 | [easy_install](http://peak.telecommunity.com/DevCenter/EasyInstall) 9 | 10 | easy_install pytils 11 | 12 | 13 | Установка из исходных текстов 14 | ----------------------------- 15 | 16 | 1. Скачайте последнюю версию с http://cheeseshop.python.org/pypi/pytils/ 17 | 2. Разархивируйте архив 18 | 3. Выполните `python setup.py install` 19 | 20 | 21 | Установка разрабатываемой версии 22 | -------------------------------- 23 | 24 | Если у Вас есть желание как можно быстрее получать исправления ошибок или 25 | использовать последние улучшения pytils, то Вы можете установить свежий 26 | срез репозитория pytils при помощи easy_install: 27 | 28 | easy_install pytils==dev 29 | 30 | Если Вы не используете по каким-либо причинам easy_install, то срез доступен 31 | по адресу: http://hg.pyobject.ru/pytils/archive/tip.zip 32 | -------------------------------------------------------------------------------- /doc/README.rus.txt: -------------------------------------------------------------------------------- 1 | pytils - простой обработчик русского текста. 2 | ========================================================== 3 | 4 | Кратко 5 | ------ 6 | 7 | pytils - простой обработчик русского текста, реализован на Python. 8 | Идея позаимствована у [Julik](http://live.julik.nl) и его 9 | [RuTils](http://rutils.rubyforge.org/). 10 | 11 | Ссылки 12 | ------ 13 | 14 | * [pytils на github](http://github.com/j2a/pytils/) 15 | 16 | Как установить 17 | -------------- 18 | 19 | Смотрите INSTALL (или INSTALL.rus.txt) 20 | 21 | Как использовать 22 | ---------------- 23 | 24 | Во-первых, **все** входящие строки - unicode. И выходящие - тоже (за малыми 25 | исключениями, о них ниже). В случае, если Вы передадите str, получите 26 | AssertionError. 27 | 28 | pytils содержит следующие модули: 29 | 30 | 1. `numeral` - для обработки числительных 31 | 2. `dt` - русские даты без локалей 32 | 3. `translit` - транслитерация 33 | 34 | pytils легко интегрируется с популярными Web-фреймворками (Django, Flask), 35 | подробнее об этом смотрите в WEBFRAMEWORKS.rus.txt. 36 | 37 | Примеры смотрите в каталоге examples. 38 | 39 | Числительные 40 | ------------ 41 | 42 | pytils умеет выбирать правильный падеж в зависимости от числа 43 | 44 | >>> pytils.numeral.choose_plural(15, (u"гвоздь", u"гвоздя", u"гвоздей")) 45 | u'гвоздей' 46 | 47 | 48 | В качестве второго параметра передается кортеж с вариантами (либо строка, 49 | где варианты перечисляются через запятую). Чтобы легко запомнить, 50 | в каком порядке указывать варианты, пользуйтесь мнемоническим 51 | правилом: один-два-пять - один гвоздь, два гвоздя, пять гвоздей. 52 | 53 | Часто нужен не просто вариант, а число вместе с текстом 54 | 55 | >>> pytils.numeral.get_plural(15, u"гвоздь, гвоздя, гвоздей") 56 | u'15 гвоздей' 57 | 58 | В get_plural можно еще передать вариант, когда число -- ноль. Т.е. чтобы 59 | было не '0 гвоздей', а 'гвоздей нет': 60 | 61 | >>> pytils.numeral.get_plural(0, u"гвоздь, гвоздя, гвоздей") 62 | u'0 гвоздей' 63 | >>> pytils.numeral.get_plural(0, u"гвоздь, гвоздя, гвоздей", absence=u"гвоздей нет") 64 | u'гвоздей нет' 65 | 66 | 67 | Также pytils реализует числа прописью 68 | 69 | >>> pytils.numeral.in_words(254) 70 | u'двести пятьдесят четыре' 71 | >>> pytils.numeral.in_words(2.01) 72 | u'две целых одна сотая' 73 | >>> pytils.numeral.rubles(2.01) 74 | u'два рубля одна копейка' 75 | >>> pytils.numeral.sum_string(32, pytils.numeral.MALE, (u"гвоздь", u"гвоздя", u"гвоздей")) 76 | u'тридцать два гвоздя' 77 | >>> pytils.numeral.sum_string(21, pytils.numeral.FEMALE, u"белка, белки, белок") 78 | u'двадцать одна белка' 79 | 80 | Даты 81 | ---- 82 | 83 | В pytils можно получить русские даты без использования локалей. 84 | 85 | >>> pytils.dt.ru_strftime(u"сегодня - %d %B %Y, %A", inflected=True, date=datetime.date(2006, 9, 2)) 86 | u'сегодня - 2 сентября 2006, суббота' 87 | 88 | >>> pytils.dt.ru_strftime(u"сделано %A, %d %B %Y", inflected=True, preposition=True) 89 | u'сделано во вторник, 10 июля 2007' 90 | 91 | Есть возможность получить величину периода: 92 | 93 | >>> pytils.dt.distance_of_time_in_words(time.time()-10000) 94 | u'2 часа назад' 95 | >>> pytils.dt.distance_of_time_in_words(datetime.datetime.now()+datetime.timedelta(0,10000), accuracy=2) 96 | u'через 2 часа 46 минут' 97 | 98 | Транслитерация 99 | -------------- 100 | 101 | При помощи pytils можно сделать транслитерацию: 102 | 103 | >>> print(pytils.translit.translify(u"Проверка связи")) 104 | 'Proverka svyazi' 105 | >>> pytils.translit.detranslify("Proverka svyazi") 106 | u'Проверка связи' 107 | 108 | В translify вывод - str, а не unicode. В detranslify вход может быть как 109 | unicode, так и str. 110 | 111 | И сделать строку для URL (удаляются лишние символы, пробелы заменяются на 112 | дефисы): 113 | 114 | >>> pytils.translit.slugify(u"тест и еще раз тест") 115 | 'test-i-esche-raz-test' 116 | 117 | -------------------------------------------------------------------------------- /doc/WEBFRAMEWORKS.rus.txt: -------------------------------------------------------------------------------- 1 | Полнофункциональные примеры смотрите: 2 | 3 | * для Django в examples-django 4 | 5 | -------------------------------------------------------------------------------- /doc/examples-django/README: -------------------------------------------------------------------------------- 1 | Examples of usage pytils with Django. 2 | ===================================== 3 | 4 | You need installed Django (3.2 and higher) and pytils 5 | for run it. 6 | 7 | It's a regular Django-project, so just start it 8 | 9 | python manage.py runserver 10 | 11 | go to http://127.0.0.1:8000/ and look it... 12 | 13 | ------------------------------------ 14 | 15 | Пример использования pytils с Django. 16 | ==================================== 17 | 18 | Чтобы запустить примеры, Вам понадобятся установленные 19 | Django (версии 3.2 и выше) и pytils. 20 | 21 | Это обычный Django-проект. Запускайте 22 | 23 | python manage.py runserver 24 | 25 | заходите браузером на http://127.0.0.1:8000/ и смотрите... 26 | -------------------------------------------------------------------------------- /doc/examples-django/pytilsex/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of usage pytils with Django 3 | """ 4 | -------------------------------------------------------------------------------- /doc/examples-django/pytilsex/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", "settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /doc/examples-django/pytilsex/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for pytilsex project. 2 | 3 | # find current path 4 | import os 5 | 6 | BASE_DIR = os.path.dirname(os.path.normpath(os.path.abspath(__file__))) 7 | 8 | DEBUG = True 9 | TEMPLATE_DEBUG = DEBUG 10 | DEFAULT_CHARSET = "utf-8" 11 | 12 | ADMINS = (("Pythy", "the.pythy@gmail.com"),) 13 | 14 | DATABASES = { 15 | "default": {"NAME": "pytils_example", "ENGINE": "django.db.backends.sqlite3"} 16 | } 17 | 18 | MANAGERS = ADMINS 19 | 20 | # Local time zone for this installation. All choices can be found here: 21 | # http://www.postgresql.org/docs/current/static/datetime-keywords.html#DATETIME-TIMEZONE-SET-TABLE 22 | TIME_ZONE = "UTC" 23 | USE_TZ = False 24 | 25 | # Language code for this installation. All choices can be found here: 26 | # http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes 27 | # http://blogs.law.harvard.edu/tech/stories/storyReader$15 28 | LANGUAGE_CODE = "ru-ru" 29 | 30 | SITE_ID = 1 31 | 32 | # Absolute path to the directory that holds media. 33 | # Example: "/home/media/media.lawrence.com/" 34 | MEDIA_ROOT = os.path.join(BASE_DIR, "static") 35 | 36 | # URL that handles the media served from MEDIA_ROOT. 37 | # Example: "http://media.lawrence.com" 38 | MEDIA_URL = "" 39 | 40 | # URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a 41 | # trailing slash. 42 | # Examples: "http://foo.com/media/", "/media/". 43 | ADMIN_MEDIA_PREFIX = "/media/" 44 | 45 | STATICFILES_DIRS = (MEDIA_ROOT,) 46 | 47 | STATIC_URL = "/static/" 48 | 49 | # Make this unique, and don't share it with anybody. 50 | SECRET_KEY = "-)^ay7gz76#9!j=ssycphb7*(gg74zhx9h-(j_1k7!wfr7j(o^" 51 | 52 | 53 | MIDDLEWARE_CLASSES = ("django.middleware.common.CommonMiddleware",) 54 | 55 | ROOT_URLCONF = "pytilsex.urls" 56 | 57 | TEMPLATES = [ 58 | { 59 | "BACKEND": "django.template.backends.django.DjangoTemplates", 60 | "DIRS": [os.path.join(BASE_DIR, "templates")], 61 | }, 62 | ] 63 | 64 | 65 | INSTALLED_APPS = ( 66 | # -- install pytils 67 | "pytils", 68 | ) 69 | 70 | 71 | # is value will shown at error in pytils (default - False) 72 | # PYTILS_SHOW_VALUES_ON_ERROR = True 73 | -------------------------------------------------------------------------------- /doc/examples-django/pytilsex/static/css/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Quick mash-up of CSS for the TG quick start page with Django colors. 3 | */ 4 | 5 | html, body, th, td { 6 | color: black; 7 | background-color: #ddd; 8 | font: x-small "Lucida Grande", "Lucida Sans Unicode", geneva, verdana, sans-serif; 9 | margin: 0; 10 | padding: 0; 11 | } 12 | 13 | #header { 14 | height: 80px; 15 | width: 777px; 16 | background: green URL('../images/header_inner.png') no-repeat; 17 | border-left: 1px solid #aaa; 18 | border-right: 1px solid #aaa; 19 | margin: 0 auto 0 auto; 20 | } 21 | 22 | a.link, a, a.active { 23 | color: #ab5603; 24 | } 25 | 26 | a:hover { 27 | background-color: #e0ffb8; 28 | color: #234f32; 29 | } 30 | 31 | 32 | #main_content { 33 | color: black; 34 | font-size: 127%; 35 | background-color: white; 36 | width: 757px; 37 | margin: 0 auto 0 auto; 38 | border-left: 1px solid #aaa; 39 | border-right: 1px solid #aaa; 40 | padding: 10px; 41 | } 42 | 43 | 44 | h1,h2,h3,h4,h5,h6,#getting_started_steps { 45 | font-family: "Century Schoolbook L", Georgia, serif; 46 | font-weight: bold; 47 | } 48 | 49 | h2 { 50 | font-size:150% 51 | } 52 | 53 | #footer { 54 | border: 1px solid #aaa; 55 | border-top: 0px none; 56 | color: #999; 57 | background-color: white; 58 | padding: 10px; 59 | font-size: 90%; 60 | text-align: center; 61 | width: 757px; 62 | margin: 0 auto 1em auto; 63 | } 64 | 65 | .code { 66 | font-family: monospace; 67 | } 68 | 69 | pre { 70 | background-color: #e0ffb8; 71 | border: 1px solid #94da3a; 72 | border-width: 1px 0; 73 | } 74 | 75 | span.code { 76 | font-weight: bold; 77 | background: #eee; 78 | } 79 | 80 | -------------------------------------------------------------------------------- /doc/examples-django/pytilsex/static/images/header_inner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/last-partizan/pytils/62a693501212d949a3ea43d58cbcb19a0a66f8be/doc/examples-django/pytilsex/static/images/header_inner.png -------------------------------------------------------------------------------- /doc/examples-django/pytilsex/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | pytils demo 8 | 11 | 12 | 13 | 14 | 15 |
16 |

pytils demo

17 | {% block content %} 18 |

Для того, чтобы воспользоваться pytils в Django, нужно: 19 |

    20 |
  1. Установить pytils (как это сделать написано в INSTALL, в архиве с pytils)
  2. 21 |
  3. Добавить 'pytils' в INSTALLED_APPS
  4. 22 |
  5. В шаблоне загрузить соответствующий компонент pytils (подробности см. в 23 | примерах к компонентам)
  6. 24 |
  7. Вставить в нужном месте искомый тег/фильтр.
  8. 25 |
26 |

27 |

28 | Компоненты pytils, доступные для загрузки: 29 |

37 |

38 |

Замечание: 39 | В зависимости от обстановки pytils по разному реагирует на ошибки при 40 | обработке тега/фильтра: 41 |

    42 |
  • Если в режиме отладки (в settings указано DEBUG = True), то подставляется 43 | "unknown: краткое описание ошибки"
  • 44 |
  • Если не в режиме отладки, то вместо значений подставляется пустая 45 | строка
  • 46 |
  • Если не в режиме отладки, но в settings указано 47 | PYTILS_SHOW_VALUES_ON_ERROR = True, то вместо значений 48 | подставляются переданные параметры. Подробности см. в описании 49 | тегов/фильтров.
  • 50 |
51 |

52 | 53 |

Протестировано с

54 |
    55 |
  • Python 2.7 Django 1.3
  • 56 |
  • Python 2.7 Django 1.4
  • 57 |
  • Python 2.7 Django 1.5
  • 58 |
  • Python 2.7 Django 1.6
  • 59 |
  • Python 3.3 Django 1.5
  • 60 |
  • Python 3.3 Django 1.6
  • 61 |
62 |

Данный пример работает с Django {{ django_version }} на Python {{ python_version }} с использованием pytils {{ pytils_version }}.

63 | 64 | {% endblock %} 65 |
66 | 67 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /doc/examples-django/pytilsex/templates/dt.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 | 4 | {% load pytils_dt %} 5 |

pytils_dt filters demo

6 | 7 |

Для загрузки компоненты, в шаблон вставьте код: 8 | 9 |

 10 | {% templatetag openblock %} load pytils_dt {% templatetag closeblock %}
 11 | 
12 |

13 | 14 |

Фильтры

15 |

Для наглядности, текст подставленный фильтром выделен курсивом.

16 | 17 |

distance_of_time

18 |

Например, тест прошлого времени был {{ otime|distance_of_time }}. 19 | Если более точно, то {{ otime|distance_of_time:2 }}. Нужно ли еще 20 | более точно? Пожалуйста - это было {{ otime|distance_of_time:3 }}. 21 |

22 | 23 |

Точно так же (т.е. для Вас абсолютно прозрачно) и с будущим временем - 24 | следующий тест будет {{ ftime|distance_of_time }}.

25 | 26 |

distance_of_time умеет работать с обеими типами времени, 27 | представленных в Python: с datetime.datetime и time.time. 28 | Например, {{ fdate }} будет {{ fdate|distance_of_time }}.

29 | 30 | 31 |

Сделано это так: 32 |

 33 | <p>Например, тест прошлого времени был <em>{% templatetag openvariable %} otime|distance_of_time {% templatetag closevariable %}</em>.
 34 | Если более точно, то <em>{% templatetag openvariable %} otime|distance_of_time:2 {% templatetag closevariable %}</em>. Нужно ли еще
 35 | более точно? Пожалуйста - это было <em>{% templatetag openvariable %} otime|distance_of_time:3 {% templatetag closevariable %}</em>.
 36 | </p>
 37 | 
 38 | <p>Точно так же (т.е. для Вас абсолютно прозрачно) и с будущим временем -
 39 | следующий тест будет <em>{% templatetag openvariable %} ftime|distance_of_time {% templatetag closevariable %}</em>.</p>
 40 | 
 41 | <p><code>distance_of_time</code> умеет работать с обеими типами времени, 
 42 | представленных в Python: с <code>datetime.datetime</code> и <code>time.time</code>.
 43 | Например, {% templatetag openvariable %} fdate {% templatetag closevariable %} будет  <em>{% templatetag openvariable %} fdate|distance_of_time {% templatetag closevariable %}</em>.</p>
 44 | 

45 | 46 |

Если включен режим PYTILS_SHOW_VALUES_ON_ERROR, то при ошибке 47 | будет отображена разница во времени в секундах, либо пустая строка (если 48 | получить разницу не удалось). 49 |

50 | 51 |

ru_strftime

52 |

Тоже всё просто - используем обычный формат strftime, в котором %a, %A, %b, 53 | %B заменены на русские.

54 | 55 |

К примеру, текущая дата: {{ cdate|ru_strftime:"%d %B %Y, %A" }}.

56 | 57 |

Код таков: 58 |

 59 | <p>К примеру, текущая дата: <em>{% templatetag openvariable %} cdate|ru_strftime:"%d %B %Y, %A" {% templatetag closevariable %}</em>.</p>
 60 | 

61 | 62 |

Если включен режим PYTILS_SHOW_VALUES_ON_ERROR, то при ошибке 63 | будет отображена дата с использованием оригинального strftime (т.е. с 64 | английскими днями/месяцами), либо пустая строка (если strftime не получилось 65 | выполнить). 66 |

67 | 68 |

ru_strftime_inflected

69 |

Аналогично ru_strftime, только день склоняется. Т.е. текущий тест был 70 | выполнен в {{ cdate|ru_strftime_inflected:"%A, %d %B %Y" }} 71 | 72 |

73 | В шаблоне запись такова: 74 |


 75 | <p>Аналогично <code>ru_strftime</code>, только день склоняется. Т.е. текущий тест был 
 76 | выполнен в <em>{% templatetag openvariable %} cdate|ru_strftime_inflected:"%A, %d %B %Y" {% templatetag closevariable %}</em>
 77 | 
78 |

79 | 80 |

Если включен режим PYTILS_SHOW_VALUES_ON_ERROR, то при ошибке 81 | будет отображена дата с использованием оригинального strftime (т.е. с 82 | английскими днями/месяцами), либо пустая строка (если strftime не получилось 83 | выполнить). 84 |

85 | 86 |

ru_strftime_preposition

87 |

Аналогично ru_strftime_inflected, только добавляется правильный предлог. Т.е. текущий тест был 88 | выполнен {{ cdate|ru_strftime_preposition:"%A, %d %B %Y" }} 89 | 90 |

91 | В шаблоне запись такова: 92 |


 93 | <p>Аналогично <code>ru_strftime</code>, только добавляется правильный предлог. Т.е. текущий тест был 
 94 | выполнен <em>{% templatetag openvariable %} cdate|ru_strftime_preposition:"%A, %d %B %Y" {% templatetag closevariable %}</em>
 95 | 
96 |

97 | 98 |

Если включен режим PYTILS_SHOW_VALUES_ON_ERROR, то при ошибке 99 | будет отображена дата с использованием оригинального strftime (т.е. с 100 | английскими днями/месяцами), либо пустая строка (если strftime не получилось 101 | выполнить). 102 |

103 | 104 | 105 | {% endblock %} 106 | -------------------------------------------------------------------------------- /doc/examples-django/pytilsex/templates/numeral.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 | 4 | {% load pytils_numeral %} 5 |

pytils_numeral filters/tags demo

6 | 7 |

Для загрузки компоненты, в шаблон вставьте код: 8 | 9 |

 10 | {% templatetag openblock %} load pytils_numeral {% templatetag closeblock %}
 11 | 
12 |

13 | 14 |

Фильтры

15 |

Для наглядности, текст подставленный фильтром выделен курсивом.

16 | 17 |

choose_plural и get_plural

18 |

Выбор нужной формы множественного числа. Классический пример с количеством 19 | комментариев: {{ comment_number }} {{ comment_number|choose_plural:comment_variants }} 20 |

21 | 22 |

В качестве аргумента можно передавать не только список вариантов, но и 23 | варианты в одну строку, например: так {{ comment_number }} 24 | комментари{{ comment_number|choose_plural:"й,я,ев" }} или так 25 | {{ comment_number }} {{ comment_number|choose_plural:"комментарий, комментария, комментариев" }} 26 | - как Вам больше нравится.

27 | 28 |

Сделано это так: 29 |

 30 | <p>Выбор нужной формы множественного числа. Классический пример с количеством 
 31 | комментариев: {% templatetag openvariable %} comment_number {% templatetag closevariable %} 
 32 | <em>{% templatetag openvariable %} comment_number|choose_plural:comment_variants {% templatetag closevariable %}</em>
 33 | </p>
 34 | 
 35 | <p>В качестве аргумента можно передавать не только список вариантов, но и 
 36 | варианты в одну строку, например: так {% templatetag openvariable %} comment_number {% templatetag closevariable %} 
 37 | комментари<em>{% templatetag openvariable %} comment_number|choose_plural:"й,я,ев" {% templatetag closevariable %}</em> или так 
 38 | {% templatetag openvariable %} comment_number {% templatetag closevariable %} 
 39 | <em>{% templatetag openvariable %} comment_number|choose_plural:"комментарий, комментария, комментариев" {% templatetag closevariable %}</em>
 40 | - как Вам больше нравится.</p>
 41 | 

42 | 43 |

Зачастую нужно показывать и число, и название объекта в правильной форме, а не только название 44 | объекта. В этом случае следует воспользоваться фильтром get_plural. Пример с теми же 45 | комментариями можно записать проще: 46 | {{ comment_number|get_plural:"комментарий,комментария,комментариев" }}. 47 | get_plural удобен еще и тем, что можно указать вариант, когда значение равно нулю. 48 | Например, гораздо симпатичней "без комментариев", чем "0 комментариев". В этом случае к вариантам 49 | нужно просто добавить еще один - нуль-вариант. Пример: 50 | {{ zero|get_plural:"пример,примера,примеров,без примеров" }}.

51 | 52 |

Сделано это так: 53 |

 54 | <p>Зачастую нужно показывать и число, и название объекта в правильной форме, а не только название
 55 | объекта. В этом случае следует воспользоваться фильтром <code>get_plural</em>. Пример с теми же
 56 | комментариями можно записать проще: 
 57 | <em>{% templatetag openvariable %} comment_number|get_plural:"комментарий,комментария,комментариев" {% templatetag closevariable %}</em>.
 58 | <code>get_plural</code> удобен еще и тем, что можно указать вариант, когда значение равно нулю.
 59 | Например, гораздо симпатичней "без комментариев", чем "0 комментариев". В этом случае к вариантам
 60 | нужно просто добавить еще один - нуль-вариант. Пример: 
 61 | <em>{% templatetag openvariable %} zero|get_plural:"пример,примера,примеров,без примеров" {% templatetag closevariable %}.</em></p>
 62 | 

63 | 64 |

Если включен режим PYTILS_SHOW_VALUES_ON_ERROR, то при ошибке отображается 65 | первый вариант либо пустая строка (если получить первый вариант не 66 | получилось).

67 | 68 |

rubles

69 |

Рубли словами. К примеру, {{ rubles_value }} р. словами будет {{ rubles_value|rubles }}. У этого фильтра есть один 70 | параметр, определяющий, нужно ли нулевые копейки "проговаривать". Если нужно - то True, по умолчанию rubles этого не делает. 71 | Пример: {{ rubles_value2 }} р. словами будет {{ rubles_value2|rubles }}, а с копейками - 72 | {{ rubles_value2|rubles:"True" }}.

73 | 74 |

В шаблоне этот фрагмент записан так: 75 |

 76 | <p>Рубли словами. К примеру, {% templatetag openvariable %} rubles_value {% templatetag closevariable %} р. словами будет 
 77 | <em>{% templatetag openvariable %} rubles_value|rubles {% templatetag closevariable %}</em>. 
 78 | У этого фильтра есть один параметр, определяющий, нужно ли нулевые копейки "проговаривать". Если нужно - 
 79 | то True, по умолчанию <code>rubles</code> этого не делает. Пример: {% templatetag openvariable %} rubles_value2 {% templatetag closevariable %} р. 
 80 | словами будет <em>{% templatetag openvariable %} rubles_value2|rubles {% templatetag closevariable %}</em>, а с копейками - 
 81 | <em>{% templatetag openvariable %} rubles_value2|rubles:"True" {% templatetag closevariable %}</em>.</p>
 82 | 

83 | 84 |

Если включен режим PYTILS_SHOW_VALUES_ON_ERROR, то при ошибке отображается 85 | просто число.

86 | 87 |

in_words

88 |

Число словами. Можно целые, можно дробные. Примеры: {{ int_value }} - 89 | {{ int_value|in_words }}. У целых можно менять пол (по умолчанию - 90 | мужской, MALE): {{ int_value|in_words:"FEMALE" }} (женский), 91 | {{ int_value|in_words:"NEUTER" }} (средний).

92 | 93 |

У дробных почти то же самое, только пол всегда женский и не меняется (т.е. 94 | параметр передавать можно, но он не будет влиять). {{ float_value }} 95 | словами будет {{ float_value|in_words }}.

96 | 97 |

В коде это так: 98 |

 99 | <p>Число словами. Можно целые, можно дробные. Примеры: {% templatetag openvariable %} int_value {% templatetag closevariable %} - 
100 | <em>{% templatetag openvariable %} int_value|in_words {% templatetag closevariable %}</em>. У целых можно менять пол (по умолчанию - 
101 | мужской, MALE): <em>{% templatetag openvariable %} int_value|in_words:"FEMALE" {% templatetag closevariable %}</em> (женский),
102 | <em>{% templatetag openvariable %} int_value|in_words:"NEUTER" {% templatetag closevariable %}</em> (средний).</p>
103 | 
104 | <p>У дробных почти то же самое, только пол всегда женский и не меняется (т.е. 
105 | параметр передавать можно, но он не будет влиять). {% templatetag openvariable %} float_value {% templatetag closevariable %} 
106 | словами будет <em>{% templatetag openvariable %} float_value|in_words {% templatetag closevariable %}</em>.</p>
107 | 

108 | 109 |

Если включен режим PYTILS_SHOW_VALUES_ON_ERROR, то при ошибке отображается 110 | просто число.

111 | 112 | 113 |

Теги

114 |

Пока только один.

115 | 116 |

sum_string

117 |

Наиболее общая функция для работы с числами. Умеет "проговаривать" числа и 118 | одновременно подставлять название объекта в нужной форме. Например, вместо 119 | {{ comment_number }} комментарий(ев) можно смело писать 120 | {% sum_string comment_number comment_gender comment_variants %} 121 |

122 | 123 |

В коде это реализовано так: 124 |

125 | <p>Наиболее общая функция для работы с числами. Умеет "проговаривать" числа и 
126 | одновременно подставлять название объекта в нужной форме. Например, вместо 
127 | {% templatetag openvariable %} comment_number {% templatetag closevariable %} комментарий(ев) можно смело писать 
128 | <em>{% templatetag openblock %} sum_string comment_number comment_gender comment_variants {% templatetag closeblock %}</em>
129 | </p>
130 | 
131 | 132 |

Если включен режим PYTILS_SHOW_VALUES_ON_ERROR, то при ошибке отображается 133 | просто число (без названия объекта).

134 | 135 | {% endblock %} 136 | -------------------------------------------------------------------------------- /doc/examples-django/pytilsex/templates/translit.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 | 4 | {% load pytils_translit %} 5 |

pytils_translit filters demo

6 | 7 |

Для загрузки компоненты, в шаблон вставьте код: 8 | 9 |

 
10 | {% templatetag openblock %} load pytils_translit {% templatetag closeblock %}
11 | 
12 |

13 | 14 |

Фильтры

15 |

Для наглядности, текст подставленный фильтром выделен курсивом.

16 | 17 |

translify

18 |

Простая транслитерация, из текста

{{ text }}
19 | получается
{{ text|translify }}

20 | 21 |

В шаблоне записано так: 22 |

23 | <p>Простая транслитерация, из текста <blockquote>{% templatetag openvariable %} text {% templatetag closevariable %}</blockquote> 
24 | получается <blockquote><em>{% templatetag openvariable %} text|translify {% templatetag closevariable %}</em></blockquote></p>
25 | 

26 | 27 |

Если включен режим PYTILS_SHOW_VALUES_ON_ERROR, то при ошибке 28 | отображается оригинальный текст.

29 | 30 |

detranslify

31 |

Простая детранслитерация, из текста

{{ translit }}
32 | получается
{{ translit|detranslify }}

33 | 34 |

В шаблоне записано так: 35 |

36 | <p>Простая детранслитерация, из текста <blockquote>{% templatetag openvariable %} translit {% templatetag closevariable %}</blockquote> 
37 | получается <blockquote><em>{% templatetag openvariable %} translit|detranslify {% templatetag closevariable %}</em></blockquote></p>
38 | 

39 | 40 |

Если включен режим PYTILS_SHOW_VALUES_ON_ERROR, то при ошибке 41 | отображается оригинальный текст.

42 | 43 | 44 |

slugify

45 |

Подготовка текста для URL. Из текста

{{ text }}
46 | получается slug
{{ text|slugify }}
47 | Также возможна обработка и английского текста: например из
{{ translit }}
48 | получается slug
{{ translit|slugify }}

49 | 50 | 51 |

В шаблоне это всё записано так: 52 |

53 | <p>Подготовка текста для URL. Из текста <blockquote>{% templatetag openvariable %} text {% templatetag closevariable %}</blockquote> 
54 | получается slug <blockquote><em>{% templatetag openvariable %} text|slugify {% templatetag closevariable %}</em></blockquote>
55 | Также возможна обработка и английского текста: например из <blockquote>{% templatetag openvariable %} translit {% templatetag closevariable %}</blockquote>
56 | получается slug <blockquote><em>{% templatetag openvariable %} translit|slugify {% templatetag closevariable %}</em></blockquote></p>
57 | 

58 | 59 |

Если включен режим PYTILS_SHOW_VALUES_ON_ERROR, то при ошибке 60 | отображается оригинальный текст.

61 | 62 | {% endblock %} 63 | -------------------------------------------------------------------------------- /doc/examples-django/pytilsex/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.test.client import Client 3 | from django.urls import reverse 4 | 5 | from pytils import VERSION as pytils_version 6 | 7 | 8 | class ExamplesTestCase(TestCase): 9 | def setUp(self): 10 | self.c = Client() 11 | 12 | def testIndex(self): 13 | resp = self.c.get(reverse("pytils_example")) 14 | self.assertEqual(resp.status_code, 200) 15 | body = resp.content.decode("utf-8") 16 | self.assertTrue("pytils %s" % pytils_version in body) 17 | self.assertTrue(reverse("pytils_dt_example") in body) 18 | self.assertTrue(reverse("pytils_numeral_example") in body) 19 | self.assertTrue(reverse("pytils_translit_example") in body) 20 | 21 | def testDt(self): 22 | resp = self.c.get(reverse("pytils_dt_example")) 23 | self.assertEqual(resp.status_code, 200) 24 | body = resp.content.decode("utf-8") 25 | self.assertTrue("distance_of_time" in body) 26 | self.assertTrue("ru_strftime" in body) 27 | self.assertTrue("ru_strftime_inflected" in body) 28 | self.assertTrue("ru_strftime_preposition" in body) 29 | self.assertTrue("вчера" in body) 30 | self.assertTrue("завтра" in body) 31 | 32 | def testNumeral(self): 33 | resp = self.c.get(reverse("pytils_numeral_example")) 34 | self.assertEqual(resp.status_code, 200) 35 | body = resp.content.decode("utf-8") 36 | self.assertTrue("choose_plural" in body) 37 | self.assertTrue("get_plural" in body) 38 | self.assertTrue("rubles" in body) 39 | self.assertTrue("in_words" in body) 40 | self.assertTrue("sum_string" in body) 41 | self.assertTrue("комментарий" in body) 42 | self.assertTrue("без примеров" in body) 43 | self.assertTrue("двадцать три рубля пятнадцать копеек" in body) 44 | self.assertTrue("двенадцать рублей" in body) 45 | self.assertTrue("двадцать один" in body) 46 | self.assertTrue("тридцать одна целая триста восемьдесят пять тысячных" in body) 47 | self.assertTrue("двадцать один комментарий" in body) 48 | 49 | def testTranslit(self): 50 | resp = self.c.get(reverse("pytils_translit_example")) 51 | self.assertEqual(resp.status_code, 200) 52 | body = resp.content.decode("utf-8") 53 | self.assertTrue("translify" in body) 54 | self.assertTrue("detranslify" in body) 55 | self.assertTrue("slugify" in body) 56 | self.assertTrue("Primer trasliteratsii sredstvami pytils" in body) 57 | self.assertTrue("primer-trasliteratsii-sredstvami-pytils" in body) 58 | self.assertTrue("primer-obratnoj-transliteratsii" in body) 59 | -------------------------------------------------------------------------------- /doc/examples-django/pytilsex/urls.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import sys 3 | import time 4 | 5 | from django import VERSION as _django_version 6 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 7 | from django.urls import path 8 | from django.views.generic.base import TemplateView 9 | 10 | from pytils import VERSION as pytils_version 11 | 12 | 13 | def get_python_version(): 14 | return ".".join(str(v) for v in sys.version_info[:3]) 15 | 16 | 17 | def get_django_version(_ver): 18 | ver = ".".join([str(x) for x in _ver[:-2]]) 19 | return ver 20 | 21 | 22 | class DtView(TemplateView): 23 | template_name = "dt.html" 24 | 25 | def get_context_data(self, **kwargs): 26 | context = super().get_context_data(**kwargs) 27 | context.update( 28 | { 29 | "ctime": time.time(), 30 | "otime": time.time() - 100000, 31 | "ftime": time.time() + 100000, 32 | "cdate": datetime.datetime.now(), 33 | "odate": datetime.datetime.now() - datetime.timedelta(0, 100000), 34 | "fdate": datetime.datetime.now() + datetime.timedelta(0, 100000), 35 | } 36 | ) 37 | return context 38 | 39 | 40 | class NumeralView(TemplateView): 41 | template_name = "numeral.html" 42 | 43 | def get_context_data(self, **kwargs): 44 | context = super().get_context_data(**kwargs) 45 | context.update( 46 | { 47 | "comment_variants": ("комментарий", "комментария", "комментариев"), 48 | "comment_number": 21, 49 | "zero": 0, 50 | "comment_gender": "MALE", 51 | "rubles_value": 23.152, 52 | "rubles_value2": 12, 53 | "int_value": 21, 54 | "float_value": 31.385, 55 | } 56 | ) 57 | return context 58 | 59 | 60 | class TranslitView(TemplateView): 61 | template_name = "translit.html" 62 | 63 | def get_context_data(self, **kwargs): 64 | context = super().get_context_data(**kwargs) 65 | context.update( 66 | { 67 | "text": "Пример траслитерации средствами pytils", 68 | "translit": "Primer obratnoj transliteratsii", 69 | } 70 | ) 71 | return context 72 | 73 | 74 | class IndexView(TemplateView): 75 | template_name = "base.html" 76 | 77 | def get_context_data(self, **kwargs): 78 | context = super().get_context_data(**kwargs) 79 | context.update( 80 | { 81 | "pytils_version": pytils_version, 82 | "django_version": get_django_version(_django_version), 83 | "python_version": get_python_version(), 84 | } 85 | ) 86 | return context 87 | 88 | 89 | urlpatterns = [ 90 | path("dt/", DtView.as_view(), name="pytils_dt_example"), 91 | path("numeral/", NumeralView.as_view(), name="pytils_numeral_example"), 92 | path("translit/", TranslitView.as_view(), name="pytils_translit_example"), 93 | path("", IndexView.as_view(), name="pytils_example"), 94 | ] 95 | 96 | urlpatterns += staticfiles_urlpatterns() 97 | -------------------------------------------------------------------------------- /doc/examples/dt.distance_of_time_in_words.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import datetime 4 | import time 5 | 6 | from pytils import dt 7 | 8 | # поддерживаются оба модуля работы со временем: 9 | # time 10 | current_time = time.time() 11 | in_past = current_time - 100000 12 | in_future = current_time + 100000 13 | # и datetime.datetime 14 | dt_current_time = datetime.datetime.now() 15 | dt_in_past = dt_current_time - datetime.timedelta(0, 100000) 16 | dt_in_future = dt_current_time + datetime.timedelta(0, 100000) 17 | 18 | # 19 | # У distance_of_time_in_words три параметра: 20 | # 1) from_time, время от которого считать 21 | # 2) accuracy, точность, по умолчанию -- 1 22 | # 3) to_time, до которого времени считать, по умолчанию - сейчас 23 | # 24 | 25 | # если to_time не передано, считается от "сейчас", 26 | # и тогда -1 день -> "вчера", а +1 день -> "завтра" 27 | print(dt.distance_of_time_in_words(in_past)) 28 | # -> вчера 29 | print(dt.distance_of_time_in_words(dt_in_future)) 30 | # -> завтра 31 | 32 | 33 | # а вот если передано to_time, то нельзя говорить "вчера", 34 | # потому что to_time не обязательно "сейчас", 35 | # поэтому -1 день -> "1 день назад" 36 | print(dt.distance_of_time_in_words(in_past, to_time=current_time)) 37 | # -> 1 день назад 38 | 39 | # увеличение точности отражается на результате 40 | print(dt.distance_of_time_in_words(in_past, accuracy=2)) 41 | # -> 1 день 3 часа назад 42 | print(dt.distance_of_time_in_words(in_past, accuracy=3)) 43 | # -> 1 день 3 часа 46 минут назад 44 | 45 | # аналогично и с будущим временем: 46 | print(dt.distance_of_time_in_words(in_future)) 47 | # -> завтра 48 | print(dt.distance_of_time_in_words(in_future, to_time=current_time)) 49 | # -> через 1 день 50 | print(dt.distance_of_time_in_words(in_future, accuracy=2)) 51 | # -> через 1 день 3 часа 52 | print(dt.distance_of_time_in_words(in_future, accuracy=3)) 53 | # -> через 1 день 3 часа 46 минут 54 | -------------------------------------------------------------------------------- /doc/examples/dt.ru_strftime.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import datetime 4 | 5 | from pytils import dt 6 | 7 | # действие ru_strftime аналогично оригинальному strftime 8 | # только в %a, %A, %b и %B вместо английских названий будут русские 9 | 10 | d = datetime.date(2006, 9, 15) 11 | 12 | # оригинал 13 | print(d.strftime("%d.%m.%Y (%a)")) 14 | # -> 15.09.2006 (Fri) 15 | 16 | # теперь на русском 17 | # (единственно, что нужно формат строки передавать в unicode 18 | # в то время, как в оригинальном strftime это обязательно str) 19 | print(dt.ru_strftime("%d.%m.%Y (%a)", d)) 20 | # -> 15.09.2006 (пт) 21 | 22 | # %A дает полное название дня недели 23 | print(dt.ru_strftime("%d.%m.%Y (%A)", d)) 24 | # -> 15.09.2006 (пятница) 25 | 26 | # %B -- название месяца 27 | print(dt.ru_strftime("%d %B %Y", d)) 28 | # -> 15 сентябрь 2006 29 | 30 | # ru_strftime умеет правильно склонять месяц (опция inflected) 31 | print(dt.ru_strftime("%d %B %Y", d, inflected=True)) 32 | # -> 15 сентября 2006 33 | 34 | # ... и день (опция inflected_day) 35 | print(dt.ru_strftime("%d.%m.%Y, в %A", d, inflected_day=True)) 36 | # -> 15.09.2006, в пятницу 37 | 38 | # ... и добавлять правильный предлог (опция preposition) 39 | print(dt.ru_strftime("%d.%m.%Y, %A", d, preposition=True)) 40 | # -> 15.09.2006, в пятницу 41 | 42 | # второй параметр можно не передавать, будет использована текущая дата 43 | print(dt.ru_strftime("%d %B %Y", inflected=True)) 44 | # ->> 1 декабря 2013 45 | -------------------------------------------------------------------------------- /doc/examples/numeral.choose_plural.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from pytils import numeral 4 | 5 | # choose_plural нужен для выбора правильной формы 6 | # существительного 7 | 8 | # у choose_plural два параметра: 9 | # 1) amount, количество 10 | # 2) variants, варианты 11 | # варианты - это кортеж из вариантов склонения 12 | # его легко составить по мнемоническому правилу: 13 | # (один, два, пять) 14 | # т.е. для 1, 2 и 5 объектов, например для слова "пример" 15 | # (пример, примера, примеров) 16 | print(numeral.choose_plural(21, ("пример", "примера", "примеров"))) 17 | # -> пример 18 | print(numeral.choose_plural(12, ("пример", "примера", "примеров"))) 19 | # -> примеров 20 | print(numeral.choose_plural(32, ("пример", "примера", "примеров"))) 21 | # -> примера 22 | 23 | # также можно задавать варианты в одну строку, разделенные запятой 24 | print(numeral.choose_plural(32, "пример,примера, примеров")) 25 | # -> примера 26 | 27 | # если в варианте используется запятая, она экранируется слешем 28 | print(numeral.choose_plural(35, r"гвоздь, гвоздя, гвоздей\, шпунтов")) 29 | # -> гвоздей, шпунтов 30 | 31 | # зачастую требуется не просто вариант, а вместе с числительным 32 | # в этом случае следует использовать get_plural 33 | print(numeral.get_plural(32, "пример,примера, примеров")) 34 | # -> 32 примера 35 | 36 | # часто хочется, чтобы в случае отсутсвия значения (т.е. количество равно нулю) 37 | # выводилось не "0 примеров", а "примеров нет" 38 | # в этом случае используйте третий параметр get_plural: 39 | print(numeral.get_plural(0, "пример,примера, примеров", "без примеров")) 40 | # -> без примеров 41 | -------------------------------------------------------------------------------- /doc/examples/numeral.in_words.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from pytils import numeral 4 | 5 | # in_words нужен для представления цифр словами 6 | 7 | print(numeral.in_words(12)) 8 | # -> двенадцать 9 | 10 | # вторым параметром можно задать пол: 11 | # мужской=numeral.MALE, женский=numeral.FEMALE, срелний=numeral.NEUTER (по умолчанию -- мужской) 12 | print(numeral.in_words(21)) 13 | # -> двадцать один 14 | 15 | # можно передавать неименованным параметром: 16 | print(numeral.in_words(21, numeral.FEMALE)) 17 | # -> двадцать одна 18 | 19 | # можно именованным 20 | print(numeral.in_words(21, gender=numeral.FEMALE)) 21 | # -> двадцать одна 22 | print(numeral.in_words(21, gender=numeral.NEUTER)) 23 | # -> двадцать одно 24 | 25 | # можно и дробные 26 | print(numeral.in_words(12.5)) 27 | # -> двенадцать целых пять десятых 28 | 29 | # причем "пишутся" только значимые цифры 30 | print(numeral.in_words(5.30000)) 31 | # -> пять целых три десятых 32 | -------------------------------------------------------------------------------- /doc/examples/numeral.rubles.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from pytils import numeral 4 | 5 | # rubles служит для формирования строк с деньгами 6 | 7 | print(numeral.rubles(10)) 8 | # -> десять рублей 9 | 10 | # если нужно, то даже 0 копеек можно записать словами 11 | print(numeral.rubles(10, zero_for_kopeck=True)) 12 | # -> десять рублей ноль копеек 13 | 14 | print(numeral.rubles(2.35)) 15 | # -> два рубля тридцать пять копеек 16 | 17 | # в случае чего, копейки округляются 18 | print(numeral.rubles(3.95754)) 19 | # -> три рубля девяносто шесть копеек 20 | -------------------------------------------------------------------------------- /doc/examples/numeral.sum_string.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from pytils import numeral 4 | 5 | # sum_string объединяет в себе choose_plural и in_words 6 | # т.е. передаются и количество, и варианты названия объекта 7 | # а на выходе получаем количество объектов в правильной форме 8 | 9 | # параметры: 10 | # 1) amount, количество (только целое) 11 | # 2) gender, пол (1=мужской, 2=женский, 3=средний) 12 | # 3) items, варианты названий объекта (необязательно), 13 | # правила аналогичны таковым у choose_plural 14 | 15 | print(numeral.sum_string(3, numeral.MALE, ("носок", "носка", "носков"))) 16 | # -> три носка 17 | 18 | print(numeral.sum_string(5, numeral.FEMALE, ("коробка", "коробки", "коробок"))) 19 | # -> пять коробок 20 | 21 | print(numeral.sum_string(21, numeral.NEUTER, ("очко", "очка", "очков"))) 22 | # -> двадцать одно очко 23 | 24 | # если варианты не указывать, то действие функции аналогично дейтсвию in_words 25 | print(numeral.sum_string(21, gender=numeral.NEUTER)) 26 | # -> двадцать одно 27 | -------------------------------------------------------------------------------- /doc/examples/test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | 5 | import pytest 6 | 7 | EXAMPLES = [ 8 | "dt.distance_of_time_in_words.py", 9 | "dt.ru_strftime.py", 10 | "numeral.choose_plural.py", 11 | "numeral.in_words.py", 12 | "numeral.rubles.py", 13 | "numeral.sum_string.py", 14 | "translit.py", 15 | ] 16 | 17 | 18 | def name_to_path(x): 19 | return os.path.join(os.path.normpath(os.path.abspath(os.path.dirname(__file__))), x) 20 | 21 | 22 | def sanitize_output(x): 23 | return x.replace("#->", "").replace("# ->", "").strip() 24 | 25 | 26 | def safe_file_iterator(fh, encoding="UTF-8"): 27 | yield from fh 28 | 29 | 30 | def grab_expected_output(name): 31 | with open(name_to_path(name)) as fh: 32 | return [ 33 | sanitize_output(x) 34 | for x in safe_file_iterator(fh) 35 | if x.replace(" ", "").startswith("#->") 36 | ] 37 | 38 | 39 | def run_example_and_collect_output(name): 40 | return [ 41 | x.decode("UTF-8") 42 | for x in subprocess.check_output( 43 | ["python", name_to_path(name)], stderr=subprocess.STDOUT 44 | ) 45 | .strip() 46 | .splitlines() 47 | ] 48 | 49 | 50 | class ExampleFileTestSuite: 51 | def __init__(self, name): 52 | self.name = name 53 | self.expected_output = list(grab_expected_output(name)) 54 | self.real_output = list(run_example_and_collect_output(name)) 55 | assert len(self.real_output) == len(self.expected_output), ( 56 | "Mismatch in number of real (%s) and expected (%s) strings" 57 | % (len(self.real_output), len(self.expected_output)) 58 | ) 59 | assert len(self.real_output) > 0 60 | assert isinstance(self.real_output[0], str), ( 61 | "%r is not text type (not a unicode for Py2.x, not a str for Py3.x" 62 | % self.real_output[0] 63 | ) 64 | assert isinstance(self.expected_output[0], str), ( 65 | "%r is not text type (not a unicode for Py2.x, not a str for Py3.x" 66 | % self.expected_output[0] 67 | ) 68 | 69 | def test_cases(self): 70 | return range(len(self.real_output)) 71 | 72 | def run_test(self, name, i): 73 | assert name == self.name 74 | assert isinstance(self.real_output[i], str) 75 | assert isinstance(self.expected_output[i], str) 76 | # ignore real output if in example line marked with ->> 77 | if self.expected_output[i].startswith(">"): 78 | return 79 | assert self.real_output[i] == self.expected_output[i], ( 80 | "Real output %r doesn't match to expected %r for example #%s" 81 | % (self.real_output[i], self.expected_output[i], i) 82 | ) 83 | 84 | 85 | def test_python_version(): 86 | # check that `python something.py` will run the same version interepreter as it is running 87 | current_version = str(sys.version_info) 88 | exec_version = subprocess.check_output( 89 | ["python", "-c", "import sys; print(sys.version_info)"], 90 | stderr=subprocess.STDOUT, 91 | ).strip() 92 | assert current_version == exec_version.decode("utf-8") 93 | 94 | 95 | def generate_example_tests(): 96 | for example in EXAMPLES: 97 | runner = ExampleFileTestSuite(example) 98 | # we want to have granular test, one test case per line 99 | # nose show each test as "executable, arg1, arg2", that's 100 | # why we want pass example name again, even test runner already knows it 101 | for i in runner.test_cases(): 102 | yield runner, example, i 103 | 104 | 105 | @pytest.mark.parametrize("runner,name,i", generate_example_tests()) 106 | def test_examples(runner: ExampleFileTestSuite, name: str, i: int): 107 | runner.run_test(name, i) 108 | -------------------------------------------------------------------------------- /doc/examples/translit.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from pytils import translit 4 | 5 | # простая траслитерация/детранслитерация 6 | # обратите внимание на то, что при транслитерации вход - unicode, 7 | # выход - str, а в детранслитерации -- наоборот 8 | # 9 | 10 | print(translit.translify("Это тест и ничего более")) 11 | # -> Eto test i nichego bolee 12 | 13 | print( 14 | translit.translify("Традиционно сложные для транслитерации буквы - подъезд, щука") 15 | ) 16 | # -> Traditsionno slozhnyie dlya transliteratsii bukvyi - pod`ezd, schuka 17 | 18 | # и теперь пытаемся вернуть назад... (понятно, что Э и Е получаются одинаково) 19 | print(translit.detranslify("Eto test i nichego bolee")) 20 | # -> Ето тест и ничего более 21 | 22 | print( 23 | translit.detranslify( 24 | "Traditsionno slozhnyie dlya transliteratsii bukvyi - pod`ezd, schuka" 25 | ) 26 | ) 27 | # -> Традиционно сложные для транслитерации буквы – подЪезд, щука 28 | 29 | 30 | # и пригодные для url и названий каталогов/файлов транслиты 31 | # dirify и slugify -- синонимы, действия абсолютно идентичны 32 | print(translit.slugify("Традиционно сложные для транслитерации буквы - подъезд, щука")) 33 | # -> traditsionno-slozhnyie-dlya-transliteratsii-bukvyi-podezd-schuka 34 | 35 | # обратного преобразования, понятно, нет :) 36 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "asgiref" 5 | version = "3.8.1" 6 | description = "ASGI specs, helper code, and adapters" 7 | optional = false 8 | python-versions = ">=3.8" 9 | groups = ["dev"] 10 | files = [ 11 | {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, 12 | {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, 13 | ] 14 | 15 | [package.dependencies] 16 | typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} 17 | 18 | [package.extras] 19 | tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] 20 | 21 | [[package]] 22 | name = "cachetools" 23 | version = "5.5.2" 24 | description = "Extensible memoizing collections and decorators" 25 | optional = false 26 | python-versions = ">=3.7" 27 | groups = ["dev"] 28 | files = [ 29 | {file = "cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a"}, 30 | {file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"}, 31 | ] 32 | 33 | [[package]] 34 | name = "chardet" 35 | version = "5.2.0" 36 | description = "Universal encoding detector for Python 3" 37 | optional = false 38 | python-versions = ">=3.7" 39 | groups = ["dev"] 40 | files = [ 41 | {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, 42 | {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, 43 | ] 44 | 45 | [[package]] 46 | name = "colorama" 47 | version = "0.4.6" 48 | description = "Cross-platform colored terminal text." 49 | optional = false 50 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 51 | groups = ["dev"] 52 | files = [ 53 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 54 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 55 | ] 56 | 57 | [[package]] 58 | name = "distlib" 59 | version = "0.3.9" 60 | description = "Distribution utilities" 61 | optional = false 62 | python-versions = "*" 63 | groups = ["dev"] 64 | files = [ 65 | {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, 66 | {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, 67 | ] 68 | 69 | [[package]] 70 | name = "django" 71 | version = "4.2.20" 72 | description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." 73 | optional = false 74 | python-versions = ">=3.8" 75 | groups = ["dev"] 76 | files = [ 77 | {file = "Django-4.2.20-py3-none-any.whl", hash = "sha256:213381b6e4405f5c8703fffc29cd719efdf189dec60c67c04f76272b3dc845b9"}, 78 | {file = "Django-4.2.20.tar.gz", hash = "sha256:92bac5b4432a64532abb73b2ac27203f485e40225d2640a7fbef2b62b876e789"}, 79 | ] 80 | 81 | [package.dependencies] 82 | asgiref = ">=3.6.0,<4" 83 | sqlparse = ">=0.3.1" 84 | tzdata = {version = "*", markers = "sys_platform == \"win32\""} 85 | 86 | [package.extras] 87 | argon2 = ["argon2-cffi (>=19.1.0)"] 88 | bcrypt = ["bcrypt"] 89 | 90 | [[package]] 91 | name = "exceptiongroup" 92 | version = "1.2.2" 93 | description = "Backport of PEP 654 (exception groups)" 94 | optional = false 95 | python-versions = ">=3.7" 96 | groups = ["dev"] 97 | markers = "python_version < \"3.11\"" 98 | files = [ 99 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, 100 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, 101 | ] 102 | 103 | [package.extras] 104 | test = ["pytest (>=6)"] 105 | 106 | [[package]] 107 | name = "filelock" 108 | version = "3.18.0" 109 | description = "A platform independent file lock." 110 | optional = false 111 | python-versions = ">=3.9" 112 | groups = ["dev"] 113 | files = [ 114 | {file = "filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de"}, 115 | {file = "filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2"}, 116 | ] 117 | 118 | [package.extras] 119 | docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] 120 | testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] 121 | typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] 122 | 123 | [[package]] 124 | name = "iniconfig" 125 | version = "2.1.0" 126 | description = "brain-dead simple config-ini parsing" 127 | optional = false 128 | python-versions = ">=3.8" 129 | groups = ["dev"] 130 | files = [ 131 | {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, 132 | {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, 133 | ] 134 | 135 | [[package]] 136 | name = "isort" 137 | version = "5.13.2" 138 | description = "A Python utility / library to sort Python imports." 139 | optional = false 140 | python-versions = ">=3.8.0" 141 | groups = ["dev"] 142 | files = [ 143 | {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, 144 | {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, 145 | ] 146 | 147 | [package.extras] 148 | colors = ["colorama (>=0.4.6)"] 149 | 150 | [[package]] 151 | name = "packaging" 152 | version = "24.2" 153 | description = "Core utilities for Python packages" 154 | optional = false 155 | python-versions = ">=3.8" 156 | groups = ["dev"] 157 | files = [ 158 | {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, 159 | {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, 160 | ] 161 | 162 | [[package]] 163 | name = "platformdirs" 164 | version = "4.3.7" 165 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 166 | optional = false 167 | python-versions = ">=3.9" 168 | groups = ["dev"] 169 | files = [ 170 | {file = "platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94"}, 171 | {file = "platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351"}, 172 | ] 173 | 174 | [package.extras] 175 | docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] 176 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] 177 | type = ["mypy (>=1.14.1)"] 178 | 179 | [[package]] 180 | name = "pluggy" 181 | version = "1.5.0" 182 | description = "plugin and hook calling mechanisms for python" 183 | optional = false 184 | python-versions = ">=3.8" 185 | groups = ["dev"] 186 | files = [ 187 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 188 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 189 | ] 190 | 191 | [package.extras] 192 | dev = ["pre-commit", "tox"] 193 | testing = ["pytest", "pytest-benchmark"] 194 | 195 | [[package]] 196 | name = "pyproject-api" 197 | version = "1.9.0" 198 | description = "API to interact with the python pyproject.toml based projects" 199 | optional = false 200 | python-versions = ">=3.9" 201 | groups = ["dev"] 202 | files = [ 203 | {file = "pyproject_api-1.9.0-py3-none-any.whl", hash = "sha256:326df9d68dea22d9d98b5243c46e3ca3161b07a1b9b18e213d1e24fd0e605766"}, 204 | {file = "pyproject_api-1.9.0.tar.gz", hash = "sha256:7e8a9854b2dfb49454fae421cb86af43efbb2b2454e5646ffb7623540321ae6e"}, 205 | ] 206 | 207 | [package.dependencies] 208 | packaging = ">=24.2" 209 | tomli = {version = ">=2.2.1", markers = "python_version < \"3.11\""} 210 | 211 | [package.extras] 212 | docs = ["furo (>=2024.8.6)", "sphinx-autodoc-typehints (>=3)"] 213 | testing = ["covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "setuptools (>=75.8)"] 214 | 215 | [[package]] 216 | name = "pytest" 217 | version = "7.4.4" 218 | description = "pytest: simple powerful testing with Python" 219 | optional = false 220 | python-versions = ">=3.7" 221 | groups = ["dev"] 222 | files = [ 223 | {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, 224 | {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, 225 | ] 226 | 227 | [package.dependencies] 228 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 229 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 230 | iniconfig = "*" 231 | packaging = "*" 232 | pluggy = ">=0.12,<2.0" 233 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 234 | 235 | [package.extras] 236 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 237 | 238 | [[package]] 239 | name = "pytest-django" 240 | version = "4.11.0" 241 | description = "A Django plugin for pytest." 242 | optional = false 243 | python-versions = ">=3.8" 244 | groups = ["dev"] 245 | files = [ 246 | {file = "pytest_django-4.11.0-py3-none-any.whl", hash = "sha256:ab969cc86585a1763fd4a2fa1bc0f3015abbbcb823667af48320f1a1d2053291"}, 247 | {file = "pytest_django-4.11.0.tar.gz", hash = "sha256:35b339b9a231a99de226789c3e45d4d19d6c09ca9f3c236b3b5024afbf1f68b5"}, 248 | ] 249 | 250 | [package.dependencies] 251 | pytest = ">=7.0.0" 252 | 253 | [package.extras] 254 | docs = ["sphinx", "sphinx_rtd_theme"] 255 | testing = ["Django", "django-configurations (>=2.0)"] 256 | 257 | [[package]] 258 | name = "pytest-sugar" 259 | version = "0.9.7" 260 | description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." 261 | optional = false 262 | python-versions = "*" 263 | groups = ["dev"] 264 | files = [ 265 | {file = "pytest-sugar-0.9.7.tar.gz", hash = "sha256:f1e74c1abfa55f7241cf7088032b6e378566f16b938f3f08905e2cf4494edd46"}, 266 | {file = "pytest_sugar-0.9.7-py2.py3-none-any.whl", hash = "sha256:8cb5a4e5f8bbcd834622b0235db9e50432f4cbd71fef55b467fe44e43701e062"}, 267 | ] 268 | 269 | [package.dependencies] 270 | packaging = ">=21.3" 271 | pytest = ">=6.2.0" 272 | termcolor = ">=2.1.0" 273 | 274 | [package.extras] 275 | dev = ["black", "flake8", "pre-commit"] 276 | 277 | [[package]] 278 | name = "setuptools" 279 | version = "80.9.0" 280 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 281 | optional = false 282 | python-versions = ">=3.9" 283 | groups = ["dev"] 284 | files = [ 285 | {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, 286 | {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, 287 | ] 288 | 289 | [package.extras] 290 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] 291 | core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] 292 | cover = ["pytest-cov"] 293 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] 294 | enabler = ["pytest-enabler (>=2.2)"] 295 | test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] 296 | type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] 297 | 298 | [[package]] 299 | name = "sqlparse" 300 | version = "0.5.3" 301 | description = "A non-validating SQL parser." 302 | optional = false 303 | python-versions = ">=3.8" 304 | groups = ["dev"] 305 | files = [ 306 | {file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"}, 307 | {file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"}, 308 | ] 309 | 310 | [package.extras] 311 | dev = ["build", "hatch"] 312 | doc = ["sphinx"] 313 | 314 | [[package]] 315 | name = "termcolor" 316 | version = "3.0.1" 317 | description = "ANSI color formatting for output in terminal" 318 | optional = false 319 | python-versions = ">=3.9" 320 | groups = ["dev"] 321 | files = [ 322 | {file = "termcolor-3.0.1-py3-none-any.whl", hash = "sha256:da1ed4ec8a5dc5b2e17476d859febdb3cccb612be1c36e64511a6f2485c10c69"}, 323 | {file = "termcolor-3.0.1.tar.gz", hash = "sha256:a6abd5c6e1284cea2934443ba806e70e5ec8fd2449021be55c280f8a3731b611"}, 324 | ] 325 | 326 | [package.extras] 327 | tests = ["pytest", "pytest-cov"] 328 | 329 | [[package]] 330 | name = "tomli" 331 | version = "2.2.1" 332 | description = "A lil' TOML parser" 333 | optional = false 334 | python-versions = ">=3.8" 335 | groups = ["dev"] 336 | markers = "python_version < \"3.11\"" 337 | files = [ 338 | {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, 339 | {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, 340 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, 341 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, 342 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, 343 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, 344 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, 345 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, 346 | {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, 347 | {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, 348 | {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, 349 | {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, 350 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, 351 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, 352 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, 353 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, 354 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, 355 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, 356 | {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, 357 | {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, 358 | {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, 359 | {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, 360 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, 361 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, 362 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, 363 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, 364 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, 365 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, 366 | {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, 367 | {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, 368 | {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, 369 | {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, 370 | ] 371 | 372 | [[package]] 373 | name = "tox" 374 | version = "4.25.0" 375 | description = "tox is a generic virtualenv management and test command line tool" 376 | optional = false 377 | python-versions = ">=3.8" 378 | groups = ["dev"] 379 | files = [ 380 | {file = "tox-4.25.0-py3-none-any.whl", hash = "sha256:4dfdc7ba2cc6fdc6688dde1b21e7b46ff6c41795fb54586c91a3533317b5255c"}, 381 | {file = "tox-4.25.0.tar.gz", hash = "sha256:dd67f030317b80722cf52b246ff42aafd3ed27ddf331c415612d084304cf5e52"}, 382 | ] 383 | 384 | [package.dependencies] 385 | cachetools = ">=5.5.1" 386 | chardet = ">=5.2" 387 | colorama = ">=0.4.6" 388 | filelock = ">=3.16.1" 389 | packaging = ">=24.2" 390 | platformdirs = ">=4.3.6" 391 | pluggy = ">=1.5" 392 | pyproject-api = ">=1.8" 393 | tomli = {version = ">=2.2.1", markers = "python_version < \"3.11\""} 394 | typing-extensions = {version = ">=4.12.2", markers = "python_version < \"3.11\""} 395 | virtualenv = ">=20.29.1" 396 | 397 | [package.extras] 398 | test = ["devpi-process (>=1.0.2)", "pytest (>=8.3.4)", "pytest-mock (>=3.14)"] 399 | 400 | [[package]] 401 | name = "tox-gh-actions" 402 | version = "3.3.0" 403 | description = "Seamless integration of tox into GitHub Actions" 404 | optional = false 405 | python-versions = ">=3.7" 406 | groups = ["dev"] 407 | files = [ 408 | {file = "tox_gh_actions-3.3.0-py2.py3-none-any.whl", hash = "sha256:0e1f9db7a775d04b6d94ab801c60d2d482929a934136262969791eb0ccac65a4"}, 409 | {file = "tox_gh_actions-3.3.0.tar.gz", hash = "sha256:6933775dd7ab98649de5134283277e604fecfd4eb44bf31150c1c6ba2b1092ef"}, 410 | ] 411 | 412 | [package.dependencies] 413 | tox = ">=4,<5" 414 | 415 | [package.extras] 416 | testing = ["black ; platform_python_implementation == \"CPython\"", "devpi-process", "flake8 (>=6,<7) ; python_version >= \"3.8\"", "mypy ; platform_python_implementation == \"CPython\"", "pytest (>=7)", "pytest-cov (>=4)", "pytest-mock (>=3)", "pytest-randomly (>=3)"] 417 | 418 | [[package]] 419 | name = "ty" 420 | version = "0.0.1a16" 421 | description = "An extremely fast Python type checker, written in Rust." 422 | optional = false 423 | python-versions = ">=3.8" 424 | groups = ["dev"] 425 | files = [ 426 | {file = "ty-0.0.1a16-py3-none-linux_armv6l.whl", hash = "sha256:dfb55d28df78ca40f8aff91ec3ae01f4b7bc23aa04c72ace7ec00fbc5e0468c0"}, 427 | {file = "ty-0.0.1a16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a0e9917efadf2ec173ee755db3653243b64fa8b26fa4d740dea68e969a99898"}, 428 | {file = "ty-0.0.1a16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9253cb8b5c4052337b1600f581ecd8e6929e635a07ec9e8dc5cc2fa4008e6b3b"}, 429 | {file = "ty-0.0.1a16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:374c059e184f8abc969e07965355ddbbf7205a713721d3867ee42f976249c9ac"}, 430 | {file = "ty-0.0.1a16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c5364c6d1a1a3d5b8e765a730303f8e07094ab9e63682aa82f73755d92749852"}, 431 | {file = "ty-0.0.1a16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f201ff0ab3267123b9e42cc8584a193aa76e6e0865003d1b0a41bd025f08229e"}, 432 | {file = "ty-0.0.1a16-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:57f14207d88043ba27f4b84d84dfdaa1bfbcc5170d5f50814d2997cbc3d75366"}, 433 | {file = "ty-0.0.1a16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:950c45e1d6c58e61ad77ed5d2d04f091e44b0d13e6d5d79143bb81078ab526b1"}, 434 | {file = "ty-0.0.1a16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad133d0eac5291d738e40052df98ca9f194e0f0433d6086a4890fd6733217969"}, 435 | {file = "ty-0.0.1a16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e59f877ef8b967c06173a7a663271a6e66edb049f0db00f7873be5e41d61d5b"}, 436 | {file = "ty-0.0.1a16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4e973b8cb2c382263aaf77a40889ad236bd06ddca671cc973f9e33e8e02f0af1"}, 437 | {file = "ty-0.0.1a16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a82d9c4b76a73aff60cab93b71f2dd83952c2eb68a86578e1db56aee8f7e338"}, 438 | {file = "ty-0.0.1a16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7993f48def35f1707a2dc675bf7d08906cc5f26204b0b479746664301eda15b9"}, 439 | {file = "ty-0.0.1a16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d9887ec65984e7dbf3b5e906ef44e8f47ff5351c7ac04d49e793b324d744050f"}, 440 | {file = "ty-0.0.1a16-py3-none-win32.whl", hash = "sha256:4113a176a8343196d73145668460873d26ccef8766ff4e5287eec2622ce8754d"}, 441 | {file = "ty-0.0.1a16-py3-none-win_amd64.whl", hash = "sha256:508ba4c50bc88f1a7c730d40f28d6c679696ee824bc09630c7c6763911de862a"}, 442 | {file = "ty-0.0.1a16-py3-none-win_arm64.whl", hash = "sha256:36f53e430b5e0231d6b6672160c981eaf7f9390162380bcd1096941b2c746b5d"}, 443 | {file = "ty-0.0.1a16.tar.gz", hash = "sha256:9ade26904870dc9bd988e58bad4382857f75ae05edb682ee0ba2f26fcc2d4c0f"}, 444 | ] 445 | 446 | [[package]] 447 | name = "typing-extensions" 448 | version = "4.13.0" 449 | description = "Backported and Experimental Type Hints for Python 3.8+" 450 | optional = false 451 | python-versions = ">=3.8" 452 | groups = ["dev"] 453 | markers = "python_version < \"3.11\"" 454 | files = [ 455 | {file = "typing_extensions-4.13.0-py3-none-any.whl", hash = "sha256:c8dd92cc0d6425a97c18fbb9d1954e5ff92c1ca881a309c45f06ebc0b79058e5"}, 456 | {file = "typing_extensions-4.13.0.tar.gz", hash = "sha256:0a4ac55a5820789d87e297727d229866c9650f6521b64206413c4fbada24d95b"}, 457 | ] 458 | 459 | [[package]] 460 | name = "tzdata" 461 | version = "2025.2" 462 | description = "Provider of IANA time zone data" 463 | optional = false 464 | python-versions = ">=2" 465 | groups = ["dev"] 466 | markers = "sys_platform == \"win32\"" 467 | files = [ 468 | {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, 469 | {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, 470 | ] 471 | 472 | [[package]] 473 | name = "virtualenv" 474 | version = "20.30.0" 475 | description = "Virtual Python Environment builder" 476 | optional = false 477 | python-versions = ">=3.8" 478 | groups = ["dev"] 479 | files = [ 480 | {file = "virtualenv-20.30.0-py3-none-any.whl", hash = "sha256:e34302959180fca3af42d1800df014b35019490b119eba981af27f2fa486e5d6"}, 481 | {file = "virtualenv-20.30.0.tar.gz", hash = "sha256:800863162bcaa5450a6e4d721049730e7f2dae07720e0902b0e4040bd6f9ada8"}, 482 | ] 483 | 484 | [package.dependencies] 485 | distlib = ">=0.3.7,<1" 486 | filelock = ">=3.12.2,<4" 487 | platformdirs = ">=3.9.1,<5" 488 | 489 | [package.extras] 490 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 491 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] 492 | 493 | [metadata] 494 | lock-version = "2.1" 495 | python-versions = "^3.9" 496 | content-hash = "3898e1e78b4eb2337916e402b33f8e7c0c754d49a7893c9edcefe6c7310be082" 497 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pytils" 3 | version = "0.4.3" 4 | description = "Russian-specific string utils" 5 | authors = ["Yury Yurevich "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.9" 9 | 10 | [tool.poetry.group.dev.dependencies] 11 | Django = ">=3.2,<5" 12 | isort = "^5.10.1" 13 | pytest = "^7.0.1" 14 | pytest-sugar = "^0.9.4" 15 | pytest-django = "^4.5.2" 16 | tox = "^4.25.0" 17 | tox-gh-actions = "^3.3.0" 18 | ty = "^0.0.1a16" 19 | setuptools = "^80.9.0" 20 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = pytilsex.settings 3 | python_files = tests.py test.py test_*.py *_tests.py 4 | addopts = 5 | --maxfail=5 6 | -------------------------------------------------------------------------------- /pytils/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple processing for russian strings 3 | """ 4 | 5 | from pytils import dt, numeral, translit, typo, utils 6 | 7 | VERSION = "0.4.0dev" 8 | __all__ = ["dt", "numeral", "translit", "typo", "utils"] 9 | -------------------------------------------------------------------------------- /pytils/dt.py: -------------------------------------------------------------------------------- 1 | # -*- test-case-name: pytils.test.test_dt -*- 2 | """ 3 | Russian dates without locales 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import datetime 9 | 10 | from pytils import numeral 11 | from pytils.utils import check_positive 12 | 13 | DAY_ALTERNATIVES = { 14 | 1: ("вчера", "завтра"), 15 | 2: ("позавчера", "послезавтра"), 16 | } #: Day alternatives (i.e. one day ago -> yesterday) 17 | 18 | DAY_VARIANTS = ( 19 | "день", 20 | "дня", 21 | "дней", 22 | ) #: Forms (1, 2, 5) for noun 'day' 23 | 24 | HOUR_VARIANTS = ( 25 | "час", 26 | "часа", 27 | "часов", 28 | ) #: Forms (1, 2, 5) for noun 'hour' 29 | 30 | MINUTE_VARIANTS = ( 31 | "минуту", 32 | "минуты", 33 | "минут", 34 | ) #: Forms (1, 2, 5) for noun 'minute' 35 | 36 | PREFIX_IN = "через" #: Prefix 'in' (i.e. B{in} three hours) 37 | SUFFIX_AGO = "назад" #: Prefix 'ago' (i.e. three hours B{ago}) 38 | 39 | MONTH_NAMES = ( 40 | ("янв", "январь", "января"), 41 | ("фев", "февраль", "февраля"), 42 | ("мар", "март", "марта"), 43 | ("апр", "апрель", "апреля"), 44 | ("май", "май", "мая"), 45 | ("июн", "июнь", "июня"), 46 | ("июл", "июль", "июля"), 47 | ("авг", "август", "августа"), 48 | ("сен", "сентябрь", "сентября"), 49 | ("окт", "октябрь", "октября"), 50 | ("ноя", "ноябрь", "ноября"), 51 | ("дек", "декабрь", "декабря"), 52 | ) #: Month names (abbreviated, full, inflected) 53 | 54 | DAY_NAMES = ( 55 | ("пн", "понедельник", "понедельник", "в\xa0"), 56 | ("вт", "вторник", "вторник", "во\xa0"), 57 | ("ср", "среда", "среду", "в\xa0"), 58 | ("чт", "четверг", "четверг", "в\xa0"), 59 | ("пт", "пятница", "пятницу", "в\xa0"), 60 | ("сб", "суббота", "субботу", "в\xa0"), 61 | ("вск", "воскресенье", "воскресенье", "в\xa0"), 62 | ) #: Day names (abbreviated, full, inflected, preposition) 63 | 64 | 65 | def distance_of_time_in_words( 66 | from_time: int | float | datetime.datetime, 67 | accuracy: int = 1, 68 | to_time: int | float | datetime.datetime | None = None, 69 | ) -> str: 70 | """ 71 | Represents distance of time in words 72 | 73 | @param from_time: source time (in seconds from epoch) 74 | @type from_time: C{int}, C{float} or C{datetime.datetime} 75 | 76 | @param accuracy: level of accuracy (1..3), default=1 77 | @type accuracy: C{int} 78 | 79 | @param to_time: target time (in seconds from epoch), 80 | default=None translates to current time 81 | @type to_time: C{int}, C{float} or C{datetime.datetime} 82 | 83 | @return: distance of time in words 84 | @rtype: C{str} 85 | 86 | @raise ValueError: accuracy is lesser or equal zero 87 | """ 88 | current = False 89 | 90 | if to_time is None: 91 | current = True 92 | to_time = datetime.datetime.now() 93 | 94 | check_positive(accuracy, strict=True) 95 | 96 | if not isinstance(from_time, datetime.datetime): 97 | from_time = datetime.datetime.fromtimestamp(from_time) 98 | 99 | if not isinstance(to_time, datetime.datetime): 100 | to_time = datetime.datetime.fromtimestamp(to_time) 101 | 102 | if from_time.tzinfo and not to_time.tzinfo: 103 | to_time = to_time.replace(tzinfo=from_time.tzinfo) 104 | 105 | dt_delta = to_time - from_time 106 | difference = dt_delta.days * 86400 + dt_delta.seconds 107 | 108 | minutes_orig = int(abs(difference) / 60.0) 109 | hours_orig = int(abs(difference) / 3600.0) 110 | days_orig = int(abs(difference) / 86400.0) 111 | in_future = from_time > to_time 112 | 113 | words = [] 114 | values = [] 115 | alternatives = [] 116 | 117 | days = days_orig 118 | hours = hours_orig - days_orig * 24 119 | 120 | words.append("%d %s" % (days, numeral.choose_plural(days, DAY_VARIANTS))) 121 | values.append(days) 122 | 123 | words.append("%d %s" % (hours, numeral.choose_plural(hours, HOUR_VARIANTS))) 124 | values.append(hours) 125 | 126 | days == 0 and hours == 1 and current and alternatives.append("час") 127 | 128 | minutes = minutes_orig - hours_orig * 60 129 | 130 | words.append("%d %s" % (minutes, numeral.choose_plural(minutes, MINUTE_VARIANTS))) 131 | values.append(minutes) 132 | 133 | days == 0 and hours == 0 and minutes == 1 and current and alternatives.append( 134 | "минуту" 135 | ) 136 | 137 | # убираем из values и words конечные нули 138 | while values and not values[-1]: 139 | values.pop() 140 | words.pop() 141 | # убираем из values и words начальные нули 142 | while values and not values[0]: 143 | values.pop(0) 144 | words.pop(0) 145 | limit = min(accuracy, len(words)) 146 | real_words = words[:limit] 147 | real_values = values[:limit] 148 | # снова убираем конечные нули 149 | while real_values and not real_values[-1]: 150 | real_values.pop() 151 | real_words.pop() 152 | limit -= 1 153 | 154 | real_str = " ".join(real_words) 155 | 156 | # альтернативные варианты нужны только если в real_words одно значение 157 | # и, вдобавок, если используется текущее время 158 | alter_str = limit == 1 and current and alternatives and alternatives[0] 159 | _result_str = alter_str or real_str 160 | result_str = ( 161 | in_future 162 | and "{} {}".format(PREFIX_IN, _result_str) 163 | or "{} {}".format(_result_str, SUFFIX_AGO) 164 | ) 165 | 166 | # если же прошло менее минуты, то real_words -- пустой, и поэтому 167 | # нужно брать alternatives[0], а не result_str 168 | zero_str = ( 169 | minutes == 0 170 | and not real_words 171 | and (in_future and "менее чем через минуту" or "менее минуты назад") 172 | ) 173 | 174 | # нужно использовать вчера/позавчера/завтра/послезавтра 175 | # если days 1..2 и в real_words одно значение 176 | day_alternatives = DAY_ALTERNATIVES.get(days, False) 177 | alternate_day = ( 178 | day_alternatives 179 | and current 180 | and limit == 1 181 | and ((in_future and day_alternatives[1]) or day_alternatives[0]) 182 | ) 183 | 184 | final_str = not real_words and zero_str or alternate_day or result_str 185 | 186 | return final_str 187 | 188 | 189 | def ru_strftime( 190 | format: str = "%d.%m.%Y", 191 | date: datetime.date | datetime.datetime | None = None, 192 | inflected: bool = False, 193 | inflected_day: bool = False, 194 | preposition: bool = False, 195 | ): 196 | """ 197 | Russian strftime without locale 198 | 199 | @param format: strftime format, default='%d.%m.%Y' 200 | @type format: C{str} 201 | 202 | @param date: date value, default=None translates to today 203 | @type date: C{datetime.date} or C{datetime.datetime} 204 | 205 | @param inflected: is month inflected, default False 206 | @type inflected: C{bool} 207 | 208 | @param inflected_day: is day inflected, default False 209 | @type inflected: C{bool} 210 | 211 | @param preposition: is preposition used, default False 212 | preposition=True automatically implies inflected_day=True 213 | @type preposition: C{bool} 214 | 215 | @return: strftime string 216 | @rtype: C{str} 217 | """ 218 | if date is None: 219 | date = datetime.datetime.today() 220 | 221 | weekday = date.weekday() 222 | 223 | prepos = preposition and DAY_NAMES[weekday][3] or "" 224 | 225 | month_idx = inflected and 2 or 1 226 | day_idx = (inflected_day or preposition) and 2 or 1 227 | 228 | # for russian typography standard, 229 | # 1 April 2007, but 01.04.2007 230 | if "%b" in format or "%B" in format: 231 | format = format.replace("%d", str(date.day)) 232 | 233 | format = format.replace("%a", prepos + DAY_NAMES[weekday][0]) 234 | format = format.replace("%A", prepos + DAY_NAMES[weekday][day_idx]) 235 | format = format.replace("%b", MONTH_NAMES[date.month - 1][0]) 236 | format = format.replace("%B", MONTH_NAMES[date.month - 1][month_idx]) 237 | 238 | u_res = date.strftime(format) 239 | return u_res 240 | -------------------------------------------------------------------------------- /pytils/numeral.py: -------------------------------------------------------------------------------- 1 | # -*- test-case-name: pytils.test.test_numeral -*- 2 | """ 3 | Plural forms and in-word representation for numerals. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from decimal import Decimal 9 | from typing import cast 10 | 11 | from pytils.utils import check_length, check_positive, split_values 12 | 13 | FRACTIONS = ( 14 | ("десятая", "десятых", "десятых"), 15 | ("сотая", "сотых", "сотых"), 16 | ("тысячная", "тысячных", "тысячных"), 17 | ("десятитысячная", "десятитысячных", "десятитысячных"), 18 | ("стотысячная", "стотысячных", "стотысячных"), 19 | ("миллионная", "милллионных", "милллионных"), 20 | ("десятимиллионная", "десятимилллионных", "десятимиллионных"), 21 | ("стомиллионная", "стомилллионных", "стомиллионных"), 22 | ("миллиардная", "миллиардных", "миллиардных"), 23 | ) #: Forms (1, 2, 5) for fractions 24 | 25 | ONES = { 26 | 0: ("", "", ""), 27 | 1: ("один", "одна", "одно"), 28 | 2: ("два", "две", "два"), 29 | 3: ("три", "три", "три"), 30 | 4: ("четыре", "четыре", "четыре"), 31 | 5: ("пять", "пять", "пять"), 32 | 6: ("шесть", "шесть", "шесть"), 33 | 7: ("семь", "семь", "семь"), 34 | 8: ("восемь", "восемь", "восемь"), 35 | 9: ("девять", "девять", "девять"), 36 | } #: Forms (MALE, FEMALE, NEUTER) for ones 37 | 38 | TENS = { 39 | 0: "", 40 | # 1 - особый случай 41 | 10: "десять", 42 | 11: "одиннадцать", 43 | 12: "двенадцать", 44 | 13: "тринадцать", 45 | 14: "четырнадцать", 46 | 15: "пятнадцать", 47 | 16: "шестнадцать", 48 | 17: "семнадцать", 49 | 18: "восемнадцать", 50 | 19: "девятнадцать", 51 | 2: "двадцать", 52 | 3: "тридцать", 53 | 4: "сорок", 54 | 5: "пятьдесят", 55 | 6: "шестьдесят", 56 | 7: "семьдесят", 57 | 8: "восемьдесят", 58 | 9: "девяносто", 59 | } #: Tens 60 | 61 | HUNDREDS = { 62 | 0: "", 63 | 1: "сто", 64 | 2: "двести", 65 | 3: "триста", 66 | 4: "четыреста", 67 | 5: "пятьсот", 68 | 6: "шестьсот", 69 | 7: "семьсот", 70 | 8: "восемьсот", 71 | 9: "девятьсот", 72 | } #: Hundreds 73 | 74 | MALE = 1 #: sex - male 75 | FEMALE = 2 #: sex - female 76 | NEUTER = 3 #: sex - neuter 77 | 78 | FORMS_COUNT = 3 79 | 80 | 81 | def _get_float_remainder(fvalue: int | float | Decimal, signs: int = 9) -> str: 82 | """ 83 | Get remainder of float, i.e. 2.05 -> '05' 84 | 85 | @param fvalue: input value 86 | @type fvalue: C{integer types}, C{float} or C{Decimal} 87 | 88 | @param signs: maximum number of signs 89 | @type signs: C{integer types} 90 | 91 | @return: remainder 92 | @rtype: C{str} 93 | 94 | @raise ValueError: fvalue is negative 95 | @raise ValueError: signs overflow 96 | """ 97 | check_positive(fvalue) 98 | if isinstance(fvalue, int): 99 | return "0" 100 | if isinstance(fvalue, Decimal) and fvalue.as_tuple()[2] == 0: 101 | # Decimal.as_tuple() -> (sign, digit_tuple, exponent) 102 | # если экспонента "0" -- значит дробной части нет 103 | return "0" 104 | 105 | signs = min(signs, len(FRACTIONS)) 106 | 107 | # нужно remainder в строке, потому что дробные X.0Y 108 | # будут "ломаться" до X.Y 109 | remainder = str(fvalue).split(".")[1] 110 | iremainder = int(remainder) 111 | orig_remainder = remainder 112 | factor = len(str(remainder)) - signs 113 | 114 | if factor > 0: 115 | # после запятой цифр больше чем signs, округляем 116 | iremainder = int(round(iremainder / (10.0**factor))) 117 | format = "%%0%dd" % min(len(remainder), signs) 118 | 119 | remainder = format % iremainder 120 | 121 | if len(remainder) > signs: 122 | # при округлении цифр вида 0.998 ругаться 123 | raise ValueError( 124 | "Signs overflow: I can't round only fractional part \ 125 | of %s to fit %s in %d signs" 126 | % (str(fvalue), orig_remainder, signs) 127 | ) 128 | 129 | return remainder 130 | 131 | 132 | def choose_plural(amount: int, variants: str | tuple[str, ...]) -> str: 133 | """ 134 | Choose proper case depending on amount 135 | 136 | @param amount: amount of objects 137 | @type amount: C{integer types} 138 | 139 | @param variants: variants (forms) of object in such form: 140 | (1 object, 2 objects, 5 objects). 141 | @type variants: 3-element C{sequence} of C{unicode} 142 | or C{unicode} (three variants with delimeter ',') 143 | 144 | @return: proper variant 145 | @rtype: C{str} 146 | 147 | @raise ValueError: variants' length lesser than 3 148 | """ 149 | 150 | if isinstance(variants, str): 151 | variants = split_values(variants) 152 | check_length(variants, FORMS_COUNT) 153 | 154 | amount = abs(amount) 155 | 156 | if amount % 10 == 1 and amount % 100 != 11: 157 | variant = 0 158 | elif ( 159 | amount % 10 >= 2 160 | and amount % 10 <= 4 161 | and (amount % 100 < 10 or amount % 100 >= 20) 162 | ): 163 | variant = 1 164 | else: 165 | variant = 2 166 | 167 | return variants[variant] 168 | 169 | 170 | def get_plural( 171 | amount: int, variants: str | tuple[str, ...], absence: str | None = None 172 | ) -> str: 173 | """ 174 | Get proper case with value 175 | 176 | @param amount: amount of objects 177 | @type amount: C{integer types} 178 | 179 | @param variants: variants (forms) of object in such form: 180 | (1 object, 2 objects, 5 objects). 181 | @type variants: 3-element C{sequence} of C{str} 182 | or C{str} (three variants with delimeter ',') 183 | 184 | @param absence: if amount is zero will return it 185 | @type absence: C{str} 186 | 187 | @return: amount with proper variant 188 | @rtype: C{str} 189 | """ 190 | if amount or absence is None: 191 | return "%d %s" % (amount, choose_plural(amount, variants)) 192 | else: 193 | return absence 194 | 195 | 196 | def _get_plural_legacy(amount, extra_variants): 197 | """ 198 | Get proper case with value (legacy variant, without absence) 199 | 200 | @param amount: amount of objects 201 | @type amount: C{integer types} 202 | 203 | @param variants: variants (forms) of object in such form: 204 | (1 object, 2 objects, 5 objects, 0-object variant). 205 | 0-object variant is similar to C{absence} in C{get_plural} 206 | @type variants: 3-element C{sequence} of C{str} 207 | or C{str} (three variants with delimeter ',') 208 | 209 | @return: amount with proper variant 210 | @rtype: C{str} 211 | """ 212 | absence = None 213 | if isinstance(extra_variants, str): 214 | extra_variants = split_values(extra_variants) 215 | if len(extra_variants) == 4: 216 | variants = extra_variants[:3] 217 | absence = extra_variants[3] 218 | else: 219 | variants = extra_variants 220 | return get_plural(amount, variants, absence) 221 | 222 | 223 | def rubles(amount: int | float | Decimal, zero_for_kopeck: bool = False) -> str: 224 | """ 225 | Get string for money 226 | 227 | @param amount: amount of money 228 | @type amount: C{integer types}, C{float} or C{Decimal} 229 | 230 | @param zero_for_kopeck: If false, then zero kopecks ignored 231 | @type zero_for_kopeck: C{bool} 232 | 233 | @return: in-words representation of money's amount 234 | @rtype: C{str} 235 | 236 | @raise ValueError: amount is negative 237 | """ 238 | check_positive(amount) 239 | 240 | pts = [] 241 | amount = round(amount, 2) 242 | pts.append(sum_string(int(amount), MALE, ("рубль", "рубля", "рублей"))) 243 | remainder = _get_float_remainder(amount, 2) 244 | iremainder = int(remainder) 245 | 246 | if iremainder != 0 or zero_for_kopeck: 247 | # если 3.1, то это 10 копеек, а не одна 248 | if iremainder < 10 and len(remainder) == 1: 249 | iremainder *= 10 250 | pts.append(sum_string(iremainder, FEMALE, ("копейка", "копейки", "копеек"))) 251 | 252 | return " ".join(pts) 253 | 254 | 255 | def in_words_int(amount: int, gender: int = MALE) -> str: 256 | """ 257 | Integer in words 258 | 259 | @param amount: numeral 260 | @type amount: C{integer types} 261 | 262 | @param gender: gender (MALE, FEMALE or NEUTER) 263 | @type gender: C{int} 264 | 265 | @return: in-words reprsentation of numeral 266 | @rtype: C{str} 267 | 268 | @raise ValueError: amount is negative 269 | """ 270 | check_positive(amount) 271 | 272 | return sum_string(amount, gender) 273 | 274 | 275 | def in_words_float(amount: float | Decimal) -> str: 276 | """ 277 | Float in words 278 | 279 | @param amount: float numeral 280 | @type amount: C{float} or C{Decimal} 281 | 282 | @return: in-words reprsentation of float numeral 283 | @rtype: C{str} 284 | 285 | @raise ValueError: when ammount is negative 286 | """ 287 | check_positive(amount) 288 | 289 | pts = [] 290 | # преобразуем целую часть 291 | pts.append(sum_string(int(amount), FEMALE, ("целая", "целых", "целых"))) 292 | # теперь то, что после запятой 293 | remainder = _get_float_remainder(amount) 294 | signs = len(str(remainder)) - 1 295 | pts.append(sum_string(int(remainder), FEMALE, FRACTIONS[signs])) 296 | 297 | return " ".join(pts) 298 | 299 | 300 | def in_words(amount: int | float | Decimal, gender: int | None = None) -> str: 301 | """ 302 | Numeral in words 303 | 304 | @param amount: numeral 305 | @type amount: C{integer types}, C{float} or C{Decimal} 306 | 307 | @param gender: gender (MALE, FEMALE or NEUTER) 308 | @type gender: C{int} 309 | 310 | @return: in-words reprsentation of numeral 311 | @rtype: C{str} 312 | 313 | raise ValueError: when amount is negative 314 | """ 315 | check_positive(amount) 316 | if isinstance(amount, Decimal) and amount.as_tuple()[2] == 0: 317 | # если целое, 318 | # т.е. Decimal.as_tuple -> (sign, digits tuple, exponent), exponent=0 319 | # то как целое 320 | amount = int(amount) 321 | if gender is None: 322 | args = (amount,) 323 | else: 324 | args = (amount, gender) 325 | # если целое 326 | if isinstance(amount, int): 327 | return in_words_int(*args) # ty: ignore[invalid-argument-type] 328 | # если дробное 329 | elif isinstance(amount, (float, Decimal)): 330 | return in_words_float(amount) 331 | # ни float, ни int, ни Decimal 332 | else: 333 | # до сюда не должно дойти 334 | raise TypeError( 335 | "amount should be number type (int, long, float, Decimal), got %s" 336 | % type(amount) 337 | ) 338 | 339 | 340 | def sum_string( 341 | amount: int, gender: int, items: str | tuple[str, ...] | None = None 342 | ) -> str: 343 | """ 344 | Get sum in words 345 | 346 | @param amount: amount of objects 347 | @type amount: C{integer types} 348 | 349 | @param gender: gender of object (MALE, FEMALE or NEUTER) 350 | @type gender: C{int} 351 | 352 | @param items: variants of object in three forms: 353 | for one object, for two objects and for five objects 354 | @type items: 3-element C{sequence} of C{str} or 355 | just C{str} (three variants with delimeter ',') 356 | 357 | @return: in-words representation objects' amount 358 | @rtype: C{str} 359 | 360 | @raise ValueError: items isn't 3-element C{sequence} or C{unicode} 361 | @raise ValueError: amount bigger than 10**11 362 | @raise ValueError: amount is negative 363 | """ 364 | if isinstance(items, str): 365 | items_tuple = split_values(items) 366 | elif items is None: 367 | items_tuple = ("", "", "") 368 | else: 369 | items_tuple = items 370 | 371 | check_positive(amount) 372 | check_length(items_tuple, FORMS_COUNT) 373 | items_tuple = cast(tuple[str, str, str], items_tuple) 374 | 375 | _, _, five_items = items_tuple 376 | 377 | if amount == 0: 378 | if five_items: 379 | return "ноль %s" % five_items 380 | else: 381 | return "ноль" 382 | 383 | into = "" 384 | tmp_val = amount 385 | 386 | # единицы 387 | into, tmp_val = _sum_string_fn(into, tmp_val, gender, items_tuple) 388 | # тысячи 389 | into, tmp_val = _sum_string_fn(into, tmp_val, FEMALE, ("тысяча", "тысячи", "тысяч")) 390 | # миллионы 391 | into, tmp_val = _sum_string_fn( 392 | into, tmp_val, MALE, ("миллион", "миллиона", "миллионов") 393 | ) 394 | # миллиарды 395 | into, tmp_val = _sum_string_fn( 396 | into, tmp_val, MALE, ("миллиард", "миллиарда", "миллиардов") 397 | ) 398 | if tmp_val == 0: 399 | return into 400 | else: 401 | raise ValueError("Cannot operand with numbers bigger than 10**11") 402 | 403 | 404 | def _sum_string_fn( 405 | into: str, tmp_val: int, gender: int, items: tuple[str, str, str] 406 | ) -> tuple[str, int]: 407 | """ 408 | Make in-words representation of single order 409 | 410 | @param into: in-words representation of lower orders 411 | @type into: C{str} 412 | 413 | @param tmp_val: temporary value without lower orders 414 | @type tmp_val: C{integer types} 415 | 416 | @param gender: gender (MALE, FEMALE or NEUTER) 417 | @type gender: C{int} 418 | 419 | @param items: variants of objects 420 | @type items: 3-element C{sequence} of C{str} 421 | 422 | @return: new into and tmp_val 423 | @rtype: C{tuple} 424 | 425 | @raise ValueError: tmp_val is negative 426 | """ 427 | _, _, five_items = items 428 | 429 | check_positive(tmp_val) 430 | 431 | if tmp_val == 0: 432 | return into, tmp_val 433 | 434 | words = [] 435 | 436 | rest = tmp_val % 1000 437 | tmp_val = tmp_val // 1000 438 | if rest == 0: 439 | # последние три знака нулевые 440 | if into == "": 441 | into = "%s " % five_items 442 | return into, tmp_val 443 | 444 | # начинаем подсчет с rest 445 | end_word = five_items 446 | 447 | # сотни 448 | words.append(HUNDREDS[rest // 100]) 449 | 450 | # десятки 451 | rest = rest % 100 452 | rest1 = rest // 10 453 | # особый случай -- tens=1 454 | tens = rest1 == 1 and TENS[rest] or TENS[rest1] 455 | words.append(tens) 456 | 457 | # единицы 458 | if rest1 < 1 or rest1 > 1: 459 | amount = rest % 10 460 | end_word = choose_plural(amount, items) 461 | words.append(ONES[amount][gender - 1]) 462 | words.append(end_word) 463 | 464 | # добавляем то, что уже было 465 | words.append(into) 466 | 467 | # убираем пустые подстроки 468 | words = filter(lambda x: len(x) > 0, words) 469 | 470 | # склеиваем и отдаем 471 | return " ".join(words).strip(), tmp_val 472 | -------------------------------------------------------------------------------- /pytils/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Pytils templatetags for Django web-framework 3 | """ 4 | 5 | 6 | # Если отладка, то показываем 'unknown+сообщение об ошибке'. 7 | # Если отладка выключена, то можно чтобы при ошибках показывалось 8 | # значение, переданное фильтру (PYTILS_SHOW_VALUES_ON_ERROR=True) 9 | # либо пустая строка. 10 | def init_defaults(debug, show_value): 11 | if debug: 12 | default_value = "unknown: %(error)s" 13 | default_uvalue = "unknown: %(error)s" 14 | elif show_value: 15 | default_value = "%(value)s" 16 | default_uvalue = "%(value)s" 17 | else: 18 | default_value = "" 19 | default_uvalue = "" 20 | return default_value, default_uvalue 21 | -------------------------------------------------------------------------------- /pytils/templatetags/pytils_dt.py: -------------------------------------------------------------------------------- 1 | # -*- test-case-name: pytils.test.templatetags.test_dt -*- 2 | """ 3 | pytils.dt templatetags for Django web-framework 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import datetime 9 | import time 10 | 11 | from django import conf, template 12 | from django.utils import timezone 13 | 14 | from pytils import dt 15 | from pytils.templatetags import init_defaults 16 | 17 | register = template.Library() #: Django template tag/filter registrator 18 | debug = conf.settings.DEBUG #: Debug mode (sets in Django project's settings) 19 | show_value = getattr( 20 | conf.settings, "PYTILS_SHOW_VALUES_ON_ERROR", False 21 | ) #: Show values on errors (sets in Django project's settings) 22 | 23 | default_value, default_uvalue = init_defaults(debug, show_value) 24 | 25 | 26 | # -- filters -- 27 | def distance_of_time( 28 | from_time: int | float | datetime.datetime, accuracy: int = 1 29 | ) -> str: 30 | """ 31 | Display distance of time from current time. 32 | 33 | Parameter is an accuracy level (deafult is 1). 34 | Value must be numeral (i.e. time.time() result) or 35 | datetime.datetime (i.e. datetime.datetime.now() 36 | result). 37 | 38 | Examples:: 39 | {{ some_time|distance_of_time }} 40 | {{ some_dtime|distance_of_time:2 }} 41 | """ 42 | try: 43 | to_time = None 44 | if conf.settings.USE_TZ: 45 | to_time = timezone.now() 46 | res = dt.distance_of_time_in_words(from_time, accuracy, to_time) 47 | except Exception as err: 48 | # because filter must die silently 49 | try: 50 | default_distance = "%s seconds" % str(int(time.time() - from_time)) # ty: ignore[unsupported-operator] 51 | except Exception: 52 | default_distance = "" 53 | res = default_value % {"error": err, "value": default_distance} 54 | return res 55 | 56 | 57 | def ru_strftime( 58 | date: datetime.date | datetime.datetime, 59 | format: str = "%d.%m.%Y", 60 | inflected_day: bool = False, 61 | preposition: bool = False, 62 | ) -> str: 63 | """ 64 | Russian strftime, formats date with given format. 65 | 66 | Value is a date (supports datetime.date and datetime.datetime), 67 | parameter is a format (string). For explainings about format, 68 | see documentation for original strftime: 69 | http://docs.python.org/lib/module-time.html 70 | 71 | Examples:: 72 | {{ some_date|ru_strftime:"%d %B %Y, %A" }} 73 | """ 74 | try: 75 | res = dt.ru_strftime( 76 | format, 77 | date, 78 | inflected=True, 79 | inflected_day=inflected_day, 80 | preposition=preposition, 81 | ) 82 | except Exception as err: 83 | # because filter must die silently 84 | try: 85 | default_date = date.strftime(format) 86 | except Exception: 87 | default_date = str(date) 88 | res = default_value % {"error": err, "value": default_date} 89 | return res 90 | 91 | 92 | def ru_strftime_inflected( 93 | date: datetime.date | datetime.datetime, format: str = "%d.%m.%Y" 94 | ) -> str: 95 | """ 96 | Russian strftime with inflected day, formats date 97 | with given format (similar to ru_strftime), 98 | also inflects day in proper form. 99 | 100 | Examples:: 101 | {{ some_date|ru_strftime_inflected:"in %A (%d %B %Y)" 102 | """ 103 | return ru_strftime(date, format, inflected_day=True) 104 | 105 | 106 | def ru_strftime_preposition( 107 | date: datetime.date | datetime.datetime, format: str = "%d.%m.%Y" 108 | ) -> str: 109 | """ 110 | Russian strftime with inflected day and correct preposition, 111 | formats date with given format (similar to ru_strftime), 112 | also inflects day in proper form and inserts correct 113 | preposition. 114 | 115 | Examples:: 116 | {{ some_date|ru_strftime_prepoisiton:"%A (%d %B %Y)" 117 | """ 118 | return ru_strftime(date, format, preposition=True) 119 | 120 | 121 | # -- register filters 122 | register.filter("distance_of_time", distance_of_time) 123 | register.filter("ru_strftime", ru_strftime) 124 | register.filter("ru_strftime_inflected", ru_strftime_inflected) 125 | register.filter("ru_strftime_preposition", ru_strftime_preposition) 126 | -------------------------------------------------------------------------------- /pytils/templatetags/pytils_numeral.py: -------------------------------------------------------------------------------- 1 | # -*- test-case-name: pytils.test.templatetags.test_numeral -*- 2 | """ 3 | pytils.numeral templatetags for Django web-framework 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from decimal import Decimal 9 | 10 | from django import conf, template 11 | from django.utils.encoding import smart_str 12 | 13 | from pytils import numeral 14 | from pytils.templatetags import init_defaults 15 | 16 | register = template.Library() #: Django template tag/filter registrator 17 | encoding = ( 18 | conf.settings.DEFAULT_CHARSET 19 | ) #: Current charset (sets in Django project's settings) 20 | debug = conf.settings.DEBUG #: Debug mode (sets in Django project's settings) 21 | show_value = getattr( 22 | conf.settings, "PYTILS_SHOW_VALUES_ON_ERROR", False 23 | ) #: Show values on errors (sets in Django project's settings) 24 | 25 | default_value, default_uvalue = init_defaults(debug, show_value) 26 | 27 | # -- filters 28 | 29 | 30 | def choose_plural(amount: int, variants: str | tuple[str, ...]) -> str: 31 | """ 32 | Choose proper form for plural. 33 | 34 | Value is a amount, parameters are forms of noun. 35 | Forms are variants for 1, 2, 5 nouns. It may be tuple 36 | of elements, or string where variants separates each other 37 | by comma. 38 | 39 | Examples:: 40 | {{ some_int|choose_plural:"пример,примера,примеров" }} 41 | """ 42 | try: 43 | if isinstance(variants, str): 44 | uvariants = smart_str(variants, encoding) 45 | else: 46 | uvariants = [smart_str(v, encoding) for v in variants] 47 | res = numeral.choose_plural(amount, uvariants) 48 | except Exception as err: 49 | # because filter must die silently 50 | try: 51 | default_variant = variants 52 | except Exception: 53 | default_variant = "" 54 | res = default_value % {"error": err, "value": default_variant} 55 | return res 56 | 57 | 58 | def get_plural( 59 | amount: int, variants: str | tuple[str, ...], absence: str | None = None 60 | ) -> str: 61 | """ 62 | Get proper form for plural and it value. 63 | 64 | Value is a amount, parameters are forms of noun. 65 | Forms are variants for 1, 2, 5 nouns. It may be tuple 66 | of elements, or string where variants separates each other 67 | by comma. You can append 'absence variant' after all over variants 68 | 69 | Examples:: 70 | {{ some_int|get_plural:"пример,примера,примеров,нет примеров" }} 71 | """ 72 | try: 73 | if isinstance(variants, str): 74 | uvariants = smart_str(variants, encoding) 75 | else: 76 | uvariants = [smart_str(v, encoding) for v in variants] 77 | res = numeral._get_plural_legacy(amount, uvariants) 78 | except Exception as err: 79 | # because filter must die silently 80 | try: 81 | default_variant = variants 82 | except Exception: 83 | default_variant = "" 84 | res = default_value % {"error": err, "value": default_variant} 85 | return res 86 | 87 | 88 | def rubles(amount: int | float | Decimal, zero_for_kopeck: bool = False) -> str: 89 | """Converts float value to in-words representation (for money)""" 90 | try: 91 | res = numeral.rubles(amount, zero_for_kopeck) 92 | except Exception as err: 93 | # because filter must die silently 94 | res = default_value % {"error": err, "value": str(amount)} 95 | return res 96 | 97 | 98 | def in_words(amount: int | float | Decimal, gender: int | None = None) -> str: 99 | """ 100 | In-words representation of amount. 101 | 102 | Parameter is a gender: MALE, FEMALE or NEUTER 103 | 104 | Examples:: 105 | {{ some_int|in_words }} 106 | {{ some_other_int|in_words:FEMALE }} 107 | """ 108 | try: 109 | res = numeral.in_words(amount, getattr(numeral, str(gender), None)) 110 | except Exception as err: 111 | # because filter must die silently 112 | res = default_value % {"error": err, "value": str(amount)} 113 | return res 114 | 115 | 116 | # -- register filters 117 | register.filter("choose_plural", choose_plural) 118 | register.filter("get_plural", get_plural) 119 | register.filter("rubles", rubles) 120 | register.filter("in_words", in_words) 121 | 122 | 123 | # -- tags 124 | def sum_string( 125 | amount: int, gender: int, items: str | tuple[str, ...] | None = None 126 | ) -> str: 127 | """ 128 | in_words and choose_plural in a one flask 129 | Makes in-words representation of value with 130 | choosing correct form of noun. 131 | 132 | First parameter is an amount of objects. Second is a 133 | gender (MALE, FEMALE, NEUTER). Third is a variants 134 | of forms for object name. 135 | 136 | Examples:: 137 | {% sum_string some_int MALE "пример,примера,примеров" %} 138 | {% sum_string some_other_int FEMALE "задача,задачи,задач" %} 139 | """ 140 | try: 141 | if isinstance(items, str): 142 | uitems = smart_str(items, encoding, default_uvalue) 143 | else: 144 | uitems = [ 145 | smart_str(i, encoding) 146 | for i in items # ty: ignore[not-iterable] 147 | ] 148 | res = numeral.sum_string(amount, getattr(numeral, str(gender), None), uitems) # ty: ignore[invalid-argument-type] 149 | except Exception as err: 150 | # because tag's renderer must die silently 151 | res = default_value % {"error": err, "value": str(amount)} 152 | return res 153 | 154 | 155 | # -- register tags 156 | register.simple_tag(sum_string) 157 | -------------------------------------------------------------------------------- /pytils/templatetags/pytils_translit.py: -------------------------------------------------------------------------------- 1 | # -*- test-case-name: pytils.test.templatetags.test_translit -*- 2 | """ 3 | pytils.translit templatetags for Django web-framework 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from django import conf, template 9 | from django.utils.encoding import smart_str 10 | 11 | from pytils import translit 12 | from pytils.templatetags import init_defaults 13 | 14 | register = template.Library() #: Django template tag/filter registrator 15 | debug = conf.settings.DEBUG #: Debug mode (sets in Django project's settings) 16 | encoding = ( 17 | conf.settings.DEFAULT_CHARSET 18 | ) #: Current charset (sets in Django project's settings) 19 | show_value = getattr( 20 | conf.settings, "PYTILS_SHOW_VALUES_ON_ERROR", False 21 | ) #: Show values on errors (sets in Django project's settings) 22 | 23 | default_value, default_uvalue = init_defaults(debug, show_value) 24 | 25 | # -- filters -- 26 | 27 | 28 | def translify(text: str) -> str: 29 | """Translify russian text""" 30 | try: 31 | res = translit.translify(smart_str(text, encoding)) 32 | except Exception as err: 33 | # because filter must die silently 34 | res = default_value % {"error": err, "value": text} 35 | return res 36 | 37 | 38 | def detranslify(text: str) -> str: 39 | """Detranslify russian text""" 40 | try: 41 | res = translit.detranslify(text) 42 | except Exception as err: 43 | # because filter must die silently 44 | res = default_value % {"error": err, "value": text} 45 | return res 46 | 47 | 48 | def slugify(text: str) -> str: 49 | """Make slug from (russian) text""" 50 | try: 51 | res = translit.slugify(smart_str(text, encoding)) 52 | except Exception as err: 53 | # because filter must die silently 54 | res = default_value % {"error": err, "value": text} 55 | return res 56 | 57 | 58 | # -- register filters 59 | register.filter("translify", translify) 60 | register.filter("detranslify", detranslify) 61 | register.filter("slugify", slugify) 62 | -------------------------------------------------------------------------------- /pytils/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/last-partizan/pytils/62a693501212d949a3ea43d58cbcb19a0a66f8be/pytils/test/__init__.py -------------------------------------------------------------------------------- /pytils/test/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/last-partizan/pytils/62a693501212d949a3ea43d58cbcb19a0a66f8be/pytils/test/templatetags/__init__.py -------------------------------------------------------------------------------- /pytils/test/templatetags/helpers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helpers for templatetags' unit tests in Django webframework 3 | """ 4 | 5 | from django.template import Context, Template 6 | from django.test import TestCase 7 | 8 | 9 | class TemplateTagTestCase(TestCase): 10 | """ 11 | TestCase for testing template tags and filters 12 | """ 13 | 14 | def check_template_tag( 15 | self, template_string: str, context: dict, result_string: str 16 | ) -> None: 17 | """ 18 | Method validates output of template tag or filter 19 | 20 | @rtype: object 21 | @param template_string: contents of template 22 | @type template_string: C{str} 23 | 24 | @param context: rendering context 25 | @type context: C{dict} 26 | 27 | @param result_string: reference output 28 | @type result_string: C{str} 29 | """ 30 | t = Template(template_string) 31 | c = Context(context) 32 | output = t.render(c) 33 | self.assertEqual(output, result_string) 34 | -------------------------------------------------------------------------------- /pytils/test/templatetags/test_common.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for pytils' templatetags common things 3 | """ 4 | 5 | from django.test import TestCase 6 | 7 | from pytils import templatetags as tt 8 | 9 | 10 | class TemplateTagsCommonsTestCase(TestCase): 11 | def testInitDefaults(self): 12 | """ 13 | Unit-tests for pytils.templatetags.init_defaults 14 | """ 15 | self.assertEqual(tt.init_defaults(debug=False, show_value=False), ("", "")) 16 | self.assertEqual( 17 | tt.init_defaults(debug=False, show_value=True), ("%(value)s", "%(value)s") 18 | ) 19 | self.assertEqual( 20 | tt.init_defaults(debug=True, show_value=False), 21 | ("unknown: %(error)s", "unknown: %(error)s"), 22 | ) 23 | self.assertEqual( 24 | tt.init_defaults(debug=True, show_value=True), 25 | ("unknown: %(error)s", "unknown: %(error)s"), 26 | ) 27 | -------------------------------------------------------------------------------- /pytils/test/templatetags/test_dt.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for pytils' dt templatetags for Django web framework 3 | """ 4 | 5 | import datetime 6 | 7 | from . import helpers 8 | 9 | 10 | class DtDefaultTestCase(helpers.TemplateTagTestCase): 11 | def setUp(self): 12 | self.date = datetime.datetime(2007, 1, 26, 15, 50) 13 | self.date_before = datetime.datetime.now() - datetime.timedelta(1, 2000) 14 | 15 | def testLoad(self): 16 | self.check_template_tag("{% load pytils_dt %}", {}, "") 17 | 18 | def testRuStrftimeFilter(self): 19 | self.check_template_tag( 20 | '{% load pytils_dt %}{{ val|ru_strftime:"%d %B %Y, %A" }}', 21 | {"val": self.date}, 22 | "26 января 2007, пятница", 23 | ) 24 | 25 | def testRuStrftimeInflectedFilter(self): 26 | self.check_template_tag( 27 | '{% load pytils_dt %}{{ val|ru_strftime_inflected:"в %A, %d %B %Y" }}', 28 | {"val": self.date}, 29 | "в пятницу, 26 января 2007", 30 | ) 31 | 32 | def testDistanceFilter(self): 33 | self.check_template_tag( 34 | "{% load pytils_dt %}{{ val|distance_of_time }}", 35 | {"val": self.date_before}, 36 | "вчера", 37 | ) 38 | 39 | self.check_template_tag( 40 | "{% load pytils_dt %}{{ val|distance_of_time:3 }}", 41 | {"val": self.date_before}, 42 | "1 день 0 часов 33 минуты назад", 43 | ) 44 | 45 | # без отладки, если ошибка -- по умолчанию пустая строка 46 | def testRuStrftimeError(self): 47 | self.check_template_tag( 48 | '{% load pytils_dt %}{{ val|ru_strftime:"%d %B %Y" }}', {"val": 1}, "" 49 | ) 50 | -------------------------------------------------------------------------------- /pytils/test/templatetags/test_numeral.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for pytils' numeral templatetags for Django web framework 3 | """ 4 | 5 | from . import helpers 6 | 7 | 8 | class NumeralDefaultTestCase(helpers.TemplateTagTestCase): 9 | def testLoad(self): 10 | self.check_template_tag("{% load pytils_numeral %}", {}, "") 11 | 12 | def testChoosePluralFilter(self): 13 | self.check_template_tag( 14 | '{% load pytils_numeral %}{{ val|choose_plural:"гвоздь,гвоздя,гвоздей" }}', 15 | {"val": 10}, 16 | "гвоздей", 17 | ) 18 | 19 | def testGetPluralFilter(self): 20 | self.check_template_tag( 21 | '{% load pytils_numeral %}{{ val|get_plural:"гвоздь,гвоздя,гвоздей" }}', 22 | {"val": 10}, 23 | "10 гвоздей", 24 | ) 25 | 26 | self.check_template_tag( 27 | '{% load pytils_numeral %}{{ val|get_plural:"гвоздь,гвоздя,гвоздей" }}', 28 | {"val": 0}, 29 | "0 гвоздей", 30 | ) 31 | 32 | self.check_template_tag( 33 | '{% load pytils_numeral %}{{ val|get_plural:"гвоздь,гвоздя,гвоздей,нет гвоздей" }}', 34 | {"val": 0}, 35 | "нет гвоздей", 36 | ) 37 | 38 | def testRublesFilter(self): 39 | self.check_template_tag( 40 | "{% load pytils_numeral %}{{ val|rubles }}", 41 | {"val": 10.1}, 42 | "десять рублей десять копеек", 43 | ) 44 | 45 | def testInWordsFilter(self): 46 | self.check_template_tag( 47 | "{% load pytils_numeral %}{{ val|in_words }}", {"val": 21}, "двадцать один" 48 | ) 49 | 50 | self.check_template_tag( 51 | '{% load pytils_numeral %}{{ val|in_words:"NEUTER" }}', 52 | {"val": 21}, 53 | "двадцать одно", 54 | ) 55 | 56 | def testSumStringTag(self): 57 | self.check_template_tag( 58 | '{% load pytils_numeral %}{% sum_string val "MALE" "пример,пример,примеров" %}', 59 | {"val": 21}, 60 | "двадцать один пример", 61 | ) 62 | 63 | self.check_template_tag( 64 | "{% load pytils_numeral %}{% sum_string val male variants %}", 65 | {"val": 21, "male": "MALE", "variants": ("пример", "пример", "примеров")}, 66 | "двадцать один пример", 67 | ) 68 | 69 | # без отладки, если ошибка -- по умолчанию пустая строка 70 | def testChoosePluralError(self): 71 | self.check_template_tag( 72 | '{% load pytils_numeral %}{{ val|choose_plural:"вариант" }}', {"val": 1}, "" 73 | ) 74 | -------------------------------------------------------------------------------- /pytils/test/templatetags/test_translit.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for pytils' translit templatetags for Django web framework 3 | """ 4 | 5 | from . import helpers 6 | 7 | 8 | class TranslitDefaultTestCase(helpers.TemplateTagTestCase): 9 | def testLoad(self): 10 | self.check_template_tag("{% load pytils_translit %}", {}, "") 11 | 12 | def testTranslifyFilter(self): 13 | self.check_template_tag( 14 | "{% load pytils_translit %}{{ val|translify }}", 15 | {"val": "проверка"}, 16 | "proverka", 17 | ) 18 | 19 | def testDetranslifyFilter(self): 20 | self.check_template_tag( 21 | "{% load pytils_translit %}{{ val|detranslify }}", 22 | {"val": "proverka"}, 23 | "проверка", 24 | ) 25 | 26 | def testSlugifyFilter(self): 27 | self.check_template_tag( 28 | "{% load pytils_translit %}{{ val|slugify }}", 29 | {"val": "Проверка связи"}, 30 | "proverka-svyazi", 31 | ) 32 | -------------------------------------------------------------------------------- /pytils/test/test_dt.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit-tests for pytils.dt 3 | """ 4 | 5 | import datetime 6 | import time 7 | import unittest 8 | 9 | import pytils 10 | 11 | 12 | class DistanceOfTimeInWordsTestCase(unittest.TestCase): 13 | """ 14 | Test case for pytils.dt.distance_of_time_in_words 15 | """ 16 | 17 | def setUp(self): 18 | """ 19 | Setting up environment for tests 20 | """ 21 | self.time = 1156862275.7711999 22 | self.dtime = {} 23 | self.updateTime(self.time) 24 | 25 | def updateTime(self, _time): 26 | """Update all time-related values for current time""" 27 | self.dtime["10sec_ago"] = _time - 10 28 | self.dtime["1min_ago"] = _time - 60 29 | self.dtime["10min_ago"] = _time - 600 30 | self.dtime["59min_ago"] = _time - 3540 31 | self.dtime["59min59sec_ago"] = _time - 3599 32 | self.dtime["1hr_ago"] = _time - 3600 33 | self.dtime["1hr1sec_ago"] = _time - 3601 34 | self.dtime["1hr59sec_ago"] = _time - 3659 35 | self.dtime["1hr1min_ago"] = _time - 3660 36 | self.dtime["1hr2min_ago"] = _time - 3720 37 | self.dtime["10hr_ago"] = _time - 36600 38 | self.dtime["1day_ago"] = _time - 87600 39 | self.dtime["1day1hr_ago"] = _time - 90600 40 | self.dtime["2day_ago"] = _time - 87600 * 2 41 | self.dtime["4day1min_ago"] = _time - 87600 * 4 - 60 42 | 43 | self.dtime["in_10sec"] = _time + 10 44 | self.dtime["in_1min"] = _time + 61 45 | self.dtime["in_10min"] = _time + 601 46 | self.dtime["in_1hr"] = _time + 3721 47 | self.dtime["in_10hr"] = _time + 36601 48 | self.dtime["in_1day"] = _time + 87601 49 | self.dtime["in_1day1hr"] = _time + 90601 50 | self.dtime["in_2day"] = _time + 87600 * 2 + 1 51 | 52 | def ckDefaultAccuracy(self, typ, estimated): 53 | """ 54 | Checks with default value for accuracy 55 | """ 56 | t0 = time.time() 57 | # --- change state !!! attention 58 | self.updateTime(t0) 59 | # --- 60 | t1 = self.dtime[typ] 61 | res = pytils.dt.distance_of_time_in_words(from_time=t1, to_time=t0) 62 | # --- revert state to original value 63 | self.updateTime(self.time) 64 | # --- 65 | self.assertEqual(res, estimated) 66 | 67 | def ckDefaultTimeAndAccuracy(self, typ, estimated): 68 | """ 69 | Checks with default accuracy and default time 70 | """ 71 | t0 = time.time() 72 | # --- change state !!! attention 73 | self.updateTime(t0) 74 | # --- 75 | t1 = self.dtime[typ] 76 | res = pytils.dt.distance_of_time_in_words(t1) 77 | # --- revert state to original value 78 | self.updateTime(self.time) 79 | # --- 80 | self.assertEqual(res, estimated) 81 | 82 | def ckDefaultToTime(self, typ, accuracy, estimated): 83 | """ 84 | Checks with default value of time 85 | """ 86 | t0 = time.time() 87 | # --- change state !!! attention 88 | self.updateTime(t0) 89 | # --- 90 | t1 = self.dtime[typ] 91 | res = pytils.dt.distance_of_time_in_words(t1, accuracy) 92 | # --- revert state to original value 93 | self.updateTime(self.time) 94 | # --- 95 | self.assertEqual(res, estimated) 96 | 97 | def testDOTIWDefaultAccuracy(self): 98 | """ 99 | Unit-test for distance_of_time_in_words with default accuracy 100 | """ 101 | self.ckDefaultAccuracy("10sec_ago", "менее минуты назад") 102 | self.ckDefaultAccuracy("1min_ago", "1 минуту назад") 103 | self.ckDefaultAccuracy("10min_ago", "10 минут назад") 104 | self.ckDefaultAccuracy("59min_ago", "59 минут назад") 105 | self.ckDefaultAccuracy("59min59sec_ago", "59 минут назад") 106 | self.ckDefaultAccuracy("1hr_ago", "1 час назад") 107 | self.ckDefaultAccuracy("1hr1sec_ago", "1 час назад") 108 | self.ckDefaultAccuracy("1hr59sec_ago", "1 час назад") 109 | self.ckDefaultAccuracy("1hr1min_ago", "1 час назад") 110 | self.ckDefaultAccuracy("1hr2min_ago", "1 час назад") 111 | self.ckDefaultAccuracy("10hr_ago", "10 часов назад") 112 | self.ckDefaultAccuracy("1day_ago", "1 день назад") 113 | self.ckDefaultAccuracy("1day1hr_ago", "1 день назад") 114 | self.ckDefaultAccuracy("2day_ago", "2 дня назад") 115 | 116 | self.ckDefaultAccuracy("in_10sec", "менее чем через минуту") 117 | self.ckDefaultAccuracy("in_1min", "через 1 минуту") 118 | self.ckDefaultAccuracy("in_10min", "через 10 минут") 119 | self.ckDefaultAccuracy("in_1hr", "через 1 час") 120 | self.ckDefaultAccuracy("in_10hr", "через 10 часов") 121 | self.ckDefaultAccuracy("in_1day", "через 1 день") 122 | self.ckDefaultAccuracy("in_1day1hr", "через 1 день") 123 | self.ckDefaultAccuracy("in_2day", "через 2 дня") 124 | 125 | def testDOTIWDefaultAccuracyDayAndMinute(self): 126 | """ 127 | Unit-tests for distance_of_time_in_words with default accuracy and to_time 128 | """ 129 | self.ckDefaultTimeAndAccuracy("4day1min_ago", "4 дня назад") 130 | 131 | self.ckDefaultTimeAndAccuracy("10sec_ago", "менее минуты назад") 132 | self.ckDefaultTimeAndAccuracy("1min_ago", "минуту назад") 133 | self.ckDefaultTimeAndAccuracy("10min_ago", "10 минут назад") 134 | self.ckDefaultTimeAndAccuracy("59min_ago", "59 минут назад") 135 | self.ckDefaultTimeAndAccuracy("59min59sec_ago", "59 минут назад") 136 | self.ckDefaultTimeAndAccuracy("1hr_ago", "час назад") 137 | self.ckDefaultTimeAndAccuracy("1hr1sec_ago", "час назад") 138 | self.ckDefaultTimeAndAccuracy("1hr59sec_ago", "час назад") 139 | self.ckDefaultTimeAndAccuracy("1hr1min_ago", "час назад") 140 | self.ckDefaultTimeAndAccuracy("1hr2min_ago", "час назад") 141 | self.ckDefaultTimeAndAccuracy("10hr_ago", "10 часов назад") 142 | self.ckDefaultTimeAndAccuracy("1day_ago", "вчера") 143 | self.ckDefaultTimeAndAccuracy("1day1hr_ago", "вчера") 144 | self.ckDefaultTimeAndAccuracy("2day_ago", "позавчера") 145 | 146 | self.ckDefaultTimeAndAccuracy("in_10sec", "менее чем через минуту") 147 | self.ckDefaultTimeAndAccuracy("in_1min", "через минуту") 148 | self.ckDefaultTimeAndAccuracy("in_10min", "через 10 минут") 149 | self.ckDefaultTimeAndAccuracy("in_1hr", "через час") 150 | self.ckDefaultTimeAndAccuracy("in_10hr", "через 10 часов") 151 | self.ckDefaultTimeAndAccuracy("in_1day", "завтра") 152 | self.ckDefaultTimeAndAccuracy("in_1day1hr", "завтра") 153 | self.ckDefaultTimeAndAccuracy("in_2day", "послезавтра") 154 | 155 | def test4Days1MinuteDaytimeBug2(self): 156 | from_time = datetime.datetime.now() - datetime.timedelta(days=4, minutes=1) 157 | res = pytils.dt.distance_of_time_in_words(from_time) 158 | self.assertEqual(res, "4 дня назад") 159 | 160 | def testDOTIWDefaultToTimeAcc1(self): 161 | """ 162 | Unit-tests for distance_of_time_in_words with default to_time and accuracy=1 163 | """ 164 | # accuracy = 1 165 | self.ckDefaultToTime("10sec_ago", 1, "менее минуты назад") 166 | self.ckDefaultToTime("1min_ago", 1, "минуту назад") 167 | self.ckDefaultToTime("10min_ago", 1, "10 минут назад") 168 | self.ckDefaultToTime("59min_ago", 1, "59 минут назад") 169 | self.ckDefaultToTime("59min59sec_ago", 1, "59 минут назад") 170 | self.ckDefaultToTime("1hr_ago", 1, "час назад") 171 | self.ckDefaultToTime("1hr1sec_ago", 1, "час назад") 172 | self.ckDefaultToTime("1hr59sec_ago", 1, "час назад") 173 | self.ckDefaultToTime("1hr1min_ago", 1, "час назад") 174 | self.ckDefaultToTime("1hr2min_ago", 1, "час назад") 175 | self.ckDefaultToTime("10hr_ago", 1, "10 часов назад") 176 | self.ckDefaultToTime("1day_ago", 1, "вчера") 177 | self.ckDefaultToTime("1day1hr_ago", 1, "вчера") 178 | self.ckDefaultToTime("2day_ago", 1, "позавчера") 179 | 180 | self.ckDefaultToTime("in_10sec", 1, "менее чем через минуту") 181 | self.ckDefaultToTime("in_1min", 1, "через минуту") 182 | self.ckDefaultToTime("in_10min", 1, "через 10 минут") 183 | self.ckDefaultToTime("in_1hr", 1, "через час") 184 | self.ckDefaultToTime("in_10hr", 1, "через 10 часов") 185 | self.ckDefaultToTime("in_1day", 1, "завтра") 186 | self.ckDefaultToTime("in_1day1hr", 1, "завтра") 187 | self.ckDefaultToTime("in_2day", 1, "послезавтра") 188 | 189 | def testDOTIWDefaultToTimeAcc2(self): 190 | """ 191 | Unit-tests for distance_of_time_in_words with default to_time and accuracy=2 192 | """ 193 | # accuracy = 2 194 | self.ckDefaultToTime("10sec_ago", 2, "менее минуты назад") 195 | self.ckDefaultToTime("1min_ago", 2, "минуту назад") 196 | self.ckDefaultToTime("10min_ago", 2, "10 минут назад") 197 | self.ckDefaultToTime("59min_ago", 2, "59 минут назад") 198 | self.ckDefaultToTime("59min59sec_ago", 2, "59 минут назад") 199 | self.ckDefaultToTime("1hr_ago", 2, "час назад") 200 | self.ckDefaultToTime("1hr1sec_ago", 2, "час назад") 201 | self.ckDefaultToTime("1hr59sec_ago", 2, "час назад") 202 | self.ckDefaultToTime("1hr1min_ago", 2, "1 час 1 минуту назад") 203 | self.ckDefaultToTime("1hr2min_ago", 2, "1 час 2 минуты назад") 204 | self.ckDefaultToTime("10hr_ago", 2, "10 часов 10 минут назад") 205 | self.ckDefaultToTime("1day_ago", 2, "вчера") 206 | self.ckDefaultToTime("1day1hr_ago", 2, "1 день 1 час назад") 207 | self.ckDefaultToTime("2day_ago", 2, "позавчера") 208 | 209 | self.ckDefaultToTime("in_10sec", 2, "менее чем через минуту") 210 | self.ckDefaultToTime("in_1min", 2, "через минуту") 211 | self.ckDefaultToTime("in_10min", 2, "через 10 минут") 212 | self.ckDefaultToTime("in_1hr", 2, "через 1 час 2 минуты") 213 | self.ckDefaultToTime("in_10hr", 2, "через 10 часов 10 минут") 214 | self.ckDefaultToTime("in_1day", 2, "завтра") 215 | self.ckDefaultToTime("in_1day1hr", 2, "через 1 день 1 час") 216 | self.ckDefaultToTime("in_2day", 2, "послезавтра") 217 | 218 | def testDOTIWDefaultToTimeAcc3(self): 219 | """ 220 | Unit-tests for distance_of_time_in_words with default to_time and accuracy=3 221 | """ 222 | # accuracy = 3 223 | self.ckDefaultToTime("10sec_ago", 3, "менее минуты назад") 224 | self.ckDefaultToTime("1min_ago", 3, "минуту назад") 225 | self.ckDefaultToTime("10min_ago", 3, "10 минут назад") 226 | self.ckDefaultToTime("59min_ago", 3, "59 минут назад") 227 | self.ckDefaultToTime("59min59sec_ago", 3, "59 минут назад") 228 | self.ckDefaultToTime("1hr_ago", 3, "час назад") 229 | self.ckDefaultToTime("1hr1sec_ago", 3, "час назад") 230 | self.ckDefaultToTime("1hr59sec_ago", 3, "час назад") 231 | self.ckDefaultToTime("1hr1min_ago", 3, "1 час 1 минуту назад") 232 | self.ckDefaultToTime("1hr2min_ago", 3, "1 час 2 минуты назад") 233 | self.ckDefaultToTime("10hr_ago", 3, "10 часов 10 минут назад") 234 | self.ckDefaultToTime("1day_ago", 3, "1 день 0 часов 20 минут назад") 235 | self.ckDefaultToTime("1day1hr_ago", 3, "1 день 1 час 10 минут назад") 236 | self.ckDefaultToTime("2day_ago", 3, "2 дня 0 часов 40 минут назад") 237 | 238 | self.ckDefaultToTime("in_10sec", 3, "менее чем через минуту") 239 | self.ckDefaultToTime("in_1min", 3, "через минуту") 240 | self.ckDefaultToTime("in_10min", 3, "через 10 минут") 241 | self.ckDefaultToTime("in_1hr", 3, "через 1 час 2 минуты") 242 | self.ckDefaultToTime("in_10hr", 3, "через 10 часов 10 минут") 243 | self.ckDefaultToTime("in_1day", 3, "через 1 день 0 часов 20 минут") 244 | self.ckDefaultToTime("in_1day1hr", 3, "через 1 день 1 час 10 минут") 245 | self.ckDefaultToTime("in_2day", 3, "через 2 дня 0 часов 40 минут") 246 | 247 | def testDOTWDatetimeType(self): 248 | """ 249 | Unit-tests for testing datetime.datetime as input values 250 | """ 251 | first_time = datetime.datetime.now() 252 | second_time = first_time + datetime.timedelta(0, 1000) 253 | self.assertEqual( 254 | pytils.dt.distance_of_time_in_words( 255 | from_time=first_time, accuracy=1, to_time=second_time 256 | ), 257 | "16 минут назад", 258 | ) 259 | 260 | def testDOTIWExceptions(self): 261 | """ 262 | Unit-tests for testings distance_of_time_in_words' exceptions 263 | """ 264 | self.assertRaises( 265 | ValueError, pytils.dt.distance_of_time_in_words, time.time(), 0 266 | ) 267 | 268 | def testIssue25DaysFixed(self): 269 | """ 270 | Unit-test for testing that Issue#25 is fixed (err when accuracy==1, days<>0, hours==1) 271 | """ 272 | d_days = datetime.datetime.now() - datetime.timedelta(13, 3620) 273 | self.assertEqual(pytils.dt.distance_of_time_in_words(d_days), "13 дней назад") 274 | 275 | def testIssue25HoursFixed(self): 276 | """ 277 | Unit-test for testing that Issue#25 is fixed (err when accuracy==1, hours<>0, minutes==1) 278 | """ 279 | d_hours = datetime.datetime.now() - datetime.timedelta(0, 46865) 280 | self.assertEqual(pytils.dt.distance_of_time_in_words(d_hours), "13 часов назад") 281 | 282 | 283 | class RuStrftimeTestCase(unittest.TestCase): 284 | """ 285 | Test case for pytils.dt.ru_strftime 286 | """ 287 | 288 | def setUp(self): 289 | """ 290 | Setting up environment for tests 291 | """ 292 | self.date = datetime.date(2006, 8, 25) 293 | 294 | def ck(self, format, estimates, date=None): 295 | """ 296 | Checks w/o inflected 297 | """ 298 | if date is None: 299 | date = self.date 300 | res = pytils.dt.ru_strftime(format, date) 301 | self.assertEqual(res, estimates) 302 | 303 | def ckInflected(self, format, estimates, date=None): 304 | """ 305 | Checks with inflected 306 | """ 307 | if date is None: 308 | date = self.date 309 | res = pytils.dt.ru_strftime(format, date, True) 310 | self.assertEqual(res, estimates) 311 | 312 | def ckInflectedDay(self, format, estimates, date=None): 313 | """ 314 | Checks with inflected day 315 | """ 316 | if date is None: 317 | date = self.date 318 | res = pytils.dt.ru_strftime(format, date, inflected_day=True) 319 | self.assertEqual(res, estimates) 320 | 321 | def ckPreposition(self, format, estimates, date=None): 322 | """ 323 | Checks with inflected day 324 | """ 325 | if date is None: 326 | date = self.date 327 | res = pytils.dt.ru_strftime(format, date, preposition=True) 328 | self.assertEqual(res, estimates) 329 | 330 | def testRuStrftime(self): 331 | """ 332 | Unit-tests for pytils.dt.ru_strftime 333 | """ 334 | self.ck("тест %a", "тест пт") 335 | self.ck("тест %A", "тест пятница") 336 | self.ck("тест %b", "тест авг") 337 | self.ck("тест %B", "тест август") 338 | self.ckInflected("тест %B", "тест августа") 339 | self.ckInflected( 340 | "тест выполнен %d %B %Y года", "тест выполнен 25 августа 2006 года" 341 | ) 342 | self.ckInflectedDay("тест выполнен в %A", "тест выполнен в пятницу") 343 | 344 | def testRuStrftimeWithPreposition(self): 345 | """ 346 | Unit-tests for pytils.dt.ru_strftime with preposition option 347 | """ 348 | self.ckPreposition("тест %a", "тест в\xa0пт") 349 | self.ckPreposition("тест %A", "тест в\xa0пятницу") 350 | self.ckPreposition("тест %A", "тест во\xa0вторник", datetime.date(2007, 6, 5)) 351 | 352 | def testRuStrftimeZeros(self): 353 | """ 354 | Unit-test for testing that Issue#24 is correctly implemented 355 | 356 | It means, 1 April 2007, but 01.04.2007 357 | """ 358 | self.ck("%d.%m.%Y", "01.04.2007", datetime.date(2007, 4, 1)) 359 | self.ckInflected("%d %B %Y", "1 апреля 2007", datetime.date(2007, 4, 1)) 360 | 361 | def testIssue20Fixed(self): 362 | """ 363 | Unit-test for testing that Issue#20 is fixed (typo) 364 | """ 365 | self.assertEqual( 366 | "воскресенье", 367 | pytils.dt.ru_strftime("%A", datetime.date(2007, 3, 18), inflected_day=True), 368 | ) 369 | 370 | def test_special_case(self): 371 | self.assertEqual( 372 | "октябрь", 373 | pytils.dt.ru_strftime( 374 | "%B", inflected=False, date=datetime.datetime.fromtimestamp(1540209256) 375 | ), 376 | ) 377 | 378 | 379 | if __name__ == "__main__": 380 | unittest.main() 381 | -------------------------------------------------------------------------------- /pytils/test/test_numeral.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit-tests for pytils.numeral 3 | """ 4 | 5 | import decimal 6 | import unittest 7 | 8 | import pytils 9 | 10 | 11 | class ChoosePluralTestCase(unittest.TestCase): 12 | """ 13 | Test case for pytils.numeral.choose_plural 14 | """ 15 | 16 | def setUp(self): 17 | """ 18 | Setting up environment for tests 19 | """ 20 | self.variants = ("гвоздь", "гвоздя", "гвоздей") 21 | 22 | def checkChoosePlural(self, amount, estimated): 23 | """ 24 | Checks choose_plural 25 | """ 26 | self.assertEqual(pytils.numeral.choose_plural(amount, self.variants), estimated) 27 | 28 | def testChoosePlural(self): 29 | """ 30 | Unit-test for choose_plural 31 | """ 32 | self.checkChoosePlural(1, "гвоздь") 33 | self.checkChoosePlural(2, "гвоздя") 34 | self.checkChoosePlural(3, "гвоздя") 35 | self.checkChoosePlural(5, "гвоздей") 36 | self.checkChoosePlural(11, "гвоздей") 37 | self.checkChoosePlural(109, "гвоздей") 38 | 39 | def testChoosePluralNegativeBug9(self): 40 | """ 41 | Test handling of negative numbers 42 | """ 43 | self.checkChoosePlural(-5, "гвоздей") 44 | self.checkChoosePlural(-2, "гвоздя") 45 | 46 | def testChoosePluralExceptions(self): 47 | """ 48 | Unit-test for testing choos_plural's exceptions 49 | """ 50 | self.assertRaises(ValueError, pytils.numeral.choose_plural, 25, "any,bene") 51 | 52 | def testChoosePluralVariantsInStr(self): 53 | """ 54 | Tests new-style variants 55 | """ 56 | self.assertEqual( 57 | pytils.numeral.choose_plural(1, "гвоздь,гвоздя, гвоздей"), "гвоздь" 58 | ) 59 | self.assertEqual( 60 | pytils.numeral.choose_plural(5, r"гвоздь, гвоздя, гвоздей\, шпунтов"), 61 | "гвоздей, шпунтов", 62 | ) 63 | 64 | 65 | class GetPluralTestCase(unittest.TestCase): 66 | """ 67 | Test case for get_plural 68 | """ 69 | 70 | def testGetPlural(self): 71 | """ 72 | Test regular get_plural 73 | """ 74 | self.assertEqual( 75 | pytils.numeral.get_plural(1, "комментарий, комментария, комментариев"), 76 | "1 комментарий", 77 | ) 78 | self.assertEqual( 79 | pytils.numeral.get_plural(0, "комментарий, комментария, комментариев"), 80 | "0 комментариев", 81 | ) 82 | 83 | def testGetPluralAbsence(self): 84 | """ 85 | Test get_plural with absence 86 | """ 87 | self.assertEqual( 88 | pytils.numeral.get_plural( 89 | 1, "комментарий, комментария, комментариев", "без комментариев" 90 | ), 91 | "1 комментарий", 92 | ) 93 | self.assertEqual( 94 | pytils.numeral.get_plural( 95 | 0, "комментарий, комментария, комментариев", "без комментариев" 96 | ), 97 | "без комментариев", 98 | ) 99 | 100 | def testGetPluralLegacy(self): 101 | """ 102 | Test _get_plural_legacy 103 | """ 104 | self.assertEqual( 105 | pytils.numeral._get_plural_legacy( 106 | 1, "комментарий, комментария, комментариев" 107 | ), 108 | "1 комментарий", 109 | ) 110 | self.assertEqual( 111 | pytils.numeral._get_plural_legacy( 112 | 0, "комментарий, комментария, комментариев" 113 | ), 114 | "0 комментариев", 115 | ) 116 | self.assertEqual( 117 | pytils.numeral._get_plural_legacy( 118 | 1, "комментарий, комментария, комментариев, без комментариев" 119 | ), 120 | "1 комментарий", 121 | ) 122 | self.assertEqual( 123 | pytils.numeral._get_plural_legacy( 124 | 0, "комментарий, комментария, комментариев, без комментариев" 125 | ), 126 | "без комментариев", 127 | ) 128 | 129 | 130 | class GetFloatRemainderTestCase(unittest.TestCase): 131 | """ 132 | Test case for pytils.numeral._get_float_remainder 133 | """ 134 | 135 | def testFloatRemainder(self): 136 | """ 137 | Unit-test for _get_float_remainder 138 | """ 139 | self.assertEqual(pytils.numeral._get_float_remainder(1.3), "3") 140 | self.assertEqual(pytils.numeral._get_float_remainder(2.35, 1), "4") 141 | self.assertEqual( 142 | pytils.numeral._get_float_remainder(123.1234567891), "123456789" 143 | ) 144 | self.assertEqual(pytils.numeral._get_float_remainder(2.353, 2), "35") 145 | self.assertEqual(pytils.numeral._get_float_remainder(0.01), "01") 146 | self.assertEqual(pytils.numeral._get_float_remainder(5), "0") 147 | 148 | def testFloatRemainderDecimal(self): 149 | """ 150 | Unit-test for _get_float_remainder with decimal type 151 | """ 152 | D = decimal.Decimal 153 | self.assertEqual(pytils.numeral._get_float_remainder(D("1.3")), "3") 154 | self.assertEqual(pytils.numeral._get_float_remainder(D("2.35"), 1), "4") 155 | self.assertEqual( 156 | pytils.numeral._get_float_remainder(D("123.1234567891")), "123456789" 157 | ) 158 | self.assertEqual(pytils.numeral._get_float_remainder(D("2.353"), 2), "35") 159 | self.assertEqual(pytils.numeral._get_float_remainder(D("0.01")), "01") 160 | self.assertEqual(pytils.numeral._get_float_remainder(D("5")), "0") 161 | 162 | def testFloatRemainderExceptions(self): 163 | """ 164 | Unit-test for testing _get_float_remainder's exceptions 165 | """ 166 | self.assertRaises(ValueError, pytils.numeral._get_float_remainder, 2.998, 2) 167 | self.assertRaises(ValueError, pytils.numeral._get_float_remainder, -1.23) 168 | 169 | 170 | class RublesTestCase(unittest.TestCase): 171 | """ 172 | Test case for pytils.numeral.rubles 173 | """ 174 | 175 | def testRubles(self): 176 | """ 177 | Unit-test for rubles 178 | """ 179 | self.assertEqual(pytils.numeral.rubles(10.01), "десять рублей одна копейка") 180 | self.assertEqual(pytils.numeral.rubles(10.10), "десять рублей десять копеек") 181 | self.assertEqual(pytils.numeral.rubles(2.353), "два рубля тридцать пять копеек") 182 | self.assertEqual(pytils.numeral.rubles(2.998), "три рубля") 183 | self.assertEqual(pytils.numeral.rubles(3), "три рубля") 184 | self.assertEqual(pytils.numeral.rubles(3, True), "три рубля ноль копеек") 185 | 186 | def testRublesDecimal(self): 187 | """ 188 | Test for rubles with decimal instead of float/integer 189 | """ 190 | D = decimal.Decimal 191 | self.assertEqual( 192 | pytils.numeral.rubles(D("10.01")), "десять рублей одна копейка" 193 | ) 194 | self.assertEqual( 195 | pytils.numeral.rubles(D("10.10")), "десять рублей десять копеек" 196 | ) 197 | self.assertEqual( 198 | pytils.numeral.rubles(D("2.35")), "два рубля тридцать пять копеек" 199 | ) 200 | self.assertEqual(pytils.numeral.rubles(D(3)), "три рубля") 201 | self.assertEqual(pytils.numeral.rubles(D(3), True), "три рубля ноль копеек") 202 | 203 | def testRublesExceptions(self): 204 | """ 205 | Unit-test for testing rubles' exceptions 206 | """ 207 | self.assertRaises(ValueError, pytils.numeral.rubles, -15) 208 | 209 | 210 | class InWordsTestCase(unittest.TestCase): 211 | """ 212 | Test case for pytils.numeral.in_words 213 | """ 214 | 215 | def testInt(self): 216 | """ 217 | Unit-test for in_words_int 218 | """ 219 | self.assertEqual(pytils.numeral.in_words_int(0), "ноль") 220 | self.assertEqual(pytils.numeral.in_words_int(10), "десять") 221 | self.assertEqual(pytils.numeral.in_words_int(5), "пять") 222 | self.assertEqual(pytils.numeral.in_words_int(102), "сто два") 223 | self.assertEqual( 224 | pytils.numeral.in_words_int(3521), "три тысячи пятьсот двадцать один" 225 | ) 226 | self.assertEqual(pytils.numeral.in_words_int(3500), "три тысячи пятьсот") 227 | self.assertEqual( 228 | pytils.numeral.in_words_int(5231000), 229 | "пять миллионов двести тридцать одна тысяча", 230 | ) 231 | 232 | def testIntExceptions(self): 233 | """ 234 | Unit-test for testing in_words_int's exceptions 235 | """ 236 | self.assertRaises(ValueError, pytils.numeral.in_words_int, -3) 237 | 238 | def testFloat(self): 239 | """ 240 | Unit-test for in_words_float 241 | """ 242 | self.assertEqual( 243 | pytils.numeral.in_words_float(10.0), "десять целых ноль десятых" 244 | ) 245 | self.assertEqual( 246 | pytils.numeral.in_words_float(2.25), "две целых двадцать пять сотых" 247 | ) 248 | self.assertEqual(pytils.numeral.in_words_float(0.01), "ноль целых одна сотая") 249 | self.assertEqual(pytils.numeral.in_words_float(0.10), "ноль целых одна десятая") 250 | 251 | def testDecimal(self): 252 | """ 253 | Unit-test for in_words_float with decimal type 254 | """ 255 | D = decimal.Decimal 256 | self.assertEqual( 257 | pytils.numeral.in_words_float(D("10.0")), "десять целых ноль десятых" 258 | ) 259 | self.assertEqual( 260 | pytils.numeral.in_words_float(D("2.25")), "две целых двадцать пять сотых" 261 | ) 262 | self.assertEqual( 263 | pytils.numeral.in_words_float(D("0.01")), "ноль целых одна сотая" 264 | ) 265 | # поскольку это Decimal, то здесь нет незначащих нулей 266 | # т.е. нули определяют точность, поэтому десять сотых, 267 | # а не одна десятая 268 | self.assertEqual( 269 | pytils.numeral.in_words_float(D("0.10")), "ноль целых десять сотых" 270 | ) 271 | 272 | def testFloatExceptions(self): 273 | """ 274 | Unit-test for testing in_words_float's exceptions 275 | """ 276 | self.assertRaises(ValueError, pytils.numeral.in_words_float, -2.3) 277 | 278 | def testWithGenderOldStyle(self): 279 | """ 280 | Unit-test for in_words_float with gender (old-style, i.e. ints) 281 | """ 282 | self.assertEqual(pytils.numeral.in_words(21, 1), "двадцать один") 283 | self.assertEqual(pytils.numeral.in_words(21, 2), "двадцать одна") 284 | self.assertEqual(pytils.numeral.in_words(21, 3), "двадцать одно") 285 | # на дробные пол не должен влиять - всегда в женском роде 286 | self.assertEqual( 287 | pytils.numeral.in_words(21.0, 1), "двадцать одна целая ноль десятых" 288 | ) 289 | self.assertEqual( 290 | pytils.numeral.in_words(21.0, 2), "двадцать одна целая ноль десятых" 291 | ) 292 | self.assertEqual( 293 | pytils.numeral.in_words(21.0, 3), "двадцать одна целая ноль десятых" 294 | ) 295 | 296 | def testWithGender(self): 297 | """ 298 | Unit-test for in_words_float with gender (old-style, i.e. ints) 299 | """ 300 | self.assertEqual( 301 | pytils.numeral.in_words(21, pytils.numeral.MALE), "двадцать один" 302 | ) 303 | self.assertEqual( 304 | pytils.numeral.in_words(21, pytils.numeral.FEMALE), "двадцать одна" 305 | ) 306 | self.assertEqual( 307 | pytils.numeral.in_words(21, pytils.numeral.NEUTER), "двадцать одно" 308 | ) 309 | # на дробные пол не должен влиять - всегда в женском роде 310 | self.assertEqual( 311 | pytils.numeral.in_words(21.0, pytils.numeral.MALE), 312 | "двадцать одна целая ноль десятых", 313 | ) 314 | self.assertEqual( 315 | pytils.numeral.in_words(21.0, pytils.numeral.FEMALE), 316 | "двадцать одна целая ноль десятых", 317 | ) 318 | self.assertEqual( 319 | pytils.numeral.in_words(21.0, pytils.numeral.NEUTER), 320 | "двадцать одна целая ноль десятых", 321 | ) 322 | 323 | def testCommon(self): 324 | """ 325 | Unit-test for general in_words 326 | """ 327 | D = decimal.Decimal 328 | self.assertEqual(pytils.numeral.in_words(10), "десять") 329 | self.assertEqual(pytils.numeral.in_words(5), "пять") 330 | self.assertEqual(pytils.numeral.in_words(102), "сто два") 331 | 332 | self.assertEqual( 333 | pytils.numeral.in_words(3521), "три тысячи пятьсот двадцать один" 334 | ) 335 | self.assertEqual(pytils.numeral.in_words(3500), "три тысячи пятьсот") 336 | self.assertEqual( 337 | pytils.numeral.in_words(5231000), 338 | "пять миллионов двести тридцать одна тысяча", 339 | ) 340 | self.assertEqual(pytils.numeral.in_words(10.0), "десять целых ноль десятых") 341 | self.assertEqual(pytils.numeral.in_words(2.25), "две целых двадцать пять сотых") 342 | self.assertEqual(pytils.numeral.in_words(0.01), "ноль целых одна сотая") 343 | self.assertEqual(pytils.numeral.in_words(0.10), "ноль целых одна десятая") 344 | self.assertEqual( 345 | pytils.numeral.in_words(D("2.25")), "две целых двадцать пять сотых" 346 | ) 347 | self.assertEqual(pytils.numeral.in_words(D("0.01")), "ноль целых одна сотая") 348 | self.assertEqual(pytils.numeral.in_words(D("0.10")), "ноль целых десять сотых") 349 | self.assertEqual(pytils.numeral.in_words(D("10")), "десять") 350 | 351 | def testCommonExceptions(self): 352 | """ 353 | Unit-test for testing in_words' exceptions 354 | """ 355 | self.assertRaises(ValueError, pytils.numeral.in_words, -2) 356 | self.assertRaises(ValueError, pytils.numeral.in_words, -2.5) 357 | 358 | 359 | class SumStringTestCase(unittest.TestCase): 360 | """ 361 | Test case for pytils.numeral.sum_string 362 | """ 363 | 364 | def setUp(self): 365 | """ 366 | Setting up environment for tests 367 | """ 368 | self.variants_male = ("гвоздь", "гвоздя", "гвоздей") 369 | self.variants_female = ("шляпка", "шляпки", "шляпок") 370 | 371 | def ckMaleOldStyle(self, amount, estimated): 372 | """ 373 | Checks sum_string with male gender with old-style genders (i.e. ints) 374 | """ 375 | self.assertEqual( 376 | pytils.numeral.sum_string(amount, 1, self.variants_male), estimated 377 | ) 378 | 379 | def ckMale(self, amount, estimated): 380 | """ 381 | Checks sum_string with male gender 382 | """ 383 | self.assertEqual( 384 | pytils.numeral.sum_string(amount, pytils.numeral.MALE, self.variants_male), 385 | estimated, 386 | ) 387 | 388 | def ckFemaleOldStyle(self, amount, estimated): 389 | """ 390 | Checks sum_string with female gender wuth old-style genders (i.e. ints) 391 | """ 392 | self.assertEqual( 393 | pytils.numeral.sum_string(amount, 2, self.variants_female), estimated 394 | ) 395 | 396 | def ckFemale(self, amount, estimated): 397 | """ 398 | Checks sum_string with female gender 399 | """ 400 | self.assertEqual( 401 | pytils.numeral.sum_string( 402 | amount, pytils.numeral.FEMALE, self.variants_female 403 | ), 404 | estimated, 405 | ) 406 | 407 | def testSumStringOldStyleGender(self): 408 | """ 409 | Unit-test for sum_string with old-style genders 410 | """ 411 | self.ckMaleOldStyle(10, "десять гвоздей") 412 | self.ckMaleOldStyle(2, "два гвоздя") 413 | self.ckMaleOldStyle(31, "тридцать один гвоздь") 414 | self.ckFemaleOldStyle(10, "десять шляпок") 415 | self.ckFemaleOldStyle(2, "две шляпки") 416 | self.ckFemaleOldStyle(31, "тридцать одна шляпка") 417 | 418 | self.assertEqual( 419 | "одиннадцать негритят", 420 | pytils.numeral.sum_string(11, 1, "негритенок,негритенка,негритят"), 421 | ) 422 | 423 | def testSumString(self): 424 | """ 425 | Unit-test for sum_string 426 | """ 427 | self.ckMale(10, "десять гвоздей") 428 | self.ckMale(2, "два гвоздя") 429 | self.ckMale(31, "тридцать один гвоздь") 430 | self.ckFemale(10, "десять шляпок") 431 | self.ckFemale(2, "две шляпки") 432 | self.ckFemale(31, "тридцать одна шляпка") 433 | 434 | self.assertEqual( 435 | "одиннадцать негритят", 436 | pytils.numeral.sum_string( 437 | 11, pytils.numeral.MALE, "негритенок,негритенка,негритят" 438 | ), 439 | ) 440 | 441 | def testSumStringExceptions(self): 442 | """ 443 | Unit-test for testing sum_string's exceptions 444 | """ 445 | self.assertRaises( 446 | ValueError, 447 | pytils.numeral.sum_string, 448 | -1, 449 | pytils.numeral.MALE, 450 | "any,bene,raba", 451 | ) 452 | 453 | 454 | if __name__ == "__main__": 455 | unittest.main() 456 | -------------------------------------------------------------------------------- /pytils/test/test_translit.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit-tests for pytils.translit 3 | """ 4 | 5 | import unittest 6 | 7 | import pytils 8 | 9 | 10 | class TranslitTestCase(unittest.TestCase): 11 | """ 12 | Test case for pytils.translit 13 | """ 14 | 15 | def ckTransl(self, in_, out_): 16 | """ 17 | Checks translify 18 | """ 19 | self.assertEqual(pytils.translit.translify(in_), out_) 20 | 21 | def ckDetransl(self, in_, out_): 22 | """ 23 | Checks detranslify 24 | """ 25 | self.assertEqual(pytils.translit.detranslify(in_), out_) 26 | 27 | def ckSlug(self, in_, out_): 28 | """ 29 | Checks slugify 30 | """ 31 | self.assertEqual(pytils.translit.slugify(in_), out_) 32 | 33 | def testTransliteration(self): 34 | """ 35 | Unit-test for transliterations 36 | """ 37 | self.ckTransl("тест", "test") 38 | self.ckTransl("проверка", "proverka") 39 | self.ckTransl("транслит", "translit") 40 | self.ckTransl("правда ли это", "pravda li eto") 41 | self.ckTransl("Щука", "Schuka") 42 | 43 | def testTransliterationExceptions(self): 44 | """ 45 | Unit-test for testing translify's exceptions 46 | """ 47 | self.assertRaises( 48 | ValueError, pytils.translit.translify, "\u00bfHabla espa\u00f1ol?" 49 | ) 50 | 51 | def testDetransliteration(self): 52 | """ 53 | Unit-test for detransliterations 54 | """ 55 | self.ckDetransl("test", "тест") 56 | self.ckDetransl("proverka", "проверка") 57 | self.ckDetransl("translit", "транслит") 58 | self.ckDetransl("SCHuka", "Щука") 59 | self.ckDetransl("Schuka", "Щука") 60 | 61 | def testSlug(self): 62 | """ 63 | Unit-test for slugs 64 | """ 65 | self.ckSlug("ТеСт", "test") 66 | self.ckSlug("Проверка связи", "proverka-svyazi") 67 | self.ckSlug("me&you", "me-and-you") 68 | self.ckSlug("и еще один тест", "i-esche-odin-test") 69 | 70 | def testTranslifyAdditionalUnicodeSymbols(self): 71 | """ 72 | Unit-test for testing additional unicode symbols 73 | """ 74 | self.ckTransl("«Вот так вот»", '"Vot tak vot"') 75 | self.ckTransl("‘Или вот так’", "'Ili vot tak'") 76 | self.ckTransl("– Да…", "- Da...") 77 | 78 | def testSlugifyIssue10(self): 79 | """ 80 | Unit-test for testing that bug#10 fixed 81 | """ 82 | self.ckSlug("Проверка связи…", "proverka-svyazi") 83 | self.ckSlug("Проверка\x0aсвязи 2", "proverka-svyazi-2") 84 | self.ckSlug("Проверка\201связи 3", "proverkasvyazi-3") 85 | 86 | def testSlugifyIssue15(self): 87 | """ 88 | Unit-test for testing that bug#15 fixed 89 | """ 90 | self.ckSlug("World of Warcraft", "world-of-warcraft") 91 | 92 | def testAdditionalDashesAndQuotes(self): 93 | """ 94 | Unit-test for testing additional dashes (figure and em-dash) 95 | and quotes 96 | """ 97 | self.ckSlug("Юнит-тесты — наше всё", "yunit-testyi---nashe-vsyo") 98 | self.ckSlug("Юнит-тесты ‒ наше всё", "yunit-testyi---nashe-vsyo") 99 | self.ckSlug("95−34", "95-34") 100 | self.ckTransl("Двигатель “Pratt&Whitney”", 'Dvigatel\' "Pratt&Whitney"') 101 | 102 | 103 | if __name__ == "__main__": 104 | unittest.main() 105 | -------------------------------------------------------------------------------- /pytils/test/test_typo.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit-tests for pytils.typo 3 | """ 4 | 5 | import os 6 | import unittest 7 | 8 | from pytils import typo 9 | 10 | 11 | def cb_testrule(x): 12 | return x 13 | 14 | 15 | class HelpersTestCase(unittest.TestCase): 16 | """ 17 | Test case for pytils.typo helpers 18 | """ 19 | 20 | def testGetRuleByName(self): 21 | """ 22 | unit-test for pytils.typo._get_rule_by_name 23 | """ 24 | self.assertTrue(callable(typo._get_rule_by_name("testrule"))) 25 | self.assertEqual("rl_testrule", typo._get_rule_by_name("testrule").__name__) # ty: ignore[unresolved-attribute] 26 | 27 | def testResolveRule(self): 28 | """ 29 | unit-test for pytils.typo._resolve_rule 30 | """ 31 | self.assertTrue(callable(typo._resolve_rule_name("testrule")[1])) 32 | self.assertTrue(callable(typo._resolve_rule_name(cb_testrule)[1])) 33 | self.assertEqual("testrule", typo._resolve_rule_name("testrule")[0]) 34 | self.assertEqual("cb_testrule", typo._resolve_rule_name(cb_testrule)[0]) 35 | 36 | def testResolveRuleWithForcedName(self): 37 | """ 38 | unit-test for pytils.typo._resolve_rule with forced_name arg 39 | """ 40 | self.assertTrue(callable(typo._resolve_rule_name("testrule", "newrule")[1])) 41 | self.assertTrue(callable(typo._resolve_rule_name(cb_testrule, "newrule")[1])) 42 | self.assertEqual("newrule", typo._resolve_rule_name("testrule", "newrule")[0]) 43 | self.assertEqual("newrule", typo._resolve_rule_name(cb_testrule, "newrule")[0]) 44 | 45 | 46 | class TypographyApplierTestCase(unittest.TestCase): 47 | """ 48 | Test case for typography rule applier pytils.typo.Typography 49 | """ 50 | 51 | def testExpandEmptyArgs(self): 52 | self.assertEqual({}, typo.Typography().rules) 53 | self.assertEqual([], typo.Typography().rules_names) 54 | 55 | def testExpandSimpleStrArgs(self): 56 | self.assertEqual( 57 | {"testrule": typo.rl_testrule}, typo.Typography("testrule").rules 58 | ) 59 | self.assertEqual(["testrule"], typo.Typography("testrule").rules_names) 60 | 61 | def testExpandDictStrArgs(self): 62 | self.assertEqual( 63 | {"testrule": typo.rl_testrule, "newrule": typo.rl_testrule}, 64 | typo.Typography("testrule", {"newrule": "testrule"}).rules, 65 | ) 66 | self.assertEqual( 67 | ["testrule", "newrule"], 68 | typo.Typography("testrule", {"newrule": "testrule"}).rules_names, 69 | ) 70 | 71 | def testExpandSimpleCallableArgs(self): 72 | self.assertEqual( 73 | {"cb_testrule": cb_testrule}, typo.Typography(cb_testrule).rules 74 | ) 75 | self.assertEqual(["cb_testrule"], typo.Typography(cb_testrule).rules_names) 76 | 77 | def testExpandDictCallableArgs(self): 78 | self.assertEqual( 79 | {"cb_testrule": cb_testrule, "newrule": cb_testrule}, 80 | typo.Typography(cb_testrule, {"newrule": cb_testrule}).rules, 81 | ) 82 | self.assertEqual( 83 | ["cb_testrule", "newrule"], 84 | typo.Typography(cb_testrule, {"newrule": cb_testrule}).rules_names, 85 | ) 86 | 87 | def testExpandMixedArgs(self): 88 | self.assertEqual( 89 | {"cb_testrule": cb_testrule, "newrule": typo.rl_testrule}, 90 | typo.Typography(cb_testrule, newrule="testrule").rules, 91 | ) 92 | self.assertEqual( 93 | ["cb_testrule", "newrule"], 94 | typo.Typography(cb_testrule, newrule="testrule").rules_names, 95 | ) 96 | self.assertEqual( 97 | {"cb_testrule": cb_testrule, "testrule": typo.rl_testrule}, 98 | typo.Typography(cb_testrule, "testrule").rules, 99 | ) 100 | self.assertEqual( 101 | ["cb_testrule", "testrule"], 102 | typo.Typography(cb_testrule, "testrule").rules_names, 103 | ) 104 | 105 | def testRecommendedArgsStyle(self): 106 | def lambdarule(x): 107 | return x 108 | 109 | self.assertEqual( 110 | { 111 | "cb_testrule": cb_testrule, 112 | "testrule": typo.rl_testrule, 113 | "newrule": lambdarule, 114 | }, 115 | typo.Typography([cb_testrule], ["testrule"], {"newrule": lambdarule}).rules, 116 | ) 117 | self.assertEqual( 118 | ["cb_testrule", "testrule", "newrule"], 119 | typo.Typography( 120 | [cb_testrule], ["testrule"], {"newrule": lambdarule} 121 | ).rules_names, 122 | ) 123 | 124 | 125 | class RulesTestCase(unittest.TestCase): 126 | def checkRule(self, name, input_value, expected_result): 127 | """ 128 | Check how rule is acted on input_value with expected_result 129 | """ 130 | self.assertEqual(expected_result, typo._get_rule_by_name(name)(input_value)) 131 | 132 | def testCleanspaces(self): 133 | """ 134 | Unit-test for cleanspaces rule 135 | """ 136 | self.checkRule( 137 | "cleanspaces", 138 | " Точка ,точка , запятая, вышла рожица кривая . ", 139 | "Точка, точка, запятая, вышла рожица кривая.", 140 | ) 141 | self.checkRule( 142 | "cleanspaces", 143 | " Точка ,точка , %(n)sзапятая,%(n)s вышла рожица кривая . " 144 | % {"n": os.linesep}, 145 | "Точка, точка,{n}запятая,{n}вышла рожица кривая.".format(n=os.linesep), 146 | ) 147 | self.checkRule( 148 | "cleanspaces", 149 | "Газета ( ее принес мальчишка утром ) всё еще лежала на столе.", 150 | "Газета (ее принес мальчишка утром) всё еще лежала на столе.", 151 | ) 152 | self.checkRule( 153 | "cleanspaces", 154 | "Газета, утром принесенная мальчишкой ( это был сосед, подзарабатывающий летом ) , всё еще лежала на столе.", 155 | "Газета, утром принесенная мальчишкой (это был сосед, подзарабатывающий летом), всё еще лежала на столе.", 156 | ) 157 | self.checkRule( 158 | "cleanspaces", 159 | "Что это?!?!", 160 | "Что это?!?!", 161 | ) 162 | 163 | def testEllipsis(self): 164 | """ 165 | Unit-test for ellipsis rule 166 | """ 167 | self.checkRule( 168 | "ellipsis", 169 | "Быть или не быть, вот в чем вопрос...%(n)s%(n)sШекспир" 170 | % {"n": os.linesep}, 171 | "Быть или не быть, вот в чем вопрос…{n}{n}Шекспир".format(n=os.linesep), 172 | ) 173 | self.checkRule( 174 | "ellipsis", "Мдя..... могло быть лучше", "Мдя..... могло быть лучше" 175 | ) 176 | self.checkRule("ellipsis", "...Дааааа", "…Дааааа") 177 | self.checkRule("ellipsis", "... Дааааа", "…Дааааа") 178 | 179 | def testInitials(self): 180 | """ 181 | Unit-test for initials rule 182 | """ 183 | self.checkRule( 184 | "initials", 185 | "Председатель В.И.Иванов выступил на собрании", 186 | "Председатель В.И.\u2009Иванов выступил на собрании", 187 | ) 188 | self.checkRule( 189 | "initials", 190 | "Председатель В.И. Иванов выступил на собрании", 191 | "Председатель В.И.\u2009Иванов выступил на собрании", 192 | ) 193 | self.checkRule( 194 | "initials", 195 | "1. В.И.Иванов{n}2. С.П.Васечкин".format(n=os.linesep), 196 | "1. В.И.\u2009Иванов{n}2. С.П.\u2009Васечкин".format(n=os.linesep), 197 | ) 198 | self.checkRule( 199 | "initials", 200 | "Комиссия в составе директора В.И.Иванова и главного бухгалтера С.П.Васечкина постановила", 201 | "Комиссия в составе директора В.И.\u2009Иванова и главного бухгалтера С.П.\u2009Васечкина постановила", 202 | ) 203 | 204 | def testDashes(self): 205 | """ 206 | Unit-test for dashes rule 207 | """ 208 | self.checkRule( 209 | "dashes", 210 | "- Я пошел домой... - Может останешься? - Нет, ухожу.", 211 | "\u2014 Я пошел домой... \u2014 Может останешься? \u2014 Нет, ухожу.", 212 | ) 213 | self.checkRule( 214 | "dashes", 215 | "-- Я пошел домой... -- Может останешься? -- Нет, ухожу.", 216 | "\u2014 Я пошел домой... \u2014 Может останешься? \u2014 Нет, ухожу.", 217 | ) 218 | self.checkRule( 219 | "dashes", 220 | "-- Я\u202fпошел домой…\u202f-- Может останешься?\u202f-- Нет,\u202fухожу.", 221 | "\u2014 Я\u202fпошел домой…\u202f\u2014 Может останешься?\u202f\u2014 Нет,\u202fухожу.", 222 | ) 223 | self.checkRule( 224 | "dashes", 225 | "Ползать по-пластунски", 226 | "Ползать по-пластунски", 227 | ) 228 | self.checkRule( 229 | "dashes", 230 | "Диапазон: 9-15", 231 | "Диапазон: 9\u201315", 232 | ) 233 | 234 | def testWordglue(self): 235 | """ 236 | Unit-test for wordglue rule 237 | """ 238 | self.checkRule( 239 | "wordglue", 240 | "Вроде бы он согласен", 241 | "Вроде\u202fбы\u202fон\u202fсогласен", 242 | ) 243 | self.checkRule( 244 | "wordglue", 245 | "Он не поверил своим глазам", 246 | "Он\u202fне\u202fповерил своим\u202fглазам", 247 | ) 248 | self.checkRule( 249 | "wordglue", 250 | "Это - великий и ужасный Гудвин", 251 | "Это\u202f- великий и\u202fужасный\u202fГудвин", 252 | ) 253 | self.checkRule( 254 | "wordglue", 255 | "Это \u2014 великий и ужасный Гудвин", 256 | "Это\u202f\u2014 великий и\u202fужасный\u202fГудвин", 257 | ) 258 | self.checkRule( 259 | "wordglue", 260 | "-- Я пошел домой… -- Может останешься? -- Нет, ухожу.", 261 | "-- Я\u202fпошел домой…\u202f-- Может останешься?\u202f-- Нет,\u202fухожу.", 262 | ) 263 | self.checkRule( 264 | "wordglue", 265 | 'увидел в газете (это была "Сермяжная правда" № 45) рубрику Weather Forecast', 266 | 'увидел в\u202fгазете (это\u202fбыла "Сермяжная правда" № 45) рубрику Weather\u202fForecast', 267 | ) 268 | 269 | def testMarks(self): 270 | """ 271 | Unit-test for marks rule 272 | """ 273 | self.checkRule( 274 | "marks", 275 | "Когда В. И. Пупкин увидел в газете рубрику Weather Forecast(r), он не поверил своим глазам \u2014 температуру обещали +-451F.", 276 | "Когда В. И. Пупкин увидел в газете рубрику Weather Forecast®, он не поверил своим глазам \u2014 температуру обещали ±451\u202f°F.", 277 | ) 278 | self.checkRule("marks", "14 Foo", "14 Foo") 279 | self.checkRule("marks", "Coca-cola(tm)", "Coca-cola™") 280 | self.checkRule("marks", "(c) 2008 Юрий Юревич", "©\u202f2008 Юрий Юревич") 281 | self.checkRule("marks", "Microsoft (R) Windows (tm)", "Microsoft® Windows™") 282 | self.checkRule( 283 | "marks", 284 | "Школа-гимназия No 3", 285 | "Школа-гимназия №\u20093", 286 | ) 287 | self.checkRule( 288 | "marks", 289 | "Школа-гимназия No3", 290 | "Школа-гимназия №\u20093", 291 | ) 292 | self.checkRule( 293 | "marks", 294 | "Школа-гимназия №3", 295 | "Школа-гимназия №\u20093", 296 | ) 297 | 298 | def testQuotes(self): 299 | """ 300 | Unit-test for quotes rule 301 | """ 302 | self.checkRule("quotes", 'ООО "МСК "Аско-Забота"', "ООО «МСК «Аско-Забота»") 303 | self.checkRule( 304 | "quotes", 305 | 'ООО\u202f"МСК\u202f"Аско-Забота"', 306 | "ООО\u202f«МСК\u202f«Аско-Забота»", 307 | ) 308 | self.checkRule( 309 | "quotes", "Двигатели 'Pratt&Whitney'", "Двигатели “Pratt&Whitney”" 310 | ) 311 | self.checkRule( 312 | "quotes", 313 | '"Вложенные "кавычки" - бич всех типографик", не правда ли', 314 | "«Вложенные «кавычки» - бич всех типографик», не правда ли", 315 | ) 316 | self.checkRule( 317 | "quotes", 318 | "Двигатели 'Pratt&Whitney' никогда не использовались на самолетах \"Аэрофлота\"", 319 | "Двигатели “Pratt&Whitney” никогда не использовались на самолетах «Аэрофлота»", 320 | ) 321 | 322 | 323 | class TypographyTestCase(unittest.TestCase): 324 | """ 325 | Tests for pytils.typo.typography 326 | """ 327 | 328 | def checkTypo(self, input_value, expected_value): 329 | """ 330 | Helper for checking typo.typography 331 | """ 332 | self.assertEqual(expected_value, typo.typography(input_value)) 333 | 334 | def testPupkin(self): 335 | """ 336 | Unit-test on pupkin-text 337 | """ 338 | self.checkTypo( 339 | """...Когда В. И. Пупкин увидел в газете ( это была "Сермяжная правда" № 45) рубрику Weather Forecast(r), он не поверил своим глазам - температуру обещали +-451F.""", 340 | """…Когда В.И.\u2009Пупкин увидел в\u202fгазете (это\u202fбыла «Сермяжная правда» №\u200945) рубрику Weather Forecast®, он\u202fне\u202fповерил своим глазам\u202f\u2014 температуру обещали ±451\u202f°F.""", 341 | ) 342 | 343 | 344 | if __name__ == "__main__": 345 | unittest.main() 346 | -------------------------------------------------------------------------------- /pytils/test/test_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit-tests for pytils.utils 3 | """ 4 | 5 | import decimal 6 | import unittest 7 | 8 | import pytils 9 | 10 | 11 | class ChecksTestCase(unittest.TestCase): 12 | """ 13 | Test case for check_* utils 14 | """ 15 | 16 | def testCheckLength(self): 17 | """ 18 | Unit-test for pytils.utils.check_length 19 | """ 20 | self.assertEqual(pytils.utils.check_length("var", 3), None) 21 | 22 | self.assertRaises(ValueError, pytils.utils.check_length, "var", 4) 23 | self.assertRaises(ValueError, pytils.utils.check_length, "var", 2) 24 | self.assertRaises(ValueError, pytils.utils.check_length, (1, 2), 3) 25 | 26 | def testCheckPositive(self): 27 | """ 28 | Unit-test for pytils.utils.check_positive 29 | """ 30 | self.assertEqual(pytils.utils.check_positive(0), None) 31 | self.assertEqual(pytils.utils.check_positive(1), None) 32 | self.assertEqual(pytils.utils.check_positive(1, False), None) 33 | self.assertEqual(pytils.utils.check_positive(1, strict=False), None) 34 | self.assertEqual(pytils.utils.check_positive(1, True), None) 35 | self.assertEqual(pytils.utils.check_positive(1, strict=True), None) 36 | self.assertEqual(pytils.utils.check_positive(decimal.Decimal("2.0")), None) 37 | self.assertEqual(pytils.utils.check_positive(2.0), None) 38 | 39 | self.assertRaises(ValueError, pytils.utils.check_positive, -2) 40 | self.assertRaises(ValueError, pytils.utils.check_positive, -2.0) 41 | self.assertRaises( 42 | ValueError, pytils.utils.check_positive, decimal.Decimal("-2.0") 43 | ) 44 | self.assertRaises(ValueError, pytils.utils.check_positive, 0, True) 45 | 46 | 47 | class SplitValuesTestCase(unittest.TestCase): 48 | def testClassicSplit(self): 49 | """ 50 | Unit-test for pytils.utils.split_values, classic split 51 | """ 52 | self.assertEqual( 53 | ("Раз", "Два", "Три"), pytils.utils.split_values("Раз,Два,Три") 54 | ) 55 | self.assertEqual( 56 | ("Раз", "Два", "Три"), pytils.utils.split_values("Раз, Два,Три") 57 | ) 58 | self.assertEqual( 59 | ("Раз", "Два", "Три"), pytils.utils.split_values(" Раз, Два, Три ") 60 | ) 61 | self.assertEqual( 62 | ("Раз", "Два", "Три"), pytils.utils.split_values(" Раз, \nДва,\n Три ") 63 | ) 64 | 65 | def testEscapedSplit(self): 66 | """ 67 | Unit-test for pytils.utils.split_values, split with escaping 68 | """ 69 | self.assertEqual( 70 | ("Раз,Два", "Три,Четыре", "Пять,Шесть"), 71 | pytils.utils.split_values(r"Раз\,Два,Три\,Четыре,Пять\,Шесть"), 72 | ) 73 | self.assertEqual( 74 | ("Раз, Два", "Три", "Четыре"), 75 | pytils.utils.split_values(r"Раз\, Два, Три, Четыре"), 76 | ) 77 | 78 | 79 | if __name__ == "__main__": 80 | unittest.main() 81 | -------------------------------------------------------------------------------- /pytils/translit.py: -------------------------------------------------------------------------------- 1 | # -*- test-case-name: pytils.test.test_translit -*- 2 | """ 3 | Simple transliteration 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import re 9 | 10 | TRANSTABLE = ( 11 | ("'", "'"), 12 | ('"', '"'), 13 | ("‘", "'"), 14 | ("’", "'"), 15 | ("«", '"'), 16 | ("»", '"'), 17 | ("“", '"'), 18 | ("”", '"'), 19 | ("–", "-"), # en dash 20 | ("—", "-"), # em dash 21 | ("‒", "-"), # figure dash 22 | ("−", "-"), # minus 23 | ("…", "..."), 24 | ("№", "#"), 25 | ## upper 26 | # three-symbols replacements 27 | ("Щ", "Sch"), 28 | # on russian->english translation only first replacement will be done 29 | # i.e. Sch 30 | # but on english->russian translation both variants (Sch and SCH) will play 31 | ("Щ", "SCH"), 32 | # two-symbol replacements 33 | ("Ё", "Yo"), 34 | ("Ё", "YO"), 35 | ("Ж", "Zh"), 36 | ("Ж", "ZH"), 37 | ("Ц", "Ts"), 38 | ("Ц", "TS"), 39 | ("Ч", "Ch"), 40 | ("Ч", "CH"), 41 | ("Ш", "Sh"), 42 | ("Ш", "SH"), 43 | ("Ы", "Yi"), 44 | ("Ы", "YI"), 45 | ("Ю", "YU"), 46 | ("Ю", "Yu"), 47 | ("Я", "Ya"), 48 | ("Я", "YA"), 49 | # one-symbol replacements 50 | ("А", "A"), 51 | ("Б", "B"), 52 | ("В", "V"), 53 | ("Г", "G"), 54 | ("Д", "D"), 55 | ("Е", "E"), 56 | ("З", "Z"), 57 | ("И", "I"), 58 | ("Й", "J"), 59 | ("К", "K"), 60 | ("Л", "L"), 61 | ("М", "M"), 62 | ("Н", "N"), 63 | ("О", "O"), 64 | ("П", "P"), 65 | ("Р", "R"), 66 | ("С", "S"), 67 | ("Т", "T"), 68 | ("У", "U"), 69 | ("Ф", "F"), 70 | ("Х", "H"), 71 | ("Э", "E"), 72 | ("Ъ", "`"), 73 | ("Ь", "'"), 74 | ## lower 75 | # three-symbols replacements 76 | ("щ", "sch"), 77 | # two-symbols replacements 78 | ("ё", "yo"), 79 | ("ж", "zh"), 80 | ("ц", "ts"), 81 | ("ч", "ch"), 82 | ("ш", "sh"), 83 | ("ы", "yi"), 84 | ("ю", "yu"), 85 | ("я", "ya"), 86 | # one-symbol replacements 87 | ("а", "a"), 88 | ("б", "b"), 89 | ("в", "v"), 90 | ("г", "g"), 91 | ("д", "d"), 92 | ("е", "e"), 93 | ("з", "z"), 94 | ("и", "i"), 95 | ("й", "j"), 96 | ("к", "k"), 97 | ("л", "l"), 98 | ("м", "m"), 99 | ("н", "n"), 100 | ("о", "o"), 101 | ("п", "p"), 102 | ("р", "r"), 103 | ("с", "s"), 104 | ("т", "t"), 105 | ("у", "u"), 106 | ("ф", "f"), 107 | ("х", "h"), 108 | ("э", "e"), 109 | ("ъ", "`"), 110 | ("ь", "'"), 111 | # Make english alphabet full: append english-english pairs 112 | # for symbols which is not used in russian-english 113 | # translations. Used in slugify. 114 | ("c", "c"), 115 | ("q", "q"), 116 | ("y", "y"), 117 | ("x", "x"), 118 | ("w", "w"), 119 | ("1", "1"), 120 | ("2", "2"), 121 | ("3", "3"), 122 | ("4", "4"), 123 | ("5", "5"), 124 | ("6", "6"), 125 | ("7", "7"), 126 | ("8", "8"), 127 | ("9", "9"), 128 | ("0", "0"), 129 | ) #: Translation table 130 | 131 | RU_ALPHABET = [x[0] for x in TRANSTABLE] #: Russian alphabet that we can translate 132 | EN_ALPHABET = [ 133 | x[1] for x in TRANSTABLE 134 | ] #: English alphabet that we can detransliterate 135 | ALPHABET = RU_ALPHABET + EN_ALPHABET #: Alphabet that we can (de)transliterate 136 | 137 | 138 | def translify(in_string: str, strict: bool = True) -> str: 139 | """ 140 | Translify russian text 141 | 142 | @param in_string: input string 143 | @type in_string: C{str} 144 | 145 | @param strict: raise error if transliteration is incomplete. 146 | (True by default) 147 | @type strict: C{bool} 148 | 149 | @return: transliterated string 150 | @rtype: C{str} 151 | 152 | @raise ValueError: when string doesn't transliterate completely. 153 | Raised only if strict=True 154 | """ 155 | translit = in_string 156 | for symb_in, symb_out in TRANSTABLE: 157 | translit = translit.replace(symb_in, symb_out) 158 | 159 | if strict and any(ord(symb) > 128 for symb in translit): 160 | raise ValueError( 161 | "Unicode string doesn't transliterate completely, " + "is it russian?" 162 | ) 163 | 164 | return translit 165 | 166 | 167 | def detranslify(in_string: str) -> str: 168 | """ 169 | Detranslify 170 | 171 | @param in_string: input string 172 | @type in_string: C{basestring} 173 | 174 | @return: detransliterated string 175 | @rtype: C{str} 176 | 177 | @raise ValueError: if in_string is C{str}, but it isn't ascii 178 | """ 179 | try: 180 | russian = str(in_string) 181 | except UnicodeDecodeError: 182 | raise ValueError( 183 | "We expects if in_string is 8-bit string," 184 | + "then it consists only ASCII chars, but now it doesn't. " 185 | + "Use unicode in this case." 186 | ) 187 | 188 | for symb_out, symb_in in TRANSTABLE: 189 | russian = russian.replace(symb_in, symb_out) 190 | 191 | # TODO: выбрать правильный регистр для ь и ъ 192 | # твердый и мягкий знак в dentranslify всегда будут в верхнем регистре 193 | # потому что ` и ' не несут информацию о регистре 194 | return russian 195 | 196 | 197 | def slugify(in_string: str) -> str: 198 | """ 199 | Prepare string for slug (i.e. URL or file/dir name) 200 | 201 | @param in_string: input string 202 | @type in_string: C{basestring} 203 | 204 | @return: slug-string 205 | @rtype: C{str} 206 | 207 | @raise ValueError: if in_string is C{str}, but it isn't ascii 208 | """ 209 | try: 210 | u_in_string = str(in_string).lower() 211 | except UnicodeDecodeError: 212 | raise ValueError( 213 | "We expects when in_string is str type," 214 | + "it is an ascii, but now it isn't. Use unicode " 215 | + "in this case." 216 | ) 217 | # convert & to "and" 218 | u_in_string = re.sub(r"\&\;|\&", " and ", u_in_string) 219 | # replace spaces by hyphen 220 | u_in_string = re.sub(r"[-\s]+", "-", u_in_string) 221 | # remove symbols that not in alphabet 222 | u_in_string = "".join([symb for symb in u_in_string if symb in ALPHABET]) 223 | # translify it 224 | out_string = translify(u_in_string) 225 | # remove non-alpha 226 | return re.sub(r"[^\w\s-]", "", out_string).strip().lower() 227 | 228 | 229 | def dirify(in_string: str) -> None: 230 | """ 231 | Alias for L{slugify} 232 | """ 233 | slugify(in_string) 234 | -------------------------------------------------------------------------------- /pytils/typo.py: -------------------------------------------------------------------------------- 1 | # -*- test-case-name: pytils.test.test_typo -*- 2 | """ 3 | Russian typography 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import os 9 | import re 10 | from typing import Callable 11 | from collections.abc import Sequence 12 | 13 | 14 | def _sub_patterns(patterns: Sequence[tuple[str | re.Pattern, str]], text: str) -> str: 15 | """ 16 | Apply re.sub to bunch of (pattern, repl) 17 | """ 18 | for pattern, repl in patterns: 19 | text = re.sub(pattern, repl, text) 20 | return text 21 | 22 | 23 | # ---------- rules ------------- 24 | # rules is a regular function, 25 | # name convention is rl_RULENAME 26 | def rl_testrule(x: str) -> str: 27 | """ 28 | Rule for tests. Do nothing. 29 | """ 30 | return x 31 | 32 | 33 | def rl_cleanspaces(x: str) -> str: 34 | """ 35 | Clean double spaces, trailing spaces, heading spaces, 36 | spaces before punctuations 37 | """ 38 | patterns = ( 39 | # arguments for re.sub: pattern and repl 40 | # удаляем пробел перед знаками препинания 41 | (r" +([\.,?!\)]+)", r"\1"), 42 | # добавляем пробел после знака препинания, если только за ним нет другого 43 | (r"([\.,?!\)]+)([^\.!,?\)]+)", r"\1 \2"), 44 | # убираем пробел после открывающей скобки 45 | (r"(\S+)\s*(\()\s*(\S+)", r"\1 (\3"), 46 | ) 47 | # удаляем двойные, начальные и конечные пробелы 48 | return os.linesep.join( 49 | " ".join(part for part in line.split(" ") if part) 50 | for line in _sub_patterns(patterns, x).split(os.linesep) 51 | ) 52 | 53 | 54 | def rl_ellipsis(x: str) -> str: 55 | """ 56 | Replace three dots to ellipsis 57 | """ 58 | 59 | patterns = ( 60 | # если больше трех точек, то не заменяем на троеточие 61 | # чтобы не было глупых .....->….. 62 | (r"([^\.]|^)\.\.\.([^\.]|$)", "\\1\u2026\\2"), 63 | # если троеточие в начале строки или возле кавычки -- 64 | # это цитата, пробел между троеточием и первым 65 | # словом нужно убрать 66 | ( 67 | re.compile('(^|\\"|\u201c|\xab)\\s*\u2026\\s*([А-Яа-яA-Za-z])', re.UNICODE), 68 | "\\1\u2026\\2", 69 | ), 70 | ) 71 | return _sub_patterns(patterns, x) 72 | 73 | 74 | def rl_initials(x: str) -> str: 75 | """ 76 | Replace space between initials and surname by thin space 77 | """ 78 | return re.sub( 79 | re.compile("([А-Я])\\.\\s*([А-Я])\\.\\s*([А-Я][а-я]+)", re.UNICODE), 80 | "\\1.\\2.\u2009\\3", 81 | x, 82 | ) 83 | 84 | 85 | def rl_dashes(x: str) -> str: 86 | """ 87 | Replace dash to long/medium dashes 88 | """ 89 | patterns = ( 90 | # тире 91 | ( 92 | re.compile( 93 | "(^|(.\\s))\\-\\-?(([\\s\u202f].)|$)", re.MULTILINE | re.UNICODE 94 | ), 95 | "\\1\u2014\\3", 96 | ), 97 | # диапазоны между цифрами - en dash 98 | ( 99 | re.compile(r"(\d[\s\u2009]*)\-([\s\u2009]*\d)", re.MULTILINE | re.UNICODE), 100 | "\\1\u2013\\2", 101 | ), 102 | # TODO: а что с минусом? 103 | ) 104 | return _sub_patterns(patterns, x) 105 | 106 | 107 | def rl_wordglue(x: str) -> str: 108 | """ 109 | Glue (set nonbreakable space) short words with word before/after 110 | """ 111 | patterns = ( 112 | # частицы склеиваем с предыдущим словом 113 | ( 114 | re.compile("(\\s+)(же|ли|ль|бы|б|ж|ка)([\\.,!\\?:;]?\\s+)", re.UNICODE), 115 | "\u202f\\2\\3", 116 | ), 117 | # склеиваем короткие слова со следующим словом 118 | (re.compile("\\b([a-zA-ZА-Яа-я]{1,3})(\\s+)", re.UNICODE), "\\1\u202f"), 119 | # склеиваем тире с предыдущим словом 120 | (re.compile("(\\s+)([\u2014\\-]+)(\\s+)", re.UNICODE), "\u202f\\2\\3"), 121 | # склеиваем два последних слова в абзаце между собой 122 | # полагается, что абзацы будут передаваться отдельной строкой 123 | (re.compile("([^\\s]+)\\s+([^\\s]+)$", re.UNICODE), "\\1\u202f\\2"), 124 | ) 125 | return _sub_patterns(patterns, x) 126 | 127 | 128 | def rl_marks(x: str) -> str: 129 | """ 130 | Replace +-, (c), (tm), (r), (p), etc by its typographic eqivalents 131 | """ 132 | # простые замены, можно без регулярок 133 | replacements = ( 134 | ("(r)", "\u00ae"), # ® 135 | ("(R)", "\u00ae"), # ® 136 | ("(p)", "\u00a7"), # § 137 | ("(P)", "\u00a7"), # § 138 | ("(tm)", "\u2122"), # ™ 139 | ("(TM)", "\u2122"), # ™ 140 | ) 141 | patterns = ( 142 | # копирайт ставится до года: © 2008 Юрий Юревич 143 | (re.compile("\\([cCсС]\\)\\s*(\\d+)", re.UNICODE), "\u00a9\u202f\\1"), 144 | (r"([^+])(\+\-|\-\+)", "\\1\u00b1"), # ± 145 | # градусы с минусом 146 | ("\\-(\\d+)[\\s]*([FCС][^\\w])", "\u2212\\1\202f\u00b0\\2"), # −12 °C, −53 °F 147 | # градусы без минуса 148 | ("(\\d+)[\\s]*([FCС][^\\w])", "\\1\u202f\u00b0\\2"), # 12 °C, 53 °F 149 | # ® и ™ приклеиваются к предыдущему слову, без пробела 150 | (re.compile("([A-Za-zА-Яа-я\\!\\?])\\s*(\xae|\u2122)", re.UNICODE), "\\1\\2"), 151 | # No5 -> № 5 152 | ( 153 | re.compile("(\\s)(No|no|NO|\u2116)[\\s\u2009]*(\\d+)", re.UNICODE), 154 | "\\1\u2116\u2009\\3", 155 | ), 156 | ) 157 | 158 | for what, to in replacements: 159 | x = x.replace(what, to) 160 | return _sub_patterns(patterns, x) 161 | 162 | 163 | def rl_quotes(x: str) -> str: 164 | """ 165 | Replace quotes by typographic quotes 166 | """ 167 | 168 | patterns = ( 169 | # открывающие кавычки ставятся обычно вплотную к слову слева 170 | # а закрывающие -- вплотную справа 171 | # открывающие русские кавычки-ёлочки 172 | (re.compile(r'((?:^|\s))(")', re.UNICODE), "\\1\xab"), 173 | # закрывающие русские кавычки-ёлочки 174 | (re.compile(r'(\S)(")', re.UNICODE), "\\1\xbb"), 175 | # открывающие кавычки-лапки, вместо одинарных кавычек 176 | (re.compile(r"((?:^|\s))(\')", re.UNICODE), "\\1\u201c"), 177 | # закрывающие кавычки-лапки 178 | (re.compile(r"(\S)(\')", re.UNICODE), "\\1\u201d"), 179 | ) 180 | return _sub_patterns(patterns, x) 181 | 182 | 183 | # -------- rules end ---------- 184 | STANDARD_RULES = ( 185 | "cleanspaces", 186 | "ellipsis", 187 | "initials", 188 | "marks", 189 | "dashes", 190 | "wordglue", 191 | "quotes", 192 | ) 193 | 194 | 195 | def _get_rule_by_name(name: str) -> Callable[[str], str]: 196 | rule = globals().get("rl_%s" % name) 197 | if rule is None: 198 | raise ValueError("Rule %s is not found" % name) 199 | if not callable(rule): 200 | raise ValueError("Rule with name %s is not callable" % name) 201 | return rule 202 | 203 | 204 | def _resolve_rule_name( 205 | rule_or_name: str | Callable[[str], str], forced_name: str | None = None 206 | ) -> tuple[str, Callable[[str], str]]: 207 | if isinstance(rule_or_name, str): 208 | # got name 209 | name = rule_or_name 210 | rule = _get_rule_by_name(name) 211 | elif callable(rule_or_name): 212 | # got rule 213 | name = rule_or_name.__name__ # ty: ignore[unresolved-attribute] 214 | if name.startswith("rl_"): 215 | # by rule name convention 216 | # rule is a function with name rl_RULENAME 217 | name = name[3:] 218 | rule = rule_or_name 219 | else: 220 | raise ValueError("Cannot resolve %r: neither rule, nor name" % rule_or_name) 221 | if forced_name is not None: 222 | name = forced_name 223 | return name, rule 224 | 225 | 226 | class Typography: 227 | """ 228 | Russian typography rules applier 229 | """ 230 | 231 | def __init__( 232 | self, 233 | *args: str 234 | | Callable[[str], str] 235 | | list[str | Callable[[str], str]] 236 | | dict[str, str | Callable[[str], str]] 237 | | tuple[str | Callable[[str], str], ...], 238 | **kwargs: str | Callable[[str], str], 239 | ) -> None: 240 | """ 241 | Typography applier constructor: 242 | 243 | possible variations of constructing rules chain: 244 | rules by it's names: 245 | Typography('first_rule', 'second_rule') 246 | rules callables as is: 247 | Typography(cb_first_rule, cb_second_rule) 248 | mixed: 249 | Typography('first_rule', cb_second_rule) 250 | as list: 251 | Typography(['first_rule', cb_second_rule]) 252 | as keyword args: 253 | Typography(rule_name='first_rule', 254 | another_rule=cb_second_rule) 255 | as dict (order of rule execution is not the same): 256 | Typography({'rule name': 'first_rule', 257 | 'another_rule': cb_second_rule}) 258 | 259 | For standard rules it is recommended to use list of rules 260 | names. 261 | Typography(['first_rule', 'second_rule']) 262 | 263 | For custom rules which are named functions, 264 | it is recommended to use list of callables: 265 | Typography([cb_first_rule, cb_second_rule]) 266 | 267 | For custom rules which are lambda-functions, 268 | it is recommended to use dict: 269 | Typography({'rule_name': lambda x: x}) 270 | 271 | I.e. the recommended usage is: 272 | Typography(['standard_rule_1', 'standard_rule_2'], 273 | [cb_custom_rule1, cb_custom_rule_2], 274 | {'custom_lambda_rule': lambda x: x}) 275 | """ 276 | self.rules = {} 277 | self.rules_names = [] 278 | # first of all, expand args-lists and args-dicts 279 | expanded_args = [] 280 | expanded_kwargs = {} 281 | for arg in args: 282 | if isinstance(arg, (tuple, list)): 283 | expanded_args += list(arg) 284 | elif isinstance(arg, dict): 285 | expanded_kwargs.update(arg) 286 | elif isinstance(arg, str) or callable(arg): 287 | expanded_args.append(arg) 288 | else: 289 | raise TypeError( 290 | "Cannot expand arg %r, must be tuple, list," 291 | " dict, str or callable, not %s" % (arg, type(arg).__name__) 292 | ) 293 | for kw, arg in kwargs.items(): 294 | if isinstance(arg, str) or callable(arg): 295 | expanded_kwargs[kw] = arg 296 | else: 297 | raise TypeError( 298 | "Cannot expand kwarg %r, must be str or " 299 | "callable, not %s" % (arg, type(arg).__name__) 300 | ) 301 | # next, resolve rule names to callables 302 | for name, rule in (_resolve_rule_name(a) for a in expanded_args): 303 | self.rules[name] = rule 304 | self.rules_names.append(name) 305 | for name, rule in ( 306 | _resolve_rule_name(a, k) for k, a in expanded_kwargs.items() 307 | ): 308 | self.rules[name] = rule 309 | self.rules_names.append(name) 310 | 311 | def apply_single_rule(self, rulename, text): 312 | if rulename not in self.rules: 313 | raise ValueError("Rule %s is not found in active rules" % rulename) 314 | try: 315 | res = self.rules[rulename](text) 316 | except ValueError as e: 317 | raise ValueError("Rule {} failed to apply: {}".format(rulename, e)) 318 | return res 319 | 320 | def apply(self, text): 321 | for rule in self.rules_names: 322 | text = self.apply_single_rule(rule, text) 323 | return text 324 | 325 | def __call__(self, text): 326 | return self.apply(text) 327 | 328 | 329 | def typography(text: str) -> str: 330 | t = Typography(STANDARD_RULES) 331 | return t.apply(text) 332 | -------------------------------------------------------------------------------- /pytils/utils.py: -------------------------------------------------------------------------------- 1 | # -*- test-case-name: pytils.test.test_utils -*- 2 | """ 3 | Misc utils for internal use 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from decimal import Decimal 9 | 10 | 11 | def check_length(value: str | tuple[str, ...], length: int) -> None: 12 | """ 13 | Checks length of value 14 | 15 | @param value: value to check 16 | @type value: C{str} or C{tuple} 17 | 18 | @param length: length checking for 19 | @type length: C{int} 20 | 21 | @return: None when check successful 22 | 23 | @raise ValueError: check failed 24 | """ 25 | _length = len(value) 26 | if _length != length: 27 | raise ValueError("length must be %d, not %d" % (length, _length)) 28 | 29 | 30 | def check_positive(value: int | float | Decimal, strict: bool = False) -> None: 31 | """ 32 | Checks if variable is positive 33 | 34 | @param value: value to check 35 | @type value: C{integer types}, C{float} or C{Decimal} 36 | 37 | @return: None when check successful 38 | 39 | @raise ValueError: check failed 40 | """ 41 | if not strict and value < 0: 42 | raise ValueError("Value must be positive or zero, not %s" % str(value)) 43 | if strict and value <= 0: 44 | raise ValueError("Value must be positive, not %s" % str(value)) 45 | 46 | 47 | def split_values(ustring: str, sep: str = ",") -> tuple[str, ...]: 48 | """ 49 | Splits unicode string with separator C{sep}, 50 | but skips escaped separator. 51 | 52 | @param ustring: string to split 53 | @type ustring: C{str} 54 | 55 | @param sep: separator (default to ',') 56 | @type sep: C{str} 57 | 58 | @return: tuple of splitted elements 59 | """ 60 | assert isinstance(ustring, str), "uvalue must be str, not %s" % type(ustring) 61 | # unicode have special mark symbol 0xffff which cannot be used in a regular text, 62 | # so we use it to mark a place where escaped column was 63 | ustring_marked = ustring.replace(r"\,", "\uffff") 64 | items = tuple([i.strip().replace("\uffff", ",") for i in ustring_marked.split(sep)]) 65 | return items 66 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | version = "0.4.4" 5 | 6 | setup( 7 | name="pytils", 8 | version=version, 9 | author="Yury Yurevich", 10 | author_email="yyurevich@jellycrystal.com", 11 | url="https://github.com/last-partizan/pytils/", 12 | description="Russian-specific string utils", 13 | long_description=open("README.md").read(), 14 | long_description_content_type="text/markdown", 15 | packages=[ 16 | "pytils", 17 | "pytils.templatetags", 18 | ], 19 | license="MIT", 20 | platforms="All", 21 | classifiers=[ 22 | "Topic :: Software Development :: Libraries :: Python Modules", 23 | "Topic :: Text Processing :: Linguistic", 24 | "License :: OSI Approved :: MIT License", 25 | "Operating System :: OS Independent", 26 | "Programming Language :: Python", 27 | "Programming Language :: Python :: 3", 28 | "Programming Language :: Python :: 3.9", 29 | "Programming Language :: Python :: 3.10", 30 | "Programming Language :: Python :: 3.11", 31 | "Programming Language :: Python :: 3.12", 32 | "Programming Language :: Python :: 3.13", 33 | "Natural Language :: Russian", 34 | "Development Status :: 4 - Beta", 35 | "Intended Audience :: Developers", 36 | ], 37 | zip_safe=True, 38 | include_package_data=True, 39 | ) 40 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{39}-django4,py{310,311,312,313}-django5 3 | skipsdist = true 4 | 5 | [gh-actions] 6 | python = 7 | 3.9: py39 8 | 3.10: py310 9 | 3.11: py311 10 | 3.12: py312 11 | 3.13: py313 12 | 13 | [testenv] 14 | setenv= 15 | PYTHONPATH={toxinidir}:{toxinidir}/doc/examples-django 16 | allowlist_externals = pytest 17 | commands= 18 | pytest 19 | 20 | basepython = 21 | py39: python3.9 22 | py310: python3.10 23 | py311: python3.11 24 | py312: python3.12 25 | py313: python3.13 26 | deps = 27 | django4: Django>=4.2,<5 28 | django5: Django>=5,<6 29 | -------------------------------------------------------------------------------- /ty.toml: -------------------------------------------------------------------------------- 1 | [rules] 2 | unused-ignore-comment = "error" 3 | --------------------------------------------------------------------------------