├── .github ├── dependabot.yml └── workflows │ ├── python-package.yml │ └── python-publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── LICENSE ├── README.md ├── hooks ├── __init__.py ├── check_untracked_migrations.py ├── po_location_format.py └── utils.py ├── requirements-dev.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── check_untracked_migrations_test.py ├── conftest.py └── po_location_format_test.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Keep GitHub Actions up to date with GitHub's Dependabot... 2 | # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot 3 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem 4 | version: 2 5 | updates: 6 | - package-ecosystem: github-actions 7 | directory: / 8 | groups: 9 | github-actions: 10 | patterns: 11 | - "*" # Group all Actions updates into a single larger pull request 12 | schedule: 13 | interval: weekly 14 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests, and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install flake8 pytest 31 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 32 | - name: Lint with flake8 33 | run: | 34 | # stop the build if there are Python syntax errors or undefined names 35 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 36 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 37 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 38 | - name: Test with pytest 39 | run: | 40 | pytest 41 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: __token__ 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.py[co] 3 | /.coverage 4 | /.mypy_cache 5 | /.pytest_cache 6 | /.tox 7 | /dist 8 | /venv* 9 | .vscode/ 10 | .idea/ 11 | /build 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3 3 | repos: 4 | - repo: https://github.com/psf/black 5 | rev: 24.8.0 6 | hooks: 7 | - id: black 8 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: check-untracked-migrations 2 | name: Untracked Django migrations checker 3 | description: "Forbid untracked Django migrations" 4 | entry: check-untracked-migrations 5 | language: python 6 | always_run: true 7 | pass_filenames: false 8 | - id: check-unapplied-migrations 9 | name: Check unapplied migrations with manage.py migrate --check 10 | entry: sh -c 'python `find . -name "manage.py"` migrate --check' 11 | pass_filenames: false 12 | language: system 13 | always_run: true 14 | - id: check-absent-migrations 15 | name: Check absent migrations with manage.py makemigrations --check --dry-run 16 | entry: sh -c 'python `find . -name "manage.py"` makemigrations --check --dry-run' 17 | pass_filenames: false 18 | language: system 19 | always_run: true 20 | - id: po-location-format 21 | name: Changes location format for .po files 22 | entry: po-location-format 23 | language: python 24 | files: \.po$ 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ivan Vedernikov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | pre-commit-hooks-django 2 | ================ 3 | 4 | Some useful hooks for Django development 5 | 6 | See also: https://github.com/pre-commit/pre-commit 7 | 8 | ### Using pre-commit-hooks-django with pre-commit 9 | 10 | Add this to your `.pre-commit-config.yaml` 11 | 12 | ```yaml 13 | - repo: https://github.com/ecugol/pre-commit-hooks-django 14 | rev: v0.4.0 # Use the ref you want to point at 15 | hooks: 16 | - id: check-untracked-migrations 17 | # Optional, if specified, hook will work only on these branches 18 | # otherwise it will work on all branches 19 | args: ["--branches", "main", "other_branch"] 20 | - id: check-unapplied-migrations 21 | - id: check-absent-migrations 22 | - id: po-location-format 23 | # Mandatory, select one of the following options: 24 | # file: show only the file path as location 25 | # never: remove all locations 26 | args: ["--add-location", "file"] 27 | ``` 28 | 29 | ### Hooks available 30 | 31 | #### `check-untracked-migrations` 32 | 33 | Forbids commit if untracked migrations files are found (e.g. `*/migrations/0001_initial.py`) 34 | 35 | ##### Options: 36 | --branches 37 | 38 | Optional, if specified, hook will work only on these branches 39 | otherwise it will work on all branches 40 | 41 | #### `check-unapplied-migrations` 42 | 43 | *WARNING: USE ONLY WITH DJANGO > v3.1* 44 | 45 | Check for unapplied migrations with manage.py migrate --check 46 | 47 | #### `check-absent-migrations` 48 | 49 | Check for absent migrations with manage.py makemigrations --check --dry-run 50 | 51 | #### `po-location-format` 52 | 53 | Changes location format for .po files 54 | 55 | ##### Options: 56 | 57 | --add-location [file, never] 58 | 59 | Mandatory, select one of the following options: 60 | 61 | file: show only the file path as location 62 | never: remove all locations 63 | -------------------------------------------------------------------------------- /hooks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecugol/pre-commit-hooks-django/7f436e6be071cfa69ba9f10091184d5c9cc94dcd/hooks/__init__.py -------------------------------------------------------------------------------- /hooks/check_untracked_migrations.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import re 3 | from typing import Optional 4 | from typing import Sequence 5 | 6 | from .utils import get_current_branch 7 | from .utils import get_untracked_files 8 | 9 | 10 | def main(argv: Optional[Sequence[str]] = None) -> int: 11 | parser = argparse.ArgumentParser() 12 | parser.add_argument( 13 | "--branches", nargs="*", help="Choose which branches to work on" 14 | ) 15 | args = parser.parse_args(argv) 16 | current_branch = get_current_branch() 17 | if args.branches and current_branch not in args.branches: 18 | print(f"{current_branch} is not present in --branches arg") 19 | return 1 20 | found = False 21 | for filename in get_untracked_files(): 22 | if re.match(r".*/migrations/.*\.py", filename): 23 | found = True 24 | print(f"Untracked migration file found: {filename}") 25 | if found: 26 | return 1 27 | return 0 28 | 29 | 30 | if __name__ == "__main__": 31 | exit(main()) 32 | -------------------------------------------------------------------------------- /hooks/po_location_format.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import tempfile 3 | import shutil 4 | from contextlib import closing 5 | from typing import Optional 6 | from typing import Sequence 7 | 8 | 9 | LOCATION_START = "#: " 10 | FILE = "file" 11 | NEVER = "never" 12 | 13 | 14 | def _extract_location_file_name(line): 15 | file_names = line.rstrip().replace(LOCATION_START, "").split(" ") 16 | return sorted({n.split(":")[0] for n in file_names}) 17 | 18 | 19 | def main(argv: Optional[Sequence[str]] = None) -> int: 20 | parser = argparse.ArgumentParser() 21 | parser.add_argument("filenames", nargs="*", help="Filenames to process") 22 | parser.add_argument("--add-location", choices=[FILE, NEVER], required=True) 23 | args = parser.parse_args(argv) 24 | add_location = args.add_location 25 | for filename in args.filenames: 26 | with tempfile.NamedTemporaryFile() as temp_file: 27 | with closing(open(filename, "r")) as source_file: 28 | location = set() 29 | for line in source_file: 30 | print(line) 31 | if line.startswith(LOCATION_START): 32 | if add_location == FILE: 33 | location.update(_extract_location_file_name(line)) 34 | else: 35 | if add_location == FILE: 36 | for name in sorted(location): 37 | temp_file.write(f"{LOCATION_START}{name}\n".encode()) 38 | location = set() 39 | temp_file.write(line.encode()) 40 | temp_file.seek(0) 41 | shutil.copyfile(temp_file.name, filename) 42 | return 1 43 | 44 | 45 | if __name__ == "__main__": 46 | exit(main()) 47 | -------------------------------------------------------------------------------- /hooks/utils.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from typing import List 3 | 4 | UNTRACKED_CMD = ["git", "ls-files", "--others", "--exclude-standard"] 5 | BRANCH_CMD = ["git", "symbolic-ref", "--short", "HEAD"] 6 | 7 | 8 | def get_untracked_files() -> List[str]: 9 | output = subprocess.check_output(UNTRACKED_CMD) 10 | return output.decode().split("\n") 11 | 12 | 13 | def get_current_branch() -> str: 14 | output = subprocess.check_output(BRANCH_CMD) 15 | return output.decode().rstrip() 16 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov 3 | coverage 4 | covdefaults 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pre-commit-hooks-django 3 | version = v0.4.0 4 | description = Some useful hooks for Django development 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/ecugol/pre-commit-hooks-django 8 | author = Ivan Vedernikov 9 | author_email = ecugol@gmail.com 10 | license = MIT 11 | license_file = LICENSE 12 | classifiers = 13 | License :: OSI Approved :: MIT License 14 | Programming Language :: Python :: 3 15 | Programming Language :: Python :: 3 :: Only 16 | Programming Language :: Python :: 3.9 17 | Programming Language :: Python :: 3.10 18 | Programming Language :: Python :: 3.11 19 | Programming Language :: Python :: 3.12 20 | Programming Language :: Python :: 3.13 21 | Programming Language :: Python :: Implementation :: CPython 22 | Programming Language :: Python :: Implementation :: PyPy 23 | 24 | [options] 25 | packages = find: 26 | python_requires = >=3.9 27 | 28 | [options.entry_points] 29 | console_scripts = 30 | check-untracked-migrations = hooks.check_untracked_migrations:main 31 | po-location-format = hooks.po_location_format:main 32 | 33 | 34 | [options.packages.find] 35 | exclude = 36 | tests* 37 | testing* 38 | 39 | [bdist_wheel] 40 | universal = True 41 | 42 | [coverage:run] 43 | plugins = covdefaults 44 | 45 | [mypy] 46 | check_untyped_defs = true 47 | disallow_any_generics = true 48 | disallow_incomplete_defs = true 49 | disallow_untyped_defs = true 50 | no_implicit_optional = true 51 | 52 | [mypy-testing.*] 53 | disallow_untyped_defs = false 54 | 55 | [mypy-tests.*] 56 | disallow_untyped_defs = false 57 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecugol/pre-commit-hooks-django/7f436e6be071cfa69ba9f10091184d5c9cc94dcd/tests/__init__.py -------------------------------------------------------------------------------- /tests/check_untracked_migrations_test.py: -------------------------------------------------------------------------------- 1 | from hooks.check_untracked_migrations import main 2 | from hooks.utils import get_current_branch 3 | 4 | 5 | def test_no_untracked_migrations(temp_git_dir): 6 | with temp_git_dir.as_cwd(): 7 | migrations_dir = temp_git_dir.mkdir("app") 8 | migrations_dir.join("main.py").write("print('hello world')") 9 | assert main() == 0 10 | 11 | 12 | def test_untracked_migrations(temp_git_dir): 13 | with temp_git_dir.as_cwd(): 14 | migrations_dir = temp_git_dir.mkdir("app").mkdir("migrations") 15 | migrations_dir.join("0001_initial.py").write("print('hello world')") 16 | assert main() == 1 17 | 18 | 19 | def test_running_on_correct_branch(temp_git_dir): 20 | with temp_git_dir.as_cwd(): 21 | current_branch = get_current_branch() 22 | assert main(["--branches", current_branch, "some_other_branch"]) == 0 23 | 24 | 25 | def test_running_on_incorrect_branch(temp_git_dir): 26 | with temp_git_dir.as_cwd(): 27 | assert main(["--branches", "branch_one", "branch_two"]) == 1 28 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture 7 | def temp_git_dir(tmpdir): 8 | git_dir = tmpdir.join("gits") 9 | subprocess.call(["git", "init", "--", str(git_dir)]) 10 | yield git_dir 11 | -------------------------------------------------------------------------------- /tests/po_location_format_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from hooks.po_location_format import main 4 | from hooks.utils import get_current_branch 5 | 6 | 7 | INPUT_PO_DATA = """ 8 | #: foo/bar.py:123 foo/bar.py:200 9 | #: foo/foo.py:123 10 | msgid "Foo" 11 | msgstr "Bar" 12 | 13 | #: foo/bar.py:123 foo/bar.py:200 14 | #: foo/foo.py:123 15 | msgid "Bar" 16 | msgstr "Foo" 17 | """ 18 | 19 | FILE_PO_DATA = """ 20 | #: foo/bar.py 21 | #: foo/foo.py 22 | msgid "Foo" 23 | msgstr "Bar" 24 | 25 | #: foo/bar.py 26 | #: foo/foo.py 27 | msgid "Bar" 28 | msgstr "Foo" 29 | """ 30 | 31 | NEVER_PO_DATA = """ 32 | msgid "Foo" 33 | msgstr "Bar" 34 | 35 | msgid "Bar" 36 | msgstr "Foo" 37 | """ 38 | 39 | 40 | @pytest.mark.parametrize( 41 | "input_data,output_data,add_location", 42 | [(INPUT_PO_DATA, FILE_PO_DATA, "file"), (INPUT_PO_DATA, NEVER_PO_DATA, "never")], 43 | ) 44 | def test_output_is_correct(input_data, output_data, add_location, tmpdir): 45 | with tmpdir.as_cwd(): 46 | in_file = tmpdir.join(f"in_{add_location}.po") 47 | in_file.write_text(INPUT_PO_DATA, encoding="utf-8") 48 | assert main([str(in_file), "--add-location", add_location]) == 1 49 | with in_file.open() as f: 50 | assert output_data == f.read() 51 | --------------------------------------------------------------------------------