├── .github └── workflows │ ├── ci.yml │ ├── dev.yml │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── formatter.sh ├── manage.py ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── tea.yaml └── uuslug ├── __init__.py ├── __version__.py ├── apps.py ├── models.py ├── tests ├── __init__.py ├── tests.py └── testsettings.py └── uuslug.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: (CI) 2 | 3 | on: 4 | push: 5 | branches: 6 | - ci 7 | 8 | jobs: 9 | build: 10 | name: Python ${{ matrix.python-version }}, django ${{ matrix.django-version }} 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: [3.6, 3.7, 3.8, 3.9, "3.10", pypy3] 15 | django-version: [2.2, 3.0, 3.1, 3.2, 4.0] 16 | exclude: 17 | # excludes list 18 | - python-version: 3.6 19 | django-version: 4.0 20 | - python-version: 3.7 21 | django-version: 4.0 22 | - python-version: pypy3 23 | django-version: 4.0 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: setup python 28 | uses: actions/setup-python@v2 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install -e . 35 | pip install coveralls --upgrade 36 | pip install "django~=${{ matrix.django-version }}.0" 37 | - name: Run flake8 38 | run: | 39 | pip install flake8 --upgrade 40 | flake8 --exclude=migrations,tests --ignore=E501,E241,E225,E128 . 41 | - name: Run pycodestyle 42 | run: | 43 | pip install pycodestyle --upgrade 44 | pycodestyle --exclude=migrations,tests --ignore=E501,E241,E225,E128 . 45 | - name: Run test 46 | run: | 47 | coverage run --source=uuslug manage.py test 48 | - name: Coveralls 49 | run: coveralls --service=github 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | -------------------------------------------------------------------------------- /.github/workflows/dev.yml: -------------------------------------------------------------------------------- 1 | name: DEV 2 | 3 | # Run on push only for dev/sandbox 4 | # Otherwise it may trigger concurrently `push & pull_request` on PRs. 5 | on: 6 | push: 7 | branches: 8 | - sandbox 9 | - dev 10 | 11 | jobs: 12 | build: 13 | name: Python ${{ matrix.python-version }}, django ${{ matrix.django-version }} 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: [3.6, 3.7, 3.8, 3.9, "3.10", pypy3] 18 | django-version: [2.2, 3.0, 3.1, 3.2, 4.0] 19 | exclude: 20 | # excludes list 21 | - python-version: 3.6 22 | django-version: 4.0 23 | - python-version: 3.7 24 | django-version: 4.0 25 | - python-version: pypy3 26 | django-version: 4.0 27 | 28 | steps: 29 | - uses: actions/checkout@v2 30 | - name: setup python 31 | uses: actions/setup-python@v2 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | pip install -e . 38 | pip install coveralls --upgrade 39 | pip install "django~=${{ matrix.django-version }}.0" 40 | - name: Run flake8 41 | run: | 42 | pip install flake8 --upgrade 43 | flake8 --exclude=migrations,tests --ignore=E501,E241,E225,E128 . 44 | - name: Run pycodestyle 45 | run: | 46 | pip install pycodestyle --upgrade 47 | pycodestyle --exclude=migrations,tests --ignore=E501,E241,E225,E128 . 48 | - name: Run test 49 | run: | 50 | coverage run --source=uuslug manage.py test 51 | - name: Coveralls 52 | run: coveralls --service=github 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: MAIN 2 | 3 | # Run on push only for main 4 | # Otherwise it may trigger concurrently `push & pull_request` on PRs. 5 | on: 6 | push: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | build: 12 | name: Python ${{ matrix.python-version }}, django ${{ matrix.django-version }} 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: [3.6, 3.7, 3.8, 3.9, "3.10", pypy3] 17 | django-version: [2.2, 3.0, 3.1, 3.2, 4.0] 18 | exclude: 19 | # excludes list 20 | - python-version: 3.6 21 | django-version: 4.0 22 | - python-version: 3.7 23 | django-version: 4.0 24 | - python-version: pypy3 25 | django-version: 4.0 26 | steps: 27 | - uses: actions/checkout@v2 28 | - name: setup python 29 | uses: actions/setup-python@v2 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | - name: Install dependencies 33 | run: | 34 | python -m pip install --upgrade pip 35 | pip install -e . 36 | pip install coveralls --upgrade 37 | pip install "django~=${{ matrix.django-version }}.0" 38 | - name: Run flake8 39 | run: | 40 | pip install flake8 --upgrade 41 | flake8 --exclude=migrations,tests --ignore=E501,E241,E225,E128 . 42 | - name: Run pycodestyle 43 | run: | 44 | pip install pycodestyle --upgrade 45 | pycodestyle --exclude=migrations,tests --ignore=E501,E241,E225,E128 . 46 | - name: Run test 47 | run: | 48 | coverage run --source=uuslug manage.py test 49 | - name: Coveralls 50 | run: coveralls --service=github 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | 59 | # VSCode 60 | /.vscode/ 61 | 62 | .env/ 63 | 64 | # Pipenv 65 | Pipfile 66 | Pipfile.lock 67 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.0.0 2 | 3 | Maintenance: 4 | 5 | - Up version python-slugify, remove support for old Django versions 6 | - Python 2.7 support dropped 7 | 8 | ## 1.2.0 9 | 10 | Maintenance: 11 | 12 | - Up version python-slugify, remove support for old Django versions 13 | 14 | ## 1.1.8 15 | 16 | Maintenance: 17 | 18 | - Up version python-slugify 19 | 20 | ## 1.1.7 21 | 22 | Enhancement: 23 | 24 | - Fix: False alarm on model/instance check. (@titusz) 25 | 26 | ## 1.1.6 27 | 28 | Maintenance: 29 | 30 | - Up version python-slugify, fixed broken status links on github 31 | 32 | ## 1.1.5 33 | 34 | Enhancement: 35 | 36 | - Ability to remove `stopwords` from string 37 | - Ability to auto truncate string to match model field's max_length 38 | 39 | ## 1.0.5 40 | 41 | Fix: 42 | 43 | - PyPI Cleanup 44 | 45 | ## 1.0.4 46 | 47 | Enhancement: 48 | 49 | - Added option to save word order 50 | - Added more tests 51 | - Added Django 1.8 and dropped unsupported Django 52 | 53 | ## 1.0.3 54 | 55 | Enhancement: 56 | 57 | - Support pypy 58 | - Support Django 1.7 59 | - More test cases 60 | - Update slugify 61 | 62 | ## 1.0.2 63 | 64 | Enhancement: 65 | 66 | - PEP8 Compliant 67 | 68 | ## 1.0.1 69 | 70 | Enhancement: 71 | 72 | - Added support for Django 1.6.1 73 | - Added support for Python 3.x 74 | 75 | Misc: 76 | 77 | - Dropped support for Django<=1.4.10 78 | - Dropped support for Python 2.6 79 | 80 | ## 1.0.0 81 | 82 | Enhancement: 83 | 84 | - Support Django>=1.6 85 | 86 | ## 0.2.0 87 | 88 | Features: 89 | 90 | - Added an option to choose a non-dash separator 91 | 92 | ## 0.1.0 93 | 94 | Features: 95 | 96 | - Added the ability to truncate slugs 97 | 98 | ## 0.0.9 99 | 100 | Enhancement: 101 | 102 | -- Removed buildout dependency 103 | -- Move Unicode Slug functionality into its own python-slugify module 104 | 105 | ## 0.0.1 106 | 107 | Features: 108 | 109 | - Initial Release 110 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) Val Neekman @ Neekware Inc. http://neekware.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include CHANGELOG.md 4 | include manage.py 5 | include pep8.sh 6 | include requirements.txt 7 | 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Uuslug 2 | 3 | **A Django slugify application that guarantees `Uniqueness` and handles `Unicode`** 4 | 5 | [![status-image]][status-link] 6 | [![version-image]][version-link] 7 | [![coverage-image]][coverage-link] 8 | 9 | # Overview 10 | 11 | In short: UUSlug == (`U`nique + `U`nicode) Slug 12 | 13 | # How to install 14 | 15 | 1. easy_install django-uuslug 16 | 2. pip install django-uuslug 17 | 3. git clone http://github.com/un33k/django-uuslug 18 | a. cd django-uuslug 19 | b. run python setup.py 20 | 4. wget https://github.com/un33k/django-uuslug/zipball/master 21 | a. unzip the downloaded file 22 | b. cd into django-uuslug-* directory 23 | c. run python setup.py 24 | 5. pip install -e git+https://github.com/un33k/django-uuslug#egg=django-uuslug 25 | 26 | # How to use 27 | 28 | ```python 29 | 30 | ####### Unicode Test ####### 31 | 32 | from uuslug import slugify 33 | 34 | txt = "This is a test ---" 35 | r = slugify(txt) 36 | self.assertEqual(r, "this-is-a-test") 37 | 38 | txt = "___This is a test ---" 39 | r = slugify(txt) 40 | self.assertEqual(r, "this-is-a-test") 41 | 42 | txt = "___This is a test___" 43 | r = slugify(txt) 44 | self.assertEqual(r, "this-is-a-test") 45 | 46 | txt = "This -- is a ## test ---" 47 | r = slugify(txt) 48 | self.assertEqual(r, "this-is-a-test") 49 | 50 | txt = '影師嗎' 51 | r = slugify(txt) 52 | self.assertEqual(r, "ying-shi-ma") 53 | 54 | txt = 'C\'est déjà l\'été.' 55 | r = slugify(txt) 56 | self.assertEqual(r, "c-est-deja-l-ete") 57 | 58 | txt = 'Nín hǎo. Wǒ shì zhōng guó rén' 59 | r = slugify(txt) 60 | self.assertEqual(r, "nin-hao-wo-shi-zhong-guo-ren") 61 | 62 | txt = 'jaja---lol-méméméoo--a' 63 | r = slugify(txt) 64 | self.assertEqual(r, "jaja-lol-mememeoo-a") 65 | 66 | txt = 'Компьютер' 67 | r = slugify(txt) 68 | self.assertEqual(r, "kompiuter") 69 | 70 | txt = 'jaja---lol-méméméoo--a' 71 | r = slugify(txt, max_length=9) 72 | self.assertEqual(r, "jaja-lol") 73 | 74 | txt = 'jaja---lol-méméméoo--a' 75 | r = slugify(txt, max_length=15) 76 | self.assertEqual(r, "jaja-lol-mememe") 77 | 78 | txt = 'jaja---lol-méméméoo--a' 79 | r = slugify(txt, max_length=50) 80 | self.assertEqual(r, "jaja-lol-mememeoo-a") 81 | 82 | txt = 'jaja---lol-méméméoo--a' 83 | r = slugify(txt, max_length=15, word_boundary=True) 84 | self.assertEqual(r, "jaja-lol-a") 85 | 86 | txt = 'jaja---lol-méméméoo--a' 87 | r = slugify(txt, max_length=17, word_boundary=True) 88 | self.assertEqual(r, "jaja-lol-mememeoo") 89 | 90 | txt = 'jaja---lol-méméméoo--a' 91 | r = slugify(txt, max_length=18, word_boundary=True) 92 | self.assertEqual(r, "jaja-lol-mememeoo") 93 | 94 | txt = 'jaja---lol-méméméoo--a' 95 | r = slugify(txt, max_length=19, word_boundary=True) 96 | self.assertEqual(r, "jaja-lol-mememeoo-a") 97 | 98 | txt = 'jaja---lol-méméméoo--a' 99 | r = slugify(txt, max_length=20, word_boundary=True, separator=".") 100 | self.assertEqual(r, "jaja.lol.mememeoo.a") 101 | 102 | txt = 'jaja---lol-méméméoo--a' 103 | r = slugify(txt, max_length=20, word_boundary=True, separator="ZZZZZZ") 104 | self.assertEqual(r, "jajaZZZZZZlolZZZZZZmememeooZZZZZZa") 105 | 106 | txt = 'one two three four five' 107 | r = slugify(txt, max_length=13, word_boundary=True, save_order=True) 108 | self.assertEqual(r, "one-two-three") 109 | 110 | txt = 'one two three four five' 111 | r = slugify(txt, max_length=13, word_boundary=True, save_order=False) 112 | self.assertEqual(r, "one-two-three") 113 | 114 | txt = 'one two three four five' 115 | r = slugify(txt, max_length=12, word_boundary=True, save_order=False) 116 | self.assertEqual(r, "one-two-four") 117 | 118 | txt = 'one two three four five' 119 | r = slugify(txt, max_length=12, word_boundary=True, save_order=True) 120 | self.assertEqual(r, "one-two") 121 | 122 | txt = 'this has a stopword' 123 | r = slugify(txt, stopwords=['stopword']) 124 | self.assertEqual(r, 'this-has-a') 125 | 126 | txt = 'the quick brown fox jumps over the lazy dog' 127 | r = slugify(txt, stopwords=['the']) 128 | self.assertEqual(r, 'quick-brown-fox-jumps-over-lazy-dog') 129 | 130 | txt = 'Foo A FOO B foo C' 131 | r = slugify(txt, stopwords=['foo']) 132 | self.assertEqual(r, 'a-b-c') 133 | 134 | txt = 'Foo A FOO B foo C' 135 | r = slugify(txt, stopwords=['FOO']) 136 | self.assertEqual(r, 'a-b-c') 137 | 138 | txt = 'the quick brown fox jumps over the lazy dog in a hurry' 139 | r = slugify(txt, stopwords=['the', 'in', 'a', 'hurry']) 140 | self.assertEqual(r, 'quick-brown-fox-jumps-over-lazy-dog') 141 | 142 | 143 | ####### Uniqueness Test ####### 144 | 145 | from django.db import models 146 | from uuslug import uuslug 147 | 148 | # Override your object's save method with something like this (models.py) 149 | class CoolSlug(models.Model): 150 | name = models.CharField(max_length=100) 151 | slug = models.CharField(max_length=200) 152 | 153 | def __unicode__(self): 154 | return self.name 155 | 156 | def save(self, *args, **kwargs): 157 | self.slug = uuslug(self.name, instance=self) 158 | super(CoolSlug, self).save(*args, **kwargs) 159 | 160 | # Note: You can also specify the start number. 161 | # Example: 162 | self.slug = uuslug(self.name, instance=self, start_no=2) 163 | # the second slug should start with "-2" instead of "-1" 164 | 165 | name = "john" 166 | c = CoolSlug.objects.create(name=name) 167 | c.save() 168 | print(c.slug) # => "john" 169 | 170 | c1 = CoolSlug.objects.create(name=name) 171 | c1.save() 172 | print(c1.slug) # => "john-1" 173 | 174 | c2 = CoolSlug.objects.create(name=name) 175 | c2.save() 176 | print(c2.slug) # => "john-2" 177 | 178 | 179 | # If you need truncation of your slug to exact length, here is an example 180 | class SmartTruncatedSlug(models.Model): 181 | name = models.CharField(max_length=19) 182 | slug = models.CharField(max_length=10) 183 | 184 | def __unicode__(self): 185 | return self.name 186 | 187 | def save(self, *args, **kwargs): 188 | self.slug = uuslug(self.name, instance=self, max_length=10) 189 | super(SmartTruncatedSlug, self).save(*args, **kwargs) 190 | 191 | # If you need automatic truncation of your slug, here is an example 192 | class AutoTruncatedSlug(models.Model): 193 | name = models.CharField(max_length=19) 194 | slug = models.CharField(max_length=19) 195 | 196 | def __unicode__(self): 197 | return self.name 198 | 199 | def save(self, *args, **kwargs): 200 | self.slug = uuslug(self.name, instance=self) 201 | super(SmartTruncatedSlug, self).save(*args, **kwargs) 202 | ``` 203 | 204 | # Running the tests 205 | 206 | To run the tests against the current environment: 207 | 208 | python manage.py test 209 | 210 | # License 211 | 212 | Released under a ([BSD](LICENSE.md)) license. 213 | 214 | # Version 215 | 216 | X.Y.Z Version 217 | 218 | `MAJOR` version -- when you make incompatible API changes, 219 | `MINOR` version -- when you add functionality in a backwards-compatible manner, and 220 | `PATCH` version -- when you make backwards-compatible bug fixes. 221 | 222 | [status-image]: https://github.com/un33k/django-uuslug/actions/workflows/main.yml/badge.svg 223 | [status-link]: https://github.com/un33k/django-uuslug/actions/workflows/main.yml 224 | [status-image]: https://github.com/un33k/django-uuslug/actions/workflows/main.yml/badge.svg 225 | [version-image]: https://img.shields.io/pypi/v/django-uuslug.svg 226 | [version-link]: https://pypi.python.org/pypi/django-uuslug 227 | [coverage-image]: https://coveralls.io/repos/un33k/django-uuslug/badge.svg 228 | [coverage-link]: https://coveralls.io/r/un33k/django-uuslug 229 | [download-image]: https://img.shields.io/pypi/dm/django-uuslug.svg 230 | [download-link]: https://pypi.python.org/pypi/django-uuslug 231 | 232 | # Sponsors 233 | 234 | [Neekware Inc.](https://github.com/neekware) 235 | -------------------------------------------------------------------------------- /formatter.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Ignoring autogenerated files 4 | # -- Migration directories 5 | # Ignoring error codes 6 | # -- E128 continuation line under-indented for visual indent 7 | # -- E225 missing whitespace around operator 8 | # -- E501 line too long 9 | 10 | pycodestyle --exclude=migrations --ignore=E128,E225,E501 . 11 | 12 | -------------------------------------------------------------------------------- /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", "uuslug.tests.testsettings") 7 | from django.core.management import execute_from_command_line 8 | execute_from_command_line(sys.argv) 9 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | Django==4.0.1 2 | pycodestyle==2.7.0 3 | flake8==3.9.2 4 | twine==3.4.2 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=2.2 2 | python-slugify>=5.0.1 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_file = LICENSE -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Learn more: https://github.com/un33k/setup.py 3 | import os 4 | import sys 5 | 6 | from codecs import open 7 | from shutil import rmtree 8 | from setuptools import setup 9 | 10 | 11 | package = 'uuslug' 12 | python_requires = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 13 | here = os.path.abspath(os.path.dirname(__file__)) 14 | 15 | requires = ['python-slugify>=5.0.1'] 16 | test_requirements = [] 17 | 18 | about = {} 19 | with open(os.path.join(here, package, '__version__.py'), 'r', 'utf-8') as f: 20 | exec(f.read(), about) 21 | 22 | with open('README.md', 'r', 'utf-8') as f: 23 | readme = f.read() 24 | 25 | 26 | def status(s): 27 | print('\033[1m{0}\033[0m'.format(s)) 28 | 29 | 30 | # 'setup.py publish' shortcut. 31 | if sys.argv[-1] == 'publish': 32 | try: 33 | status('Removing previous builds…') 34 | rmtree(os.path.join(here, 'dist')) 35 | except OSError: 36 | pass 37 | 38 | status('Building Source and Wheel (universal) distribution…') 39 | os.system('{0} setup.py sdist bdist_wheel --universal'.format(sys.executable)) 40 | 41 | status('Uploading the package to PyPI via Twine…') 42 | os.system('twine upload dist/*') 43 | 44 | status('Pushing git tags…') 45 | os.system('git tag v{0}'.format(about['__version__'])) 46 | os.system('git push --tags') 47 | sys.exit() 48 | 49 | setup( 50 | name=about['__title__'], 51 | version=about['__version__'], 52 | description=about['__description__'], 53 | long_description=readme, 54 | long_description_content_type='text/markdown', 55 | author=about['__author__'], 56 | author_email=about['__author_email__'], 57 | url=about['__url__'], 58 | packages=[package], 59 | package_data={'': ['LICENSE']}, 60 | package_dir={'uuslug': 'uuslug'}, 61 | include_package_data=True, 62 | python_requires=python_requires, 63 | install_requires=requires, 64 | license=about['__license__'], 65 | zip_safe=False, 66 | classifiers=[ 67 | 'Development Status :: 5 - Production/Stable', 68 | 'Intended Audience :: Developers', 69 | 'Natural Language :: English', 70 | 'License :: OSI Approved :: MIT License', 71 | 'Programming Language :: Python', 72 | 'Programming Language :: Python :: 3', 73 | 'Programming Language :: Python :: 3.6', 74 | 'Programming Language :: Python :: 3.7', 75 | 'Programming Language :: Python :: 3.8', 76 | 'Programming Language :: Python :: 3.9', 77 | 'Programming Language :: Python :: 3.10', 78 | ], 79 | cmdclass={}, 80 | tests_require=test_requirements, 81 | extras_require={}, 82 | project_urls={}, 83 | ) 84 | -------------------------------------------------------------------------------- /tea.yaml: -------------------------------------------------------------------------------- 1 | # https://tea.xyz/what-is-this-file 2 | --- 3 | version: 1.0.0 4 | codeOwners: 5 | - '0xaC8Bb28685BD43FD784DC902E132829c6C6DafA2' 6 | quorum: 1 7 | 8 | -------------------------------------------------------------------------------- /uuslug/__init__.py: -------------------------------------------------------------------------------- 1 | from .uuslug import * # noqa 2 | 3 | default_app_config = 'uuslug.apps.AppConfig' 4 | -------------------------------------------------------------------------------- /uuslug/__version__.py: -------------------------------------------------------------------------------- 1 | __title__ = 'django-uuslug' 2 | __author__ = 'Val Neekman' 3 | __author_email__ = 'info@neekware.com' 4 | __description__ = "A Django slugify application that also handles Unicode" 5 | __url__ = 'https://github.com/un33k/django-uuslug' 6 | __license__ = 'MIT' 7 | __copyright__ = 'Copyright 2022 Val Neekman @ Neekware Inc.' 8 | __version__ = '2.0.0' 9 | -------------------------------------------------------------------------------- /uuslug/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig as DjangoAppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class AppConfig(DjangoAppConfig): 6 | """ 7 | Configuration entry point for the uuslug app 8 | """ 9 | label = name = 'uuslug' 10 | verbose_name = _("uuslug app") 11 | -------------------------------------------------------------------------------- /uuslug/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | # create a database table only in unit test mode 5 | if 'testsettings' in os.environ['DJANGO_SETTINGS_MODULE']: 6 | from django.db import models 7 | from uuslug import uuslug 8 | 9 | class CoolSlug(models.Model): 10 | name = models.CharField(max_length=100) 11 | slug = models.CharField(max_length=200) 12 | 13 | def __unicode__(self): 14 | return self.name 15 | 16 | def save(self, *args, **kwargs): 17 | self.slug = uuslug(self.name, instance=self) 18 | super(CoolSlug, self).save(*args, **kwargs) 19 | 20 | class AnotherSlug(models.Model): 21 | name = models.CharField(max_length=100) 22 | slug = models.CharField(max_length=200) 23 | 24 | def __unicode__(self): 25 | return self.name 26 | 27 | def save(self, *args, **kwargs): 28 | self.slug = uuslug(self.name, instance=self, start_no=2) 29 | super(AnotherSlug, self).save(*args, **kwargs) 30 | 31 | class TruncatedSlug(models.Model): 32 | name = models.CharField(max_length=15) 33 | slug = models.CharField(max_length=17) 34 | 35 | def __unicode__(self): 36 | return self.name 37 | 38 | def save(self, *args, **kwargs): 39 | self.slug = uuslug(self.name, instance=self, start_no=2, max_length=17, word_boundary=False) 40 | super(TruncatedSlug, self).save(*args, **kwargs) 41 | 42 | class SmartTruncatedSlug(models.Model): 43 | name = models.CharField(max_length=17) 44 | slug = models.CharField(max_length=17) 45 | 46 | def __unicode__(self): 47 | return self.name 48 | 49 | def save(self, *args, **kwargs): 50 | self.slug = uuslug(self.name, instance=self, start_no=2, max_length=17, word_boundary=True) 51 | super(SmartTruncatedSlug, self).save(*args, **kwargs) 52 | 53 | class SmartTruncatedExactWordBoundarySlug(models.Model): 54 | name = models.CharField(max_length=19) 55 | slug = models.CharField(max_length=19) 56 | 57 | def __unicode__(self): 58 | return self.name 59 | 60 | def save(self, *args, **kwargs): 61 | self.slug = uuslug(self.name, instance=self, start_no=9, max_length=19, word_boundary=True) 62 | super(SmartTruncatedExactWordBoundarySlug, self).save(*args, **kwargs) 63 | 64 | class CoolSlugDifferentSeparator(models.Model): 65 | name = models.CharField(max_length=100) 66 | slug = models.CharField(max_length=200) 67 | 68 | def __unicode__(self): 69 | return self.name 70 | 71 | def save(self, *args, **kwargs): 72 | self.slug = uuslug(self.name, instance=self, separator='_') 73 | super(CoolSlugDifferentSeparator, self).save(*args, **kwargs) 74 | 75 | class TruncatedSlugDifferentSeparator(models.Model): 76 | name = models.CharField(max_length=15) 77 | slug = models.CharField(max_length=17) 78 | 79 | def __unicode__(self): 80 | return self.name 81 | 82 | def save(self, *args, **kwargs): 83 | self.slug = uuslug(self.name, instance=self, start_no=2, max_length=17, word_boundary=False, separator='_') 84 | super(TruncatedSlugDifferentSeparator, self).save(*args, **kwargs) 85 | 86 | class AutoTruncatedSlug(models.Model): 87 | """ 88 | The slug by generated by uuslug is bigger than the field max_length 89 | """ 90 | name = models.CharField(max_length=20) 91 | slug = models.CharField(max_length=10) 92 | 93 | def __unicode__(self): 94 | return self.name 95 | 96 | def save(self, *args, **kwargs): 97 | self.slug = uuslug(self.name, instance=self) 98 | super(AutoTruncatedSlug, self).save(*args, **kwargs) 99 | -------------------------------------------------------------------------------- /uuslug/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un33k/django-uuslug/c3d1ec0abfb7fccbf6254a9816437f23809bc6f1/uuslug/tests/__init__.py -------------------------------------------------------------------------------- /uuslug/tests/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.test import TestCase 4 | 5 | # http://pypi.python.org/pypi/django-tools/ 6 | # from django_tools.unittest_utils.print_sql import PrintQueries 7 | 8 | from uuslug import slugify, uuslug 9 | from uuslug.models import (CoolSlug, AnotherSlug, TruncatedSlug, 10 | SmartTruncatedExactWordBoundarySlug, 11 | CoolSlugDifferentSeparator, TruncatedSlugDifferentSeparator, 12 | AutoTruncatedSlug) 13 | 14 | 15 | class SlugUnicodeTestCase(TestCase): 16 | """Tests for Slug - Unicode""" 17 | 18 | def test_manager(self): 19 | 20 | txt = "This is a test ---" 21 | r = slugify(txt) 22 | self.assertEqual(r, "this-is-a-test") 23 | 24 | txt = "This -- is a ## test ---" 25 | r = slugify(txt) 26 | self.assertEqual(r, "this-is-a-test") 27 | 28 | txt = '影師嗎' 29 | r = slugify(txt) 30 | self.assertEqual(r, "ying-shi-ma") 31 | 32 | txt = 'C\'est déjà l\'été.' 33 | r = slugify(txt) 34 | self.assertEqual(r, "c-est-deja-l-ete") 35 | 36 | txt = 'Nín hǎo. Wǒ shì zhōng guó rén' 37 | r = slugify(txt) 38 | self.assertEqual(r, "nin-hao-wo-shi-zhong-guo-ren") 39 | 40 | txt = 'Компьютер' 41 | r = slugify(txt) 42 | self.assertEqual(r, "kompiuter") 43 | 44 | txt = 'jaja---lol-méméméoo--a' 45 | r = slugify(txt) 46 | self.assertEqual(r, "jaja-lol-mememeoo-a") 47 | 48 | txt = 'jaja---lol-méméméoo--a' 49 | r = slugify(txt, max_length=9) 50 | self.assertEqual(r, "jaja-lol") 51 | 52 | txt = 'jaja---lol-méméméoo--a' 53 | r = slugify(txt, max_length=15) 54 | self.assertEqual(r, "jaja-lol-mememe") 55 | 56 | txt = 'jaja---lol-méméméoo--a' 57 | r = slugify(txt, max_length=50) 58 | self.assertEqual(r, "jaja-lol-mememeoo-a") 59 | 60 | txt = 'jaja---lol-méméméoo--a' 61 | r = slugify(txt, max_length=15, word_boundary=True) 62 | self.assertEqual(r, "jaja-lol-a") 63 | 64 | txt = 'jaja---lol-méméméoo--a' 65 | r = slugify(txt, max_length=17, word_boundary=True) 66 | self.assertEqual(r, "jaja-lol-mememeoo") 67 | 68 | txt = 'jaja---lol-méméméoo--a' 69 | r = slugify(txt, max_length=18, word_boundary=True) 70 | self.assertEqual(r, "jaja-lol-mememeoo") 71 | 72 | txt = 'jaja---lol-méméméoo--a' 73 | r = slugify(txt, max_length=19, word_boundary=True) 74 | self.assertEqual(r, "jaja-lol-mememeoo-a") 75 | 76 | txt = 'jaja---lol-méméméoo--a' 77 | r = slugify(txt, max_length=20, word_boundary=True, separator=".") 78 | self.assertEqual(r, "jaja.lol.mememeoo.a") 79 | 80 | txt = 'jaja---lol-méméméoo--a' 81 | r = slugify(txt, max_length=20, word_boundary=True, separator="ZZZZZZ") 82 | self.assertEqual(r, "jajaZZZZZZlolZZZZZZmememeooZZZZZZa") 83 | 84 | txt = "___This is a test ---" 85 | r = slugify(txt) 86 | self.assertEqual(r, "this-is-a-test") 87 | 88 | txt = "___This is a test___" 89 | r = slugify(txt) 90 | self.assertEqual(r, "this-is-a-test") 91 | 92 | txt = 'one two three four five' 93 | r = slugify(txt, max_length=13, word_boundary=True, save_order=True) 94 | self.assertEqual(r, "one-two-three") 95 | 96 | txt = 'one two three four five' 97 | r = slugify(txt, max_length=13, word_boundary=True, save_order=False) 98 | self.assertEqual(r, "one-two-three") 99 | 100 | txt = 'one two three four five' 101 | r = slugify(txt, max_length=12, word_boundary=True, save_order=False) 102 | self.assertEqual(r, "one-two-four") 103 | 104 | txt = 'one two three four five' 105 | r = slugify(txt, max_length=12, word_boundary=True, save_order=True) 106 | self.assertEqual(r, "one-two") 107 | 108 | txt = 'this has a stopword' 109 | r = slugify(txt, stopwords=['stopword']) 110 | self.assertEqual(r, 'this-has-a') 111 | 112 | txt = 'the quick brown fox jumps over the lazy dog' 113 | r = slugify(txt, stopwords=['the']) 114 | self.assertEqual(r, 'quick-brown-fox-jumps-over-lazy-dog') 115 | 116 | txt = 'Foo A FOO B foo C' 117 | r = slugify(txt, stopwords=['foo']) 118 | self.assertEqual(r, 'a-b-c') 119 | 120 | txt = 'Foo A FOO B foo C' 121 | r = slugify(txt, stopwords=['FOO']) 122 | self.assertEqual(r, 'a-b-c') 123 | 124 | txt = 'the quick brown fox jumps over the lazy dog in a hurry' 125 | r = slugify(txt, stopwords=['the', 'in', 'a', 'hurry']) 126 | self.assertEqual(r, 'quick-brown-fox-jumps-over-lazy-dog') 127 | 128 | 129 | class SlugUniqueTestCase(TestCase): 130 | """Tests for Slug - Unique""" 131 | 132 | def test_manager(self): 133 | name = "john" 134 | 135 | # with PrintQueries("create first john"): # display the SQL queries 136 | with self.assertNumQueries(2): 137 | # 1. query: SELECT test, if slug 'john' exists 138 | # 2. query: INSERT values 139 | obj = CoolSlug.objects.create(name=name) 140 | self.assertEqual(obj.slug, "john") 141 | 142 | # with PrintQueries("create second john"): # display the SQL queries 143 | with self.assertNumQueries(3): 144 | # 1. query: SELECT test, if slug 'john' exists 145 | # 2. query: SELECT test, if slug 'john-1' exists 146 | # 3. query: INSERT values 147 | obj = CoolSlug.objects.create(name=name) 148 | self.assertEqual(obj.slug, "john-1") 149 | 150 | def test_start_no(self): 151 | name = 'Foo Bar' 152 | 153 | # with PrintQueries("create first 'Foo Bar'"): # display the SQL queries 154 | with self.assertNumQueries(2): 155 | # 1. query: SELECT test, if slug 'foo-bar' exists 156 | # 2. query: INSERT values 157 | obj = AnotherSlug.objects.create(name=name) 158 | self.assertEqual(obj.slug, "foo-bar") 159 | 160 | # with PrintQueries("create second 'Foo Bar'"): # display the SQL queries 161 | with self.assertNumQueries(3): 162 | # 1. query: SELECT test, if slug 'foo-bar' exists 163 | # 2. query: SELECT test, if slug 'foo-bar-2' exists 164 | # 3. query: INSERT values 165 | obj = AnotherSlug.objects.create(name=name) 166 | self.assertEqual(obj.slug, "foo-bar-2") 167 | 168 | # with PrintQueries("create third 'Foo Bar'"): # display the SQL queries 169 | with self.assertNumQueries(4): 170 | # 1. query: SELECT test, if slug 'foo-bar' exists 171 | # 2. query: SELECT test, if slug 'foo-bar-2' exists 172 | # 3. query: SELECT test, if slug 'foo-bar-3' exists 173 | # 4. query: INSERT values 174 | obj = AnotherSlug.objects.create(name=name) 175 | self.assertEqual(obj.slug, "foo-bar-3") 176 | 177 | def test_max_length(self): 178 | name = 'jaja---lol-méméméoo--a' 179 | 180 | obj = TruncatedSlug.objects.create(name=name) 181 | self.assertEqual(obj.slug, "jaja-lol-mememeoo") # 17 is max_length 182 | 183 | obj = TruncatedSlug.objects.create(name=name) 184 | self.assertEqual(obj.slug, "jaja-lol-mememe-2") # 17 is max_length 185 | 186 | obj = TruncatedSlug.objects.create(name=name) 187 | self.assertEqual(obj.slug, "jaja-lol-mememe-3") # 17 is max_length 188 | 189 | def test_max_length_exact_word_boundary(self): 190 | name = 'jaja---lol-méméméoo--a' 191 | 192 | obj = SmartTruncatedExactWordBoundarySlug.objects.create(name=name) 193 | self.assertEqual(obj.slug, "jaja-lol-mememeoo-a") # 19 is max_length 194 | 195 | obj = SmartTruncatedExactWordBoundarySlug.objects.create(name=name) 196 | self.assertEqual(obj.slug, "jaja-lol-mememeoo-9") # 19 is max_length, start_no = 9 197 | 198 | obj = SmartTruncatedExactWordBoundarySlug.objects.create(name=name) 199 | self.assertEqual(obj.slug, "jaja-lol-mememeo-10") # 19 is max_length, readjust for "-10" 200 | 201 | 202 | class SlugUniqueDifferentSeparatorTestCase(TestCase): 203 | """Tests for Slug - Unique with different separator """ 204 | 205 | def test_manager(self): 206 | name = "john" 207 | 208 | # with PrintQueries("create first john"): # display the SQL queries 209 | with self.assertNumQueries(2): 210 | # 1. query: SELECT test, if slug 'john' exists 211 | # 2. query: INSERT values 212 | obj = CoolSlugDifferentSeparator.objects.create(name=name) 213 | self.assertEqual(obj.slug, "john") 214 | 215 | # with PrintQueries("create second john"): # display the SQL queries 216 | with self.assertNumQueries(3): 217 | # 1. query: SELECT test, if slug 'john' exists 218 | # 2. query: SELECT test, if slug 'john-1' exists 219 | # 3. query: INSERT values 220 | obj = CoolSlugDifferentSeparator.objects.create(name=name) 221 | self.assertEqual(obj.slug, "john_1") 222 | 223 | # with PrintQueries("create third john"): # display the SQL queries 224 | with self.assertNumQueries(4): 225 | # 1. query: SELECT test, if slug 'john' exists 226 | # 2. query: SELECT test, if slug 'john-1' exists 227 | # 3. query: INSERT values 228 | obj = CoolSlugDifferentSeparator.objects.create(name=name) 229 | self.assertEqual(obj.slug, "john_2") 230 | 231 | def test_max_length(self): 232 | name = 'jaja---lol-méméméoo--a' 233 | 234 | obj = TruncatedSlugDifferentSeparator.objects.create(name=name) 235 | self.assertEqual(obj.slug, "jaja_lol_mememeoo") # 17 is max_length 236 | 237 | obj = TruncatedSlugDifferentSeparator.objects.create(name=name) 238 | self.assertEqual(obj.slug, "jaja_lol_mememe_2") # 17 is max_length 239 | 240 | obj = TruncatedSlugDifferentSeparator.objects.create(name=name) 241 | self.assertEqual(obj.slug, "jaja_lol_mememe_3") # 17 is max_length 242 | 243 | 244 | class SlugMaxLengthTestCase(TestCase): 245 | """Tests for Slug - Max length less than field length""" 246 | 247 | def test_manager(self): 248 | name = "john" * 51 249 | 250 | with self.assertNumQueries(2): 251 | obj = CoolSlug.objects.create(name=name) 252 | self.assertEqual(obj.slug, name[:200]) 253 | 254 | with self.assertNumQueries(3): 255 | obj = CoolSlug.objects.create(name=name) 256 | self.assertEqual(obj.slug, name[:198] + "-1") 257 | 258 | def test_max_length_greater_than_field_slug(self): 259 | name = 'jaja---lol-méméméoo--a-méméméoo' 260 | 261 | obj = AutoTruncatedSlug.objects.create(name=name) 262 | # 10 is field max_length, 20 is uuslug function max_length 263 | self.assertEqual(obj.slug, "jaja-lol-m") 264 | 265 | # 10 is field max_length, 20 is uuslug function max_length 266 | obj = AutoTruncatedSlug.objects.create(name=name) 267 | self.assertEqual(obj.slug, "jaja-lol-1") 268 | 269 | 270 | class ModelInstanceExeptionTestCase(TestCase): 271 | def test_uuslug_checks_for_model_instance(self): 272 | self.assertRaises(Exception, uuslug, 'test_slug', CoolSlug) 273 | -------------------------------------------------------------------------------- /uuslug/tests/testsettings.py: -------------------------------------------------------------------------------- 1 | DATABASES = { 2 | 'default': { 3 | 'ENGINE': 'django.db.backends.sqlite3', 4 | 'NAME': ':memory:', 5 | }, 6 | } 7 | SECRET_KEY = "un33k" 8 | INSTALLED_APPS = ['uuslug'] 9 | MIDDLEWARE_CLASSES = [] 10 | -------------------------------------------------------------------------------- /uuslug/uuslug.py: -------------------------------------------------------------------------------- 1 | from django.db.models.base import ModelBase 2 | from slugify import slugify as pyslugify 3 | from django.utils.encoding import smart_str 4 | 5 | __all__ = ['slugify', 'uuslug'] 6 | 7 | 8 | def slugify(text, entities=True, decimal=True, hexadecimal=True, max_length=0, 9 | word_boundary=False, separator='-', save_order=False, stopwords=()): 10 | """ 11 | Make a slug from a given text. 12 | """ 13 | 14 | return smart_str(pyslugify(text, entities, decimal, hexadecimal, max_length, 15 | word_boundary, separator, save_order, stopwords)) 16 | 17 | 18 | def uuslug(s, instance, entities=True, decimal=True, hexadecimal=True, 19 | slug_field='slug', filter_dict=None, start_no=1, max_length=0, 20 | word_boundary=False, separator='-', save_order=False, stopwords=()): 21 | 22 | """ This method tries a little harder than django's django.template.defaultfilters.slugify. """ 23 | 24 | if isinstance(instance, ModelBase): 25 | raise Exception("Error: you must pass an instance to uuslug, not a model.") 26 | 27 | queryset = instance.__class__.objects.all() 28 | if filter_dict: 29 | queryset = queryset.filter(**filter_dict) 30 | if instance.pk: 31 | queryset = queryset.exclude(pk=instance.pk) 32 | 33 | # The slug max_length cannot be bigger than the max length of the field 34 | slug_field_max_length = instance._meta.get_field(slug_field).max_length 35 | if not max_length or max_length > slug_field_max_length: 36 | max_length = slug_field_max_length 37 | 38 | slug = slugify(s, entities=entities, decimal=decimal, hexadecimal=hexadecimal, 39 | max_length=max_length, word_boundary=word_boundary, separator=separator, 40 | save_order=save_order, stopwords=stopwords) 41 | 42 | new_slug = slug 43 | counter = start_no 44 | while queryset.filter(**{slug_field: new_slug}).exists(): 45 | if len(slug) + len(separator) + len(str(counter)) > max_length: 46 | slug = slug[:max_length - len(slug) - len(separator) - len(str(counter))] 47 | new_slug = "{}{}{}".format(slug, separator, counter) 48 | counter += 1 49 | 50 | return new_slug 51 | --------------------------------------------------------------------------------