├── .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 |
17 | {% block content %}
18 |
Для того, чтобы воспользоваться pytils в Django, нужно:
19 |
20 | - Установить pytils (как это сделать написано в INSTALL, в архиве с pytils)
21 | - Добавить 'pytils' в INSTALLED_APPS
22 | - В шаблоне загрузить соответствующий компонент pytils (подробности см. в
23 | примерах к компонентам)
24 | - Вставить в нужном месте искомый тег/фильтр.
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 |
--------------------------------------------------------------------------------