├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── sample_command.png └── workflows │ ├── ci.yml │ ├── release.yml │ └── sync_python_deps.yml ├── .gitignore ├── .mise.toml ├── .pre-commit-config.yaml ├── .vscode └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── pdm.lock ├── pyproject.toml ├── scripts └── db_md.py ├── src └── sync_pre_commit_lock │ ├── __init__.py │ ├── actions │ ├── __init__.py │ ├── install_hooks.py │ └── sync_hooks.py │ ├── config.py │ ├── db.py │ ├── pdm_plugin.py │ ├── poetry_plugin.py │ ├── pre_commit_config.py │ ├── py.typed │ └── utils.py ├── tests ├── conftest.py ├── fixtures │ ├── pdm_project │ │ └── .pre-commit-config.yaml │ ├── poetry_project │ │ ├── .pre-commit-config.yaml │ │ ├── poetry.lock │ │ └── pyproject.toml │ └── sample_pre_commit_config │ │ ├── pre-commit-config-document-separator.yaml │ │ ├── pre-commit-config-only-deps.expected.yaml │ │ ├── pre-commit-config-only-deps.yaml │ │ ├── pre-commit-config-start-empty-lines.yaml │ │ ├── pre-commit-config-with-deps.expected.yaml │ │ ├── pre-commit-config-with-deps.yaml │ │ ├── pre-commit-config-with-local.yaml │ │ ├── pre-commit-config-with-one-liner-deps.expected.yaml │ │ ├── pre-commit-config-with-one-liner-deps.yaml │ │ ├── pre-commit-config-without-new-deps.expected.yaml │ │ ├── pre-commit-config-without-new-deps.yaml │ │ ├── pre-commit-config.yaml │ │ └── sample-django-stubs.yaml ├── test_actions │ ├── test_install_hooks.py │ └── test_sync_hooks.py ├── test_config.py ├── test_db.py ├── test_pdm │ ├── test_pdm_integration.py │ ├── test_pdm_plugin.py │ └── test_pdm_sync_pre_commit_hook.py ├── test_poetry │ └── test_poetry_plugin.py ├── test_pre_commit_config_file.py └── test_utils.py └── tox.ini /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | sync-pre-commit-lock@dugny.me. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. macOS] 25 | - Package Manager [e.g. PDM, Poetry] 26 | - Version [e.g. 22] 27 | 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | 22 | Feel free to share your project configuration, pre-commit config or environments (PDM on Windows...) 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | 5 | # Maintain dependencies for GitHub Actions 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | interval: "weekly" 10 | -------------------------------------------------------------------------------- /.github/sample_command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GabDug/sync-pre-commit-lock/b6e33bac981d8716b1e504efd158e50750d7bd94/.github/sample_command.png -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 3 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 4 | 5 | name: Python CI 6 | 7 | # yamllint disable-line rule:truthy 8 | on: 9 | push: 10 | branches: ["main"] 11 | pull_request: 12 | branches: ["main"] 13 | workflow_dispatch: 14 | schedule: 15 | - cron: "12 12 * * MON-FRI/3" 16 | 17 | jobs: 18 | build-package: 19 | permissions: 20 | attestations: write 21 | contents: read 22 | id-token: write 23 | name: Build & verify package 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | - name: Check built Python package 31 | id: baipp 32 | uses: hynek/build-and-inspect-python-package@v2 33 | with: 34 | upload-name-suffix: -${{ matrix.python-version }}-${{ matrix.os }} 35 | attest-build-provenance-github: ${{ github.event_name != 'pull_request' && !github.event.pull_request.head.repo.fork }} 36 | outputs: 37 | # Outputs the supported Python versions as a JSON array, from the project classifiers 38 | python-versions: ${{ steps.baipp.outputs.supported_python_classifiers_json_array }} 39 | 40 | CI-Python: 41 | runs-on: ubuntu-latest 42 | needs: build-package 43 | env: 44 | PYTHONDEVMODE: 1 45 | strategy: 46 | fail-fast: false 47 | matrix: 48 | python-version: ${{ fromJson(needs.build-package.outputs.python-versions) }} 49 | # Empty is latest, head is latest from GitHub 50 | pdm-version: [""] 51 | os: [ubuntu-latest] 52 | include: 53 | - os: macOS-latest 54 | python-version: '3.12' 55 | pdm-version: "" 56 | - os: windows-latest 57 | python-version: '3.12' 58 | pdm-version: "" 59 | 60 | steps: 61 | - uses: actions/checkout@v4 62 | with: 63 | fetch-depth: 0 64 | - uses: pdm-project/setup-pdm@v4 65 | name: Setup PDM 66 | with: 67 | cache: true 68 | python-version: ${{ matrix.python-version }} # Version range or exact version of a Python version to use, the same as actions/setup-python 69 | version: ${{ matrix.pdm-version }} # The version of PDM to install. Leave it as empty to use the latest version from PyPI, or 'head' to use the latest version from GitHub 70 | prerelease: true # Allow prerelease versions of PDM to be installed 71 | allow-python-prereleases: true # Allow prerelease versions of Python to be installed. For example if only 3.12-dev is available, 3.12 will fall back to 3.12-dev 72 | - name: Set Cache Variables 73 | id: set_variables 74 | shell: bash 75 | run: | 76 | echo "PIP_CACHE=$(pip cache dir)" >> $GITHUB_OUTPUT 77 | echo "PDM_CACHE=$(pdm config cache_dir)" >> $GITHUB_OUTPUT 78 | - name: Cache PIP and PDM 79 | uses: actions/cache@v4 80 | with: 81 | path: | 82 | ${{ steps.set_variables.outputs.PIP_CACHE }} 83 | ${{ steps.set_variables.outputs.PDM_CACHE }} 84 | key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ matrix.pdm-version }} 85 | 86 | - name: Install dependencies 87 | run: | 88 | pdm install -G :all --dev 89 | 90 | - name: Run Test with tox 91 | run: pdm run tox 92 | 93 | - name: Upload coverage to Codecov 94 | uses: codecov/codecov-action@v5 95 | with: 96 | token: ${{ secrets.CODECOV_TOKEN }} 97 | file: ./coverage.xml 98 | flags: unittests 99 | 100 | - name: Type check with mypy 101 | run: | 102 | pdm run lint-mypy 103 | - name: Lint with ruff 104 | run: | 105 | pdm run lint-ruff --output-format=github --exit-non-zero-on-fix 106 | - name: Build with pdm 107 | run: | 108 | pdm build 109 | # Do not upload to PyPI, here we only want to check that the build works 110 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | pypi-publish: 9 | name: Upload release to PyPI 10 | runs-on: ubuntu-latest 11 | permissions: 12 | # IMPORTANT: this permission is mandatory for trusted publishing 13 | id-token: write 14 | contents: write 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: pdm-project/setup-pdm@v4 18 | - name: Publish package distributions to PyPI 19 | run: pdm publish 20 | - uses: actions/upload-artifact@v4 21 | with: 22 | name: dist 23 | path: ./dist/* 24 | - uses: xresloader/upload-to-github-release@v1 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | with: 28 | file: ./dist/* 29 | release_id: ${{ github.event.release.id }} 30 | -------------------------------------------------------------------------------- /.github/workflows/sync_python_deps.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Update Python dependencies with PDM 3 | # Other dependencies updates are managed by renovatebot 4 | 5 | on: 6 | schedule: 7 | - cron: "0 12 * * SAT" 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | 14 | jobs: 15 | update-dependencies: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Update dependencies with PDM 21 | uses: pdm-project/update-deps-action@main 22 | with: 23 | # The personal access token, default: ${{ github.token }} 24 | token: ${{ github.token }} 25 | # The commit message" 26 | commit-message: "chore(deps): Update pdm.lock" 27 | # The PR title 28 | pr-title: "chore(deps): Update Python dependencies with PDM" 29 | # The update strategy, can be 'reuse', 'eager' or 'all' 30 | update-strategy: all 31 | # Whether to install PDM plugins before update 32 | install-plugins: "true" 33 | # Whether commit message contains signed-off-by 34 | sign-off-commit: "true" 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | .pdm-python 162 | .idea 163 | junit 164 | .pdm-build 165 | src/sync_pre_commit_lock/_version.py 166 | requirements-tox.txt 167 | -------------------------------------------------------------------------------- /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | pdm = "2.24" 3 | python = ["3.12", "3.13.3", "3.11", "3.10", "3.9"] 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | default_language_version: 3 | python: python3.11 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v5.0.0 7 | hooks: 8 | - id: check-toml 9 | - id: trailing-whitespace 10 | - id: check-executables-have-shebangs 11 | - id: debug-statements 12 | - id: end-of-file-fixer 13 | - id: check-added-large-files 14 | - id: check-merge-conflict 15 | - id: fix-byte-order-marker 16 | 17 | - repo: https://github.com/python-jsonschema/check-jsonschema 18 | rev: 0.29.4 19 | hooks: 20 | - id: check-github-workflows 21 | args: ["--verbose"] 22 | 23 | - repo: https://github.com/codespell-project/codespell 24 | rev: v2.2.6 25 | hooks: 26 | - id: codespell 27 | args: ["--write-changes", "--skip=pdm.lock"] 28 | 29 | - repo: https://github.com/tox-dev/tox-ini-fmt 30 | rev: "1.4.1" 31 | hooks: 32 | - id: tox-ini-fmt 33 | 34 | - repo: https://github.com/tox-dev/pyproject-fmt 35 | rev: "v2.5.0" 36 | hooks: 37 | - id: pyproject-fmt 38 | additional_dependencies: ["tox>=4.14.1"] 39 | 40 | - repo: local 41 | hooks: 42 | - id: export-supported-packages-to-readme 43 | name: db_md 44 | entry: python scripts/db_md.py 45 | language: python 46 | verbose: true 47 | files: ^db\.py|README\.md$ 48 | 49 | - rev: v0.11.6 50 | repo: https://github.com/astral-sh/ruff-pre-commit 51 | hooks: 52 | - id: ruff 53 | args: [--fix, --exit-non-zero-on-fix] 54 | - id: ruff-format 55 | 56 | - repo: https://github.com/pdm-project/pdm 57 | rev: 2.24.0 58 | hooks: 59 | - id: pdm-lock-check 60 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/*.pyc": true, 4 | "htmlcov": true, 5 | "build": true, 6 | "*.log": true, 7 | "*.egg-info": true, 8 | "pytest-*.xml": true 9 | }, 10 | "search.exclude": { 11 | "**/*.code-search": true, 12 | "dist*": true 13 | }, 14 | "html.format.templating": true, 15 | "python.testing.unittestEnabled": false, 16 | "python.testing.pytestEnabled": true, 17 | "[python]": { 18 | "editor.defaultFormatter": "charliermarsh.ruff", 19 | "editor.formatOnSave": true, 20 | "editor.codeActionsOnSave": { 21 | "source.organizeImports": "explicit", 22 | "source.organizeImports.ruff": "explicit", 23 | "source.organizeImports.isort": "never" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | All contributions are welcome! 4 | 5 | ## New features and bug fixes 6 | 7 | If you are not sure what to do, check the README roadmap for ideas. 8 | 9 | If you have something in mind, don't hesitate to open an issue to discuss it. 10 | 11 | PRs are welcomed without prior discussion, but please open an issue if you want to work on something big, to avoid wasting time. 12 | 13 | ## Repository to package mapping 14 | 15 | Contributions to the mapping of repos are welcomed, if the projects have enough usage to be worth it. 16 | 17 | If not, you can still manually add them to your `pyproject.toml` file. 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sync-pre-commit-lock 2 | 3 | [![Tests](https://github.com/GabDug/sync-pre-commit-lock/actions/workflows/ci.yml/badge.svg)](https://github.com/GabDug/sync-pre-commit-lock/actions/workflows/ci.yml) 4 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/GabDug/sync-pre-commit-lock/main.svg)](https://results.pre-commit.ci/latest/github/GabDug/sync-pre-commit-lock/main) 5 | [![OpenSSF Best Practices](https://bestpractices.coreinfrastructure.org/projects/7529/badge)](https://bestpractices.coreinfrastructure.org/projects/7529) 6 | [![pypi version](https://img.shields.io/pypi/v/sync-pre-commit-lock.svg)](https://pypi.org/project/sync-pre-commit-lock/) 7 | [![License](https://img.shields.io/pypi/l/sync-pre-commit-lock.svg)](https://pypi.python.org/pypi/sync-pre-commit-lock) 8 | [![Python version](https://img.shields.io/pypi/pyversions/sync-pre-commit-lock.svg)](https://pypi.python.org/pypi/sync-pre-commit-lock) 9 | [![pdm-managed](https://img.shields.io/badge/pdm-managed-blueviolet)](https://pdm.fming.dev) 10 | [![Ruff](https://img.shields.io/badge/ruff-lint-red)](https://github.com/charliermarsh/ruff) 11 | 12 | PDM and Poetry plugin to sync your pre-commit versions with your lockfile and automatically install pre-commit hooks. 13 | 14 | ![Sample output](.github/sample_command.png) 15 | 16 | ## Features 17 | 18 | - 🔁 Sync pre-commit versions (including `additional_dependencies`) with your lockfile 19 | - ⏩ Run every time you run the lockfile is updated, not as a pre-commit hook 20 | - 🔄 Install pre-commit hooks automatically, no need to run `pre-commit install` manually 21 | - 💫 Preserve your pre-commit config file formatting 22 | - 🍃 Lightweight, only depends on [strictyaml](https://pypi.org/project/strictyaml/) and [packaging](https://pypi.org/project/packaging/) 23 | 24 | ## Supported versions 25 | 26 | - Python 3.9+ to 3.13+ 27 | - PDM 2.7.4 to 2.24+ 28 | - Python 3.12.7+ requires PDM 2.20.1+ 29 | - Poetry 1.6 to 2.1+ 30 | 31 | > ℹ️ While we only test these versions, it should work with more recent versions. 32 | > 33 | > ⚠️ Only the latest patch version for each minor version is tested. 34 | > 35 | > 👉 We recommend using a recent version of Python, and a recent version of PDM/Poetry. 36 | 37 | ## Installation 38 | 39 | ### For PDM 40 | 41 | Install it [just like any other PDM plugin](https://pdm.fming.dev/latest/dev/write/#activate-the-plugin): 42 | 43 | ```bash 44 | pdm self add "sync-pre-commit-lock" 45 | ``` 46 | 47 | Optionally, you can also specify [the plugin in your project](https://pdm.fming.dev/latest/dev/write/#specify-the-plugins-in-project) `pyproject.toml`, to make it installable with `pdm install --plugins`: 48 | 49 | ```toml 50 | [tool.pdm] 51 | plugins = [ 52 | "sync-pre-commit-lock" 53 | ] 54 | ``` 55 | 56 | > Note: we have an extra group `[pdm]`, that adds PDM version constraints. 57 | > While it's safer, it might result in PDM being installed twice. 58 | 59 | ### For Poetry 60 | 61 | Install [like any other Poetry plugin](https://python-poetry.org/docs/plugins/#using-plugins), e.g.: 62 | 63 | ```bash 64 | poetry self add "sync-pre-commit-lock[poetry]" 65 | ``` 66 | 67 | > Only Poetry 1.6.0+ is supported. 68 | 69 | ## Configuration 70 | 71 | This plugin is configured using the `tool.sync-pre-commit-lock` section in your `pyproject.toml` file. 72 | 73 | Here is the default configuration: 74 | 75 | ```toml 76 | [tool.sync-pre-commit-lock] 77 | # Run `pre-commit install` automatically if applicable 78 | automatically-install-hooks = true 79 | # Should we sync your pre-commit versions with your lockfile (when running lock, add, update, remove, etc.)? 80 | disable-sync-from-lock = false 81 | # Packages to ignore when syncing from lock 82 | ignore = [] 83 | # Name of the pre-commit config file to sync with 84 | pre-commit-config-file = ".pre-commit-config.yaml" 85 | # Additional mapping of URLs to python packages 86 | # Default is empty, but will merge with the default mapping 87 | # "rev" indicates the format of the Git tags 88 | dependency-mapping = {"package-name"= {"repo"= "https://github.com/example/package-name", "rev"= "v${rev}"}} 89 | ``` 90 | 91 | > Note: the `dependency-mapping` is merged with the default mapping, so you don't need to specify the default mapping if you want to add a new mapping. 92 | > Repos urls will be normalized to http(s), with the trailing slash removed. 93 | 94 | ### From environment 95 | 96 | Some settings are overridable by environment variables with the following `SYNC_PRE_COMMIT_LOCK_*` prefixed environment variables: 97 | 98 | | `toml` setting | environment | format | 99 | | ------------------------------|----------------------------------------|-----------------------------------| 100 | | `automatically-install-hooks` | `SYNC_PRE_COMMIT_LOCK_INSTALL` | `bool` as string (`true`, `1`...) | 101 | | `disable-sync-from-lock` | `SYNC_PRE_COMMIT_LOCK_DISABLED` | `bool` as string (`true`, `1`...) | 102 | | `ignore` | `SYNC_PRE_COMMIT_LOCK_IGNORE` | comma-separated list | 103 | | `pre-commit-config-file` | `SYNC_PRE_COMMIT_LOCK_PRE_COMMIT_FILE` | `str` | 104 | 105 | ## Usage 106 | 107 | Once installed, and optionally configured, the plugin usage should be transparent, and trigger when you run applicable PDM or Poetry commands, like `pdm lock`, or `poetry lock`. 108 | 109 | > There should be a message in the output, when the sync or install or pre-commit is triggered. 110 | 111 | You can manually trigger the sync with the CLI command: 112 | 113 | ```bash 114 | pdm sync-pre-commit 115 | ``` 116 | 117 | or 118 | 119 | ```bash 120 | poetry sync-pre-commit 121 | ``` 122 | 123 | Both commands support `--dry-run` and verbosity options. 124 | 125 | ### PDM Github Action support 126 | 127 | If you use [pdm-project/update-deps-actions](https://github.com/pdm-project/update-deps-action) Github Action, you can get automatically update `your .pre-commit-config.yaml` file by adding the plugin in your `pyproject.toml` and setting a flag in your workflow: 128 | 129 | ```yaml 130 | # In your workflow 131 | - name: Update dependencies 132 | uses: pdm-project/update-deps-action@main 133 | with: 134 | # Whether to install PDM plugins before update (defaults to "false") 135 | install-plugins: "true" 136 | ``` 137 | 138 | ```toml 139 | # In your pyproject.toml 140 | [tool.pdm] 141 | plugins = ["sync-pre-commit-lock"] 142 | ``` 143 | 144 | ## Supported packages for pre-commits 145 | 146 | Here is the list of default packages supported by this plugin, from [`db.py`](https://github.com/GabDug/sync-pre-commit-lock/blob/main/src/sync_pre_commit_lock/db.py). You can add more packages using the `dependency-mapping` configuration. 147 | 148 | 149 | 150 | - [autopep8](https://github.com/hhatto/autopep8) 151 | - [bandit](https://github.com/PyCQA/bandit) 152 | - [black](https://github.com/psf/black-pre-commit-mirror) 153 | - [check-jsonschema](https://github.com/python-jsonschema/check-jsonschema) 154 | - [codespell](https://github.com/codespell-project/codespell) 155 | - [commitizen](https://github.com/commitizen-tools/commitizen) 156 | - [djade](https://github.com/adamchainz/djade-pre-commit) 157 | - [djhtml](https://github.com/rtts/djhtml) 158 | - [docformatter](https://github.com/PyCQA/docformatter) 159 | - [flake8](https://github.com/pycqa/flake8) 160 | - [flakeheaven](https://github.com/flakeheaven/flakeheaven) 161 | - [isort](https://github.com/pycqa/isort) 162 | - [mypy](https://github.com/pre-commit/mirrors-mypy) 163 | - [pdm](https://github.com/pdm-project/pdm) 164 | - [poetry](https://github.com/python-poetry/poetry) 165 | - [pycln](https://github.com/hadialqattan/pycln) 166 | - [pyroma](https://github.com/regebro/pyroma) 167 | - [pyupgrade](https://github.com/asottile/pyupgrade) 168 | - [rtscheck](https://github.com/rstcheck/rstcheck) 169 | - [ruff](https://github.com/astral-sh/ruff-pre-commit) 170 | - [yamllint](https://github.com/adrienverge/yamllint) 171 | 172 | 173 | > Note: `pdm` or `poetry` version will be added, from the current instance version, and not from the lockfile. 174 | 175 | ## Improvement ideas 176 | 177 | Feel free to open an issue or a PR if you have any idea, or if you want to help! 178 | 179 | ### Release / CI / DX 180 | 181 | - [ ] Add a changelog 182 | - [ ] Add "E2E" tests 183 | - [ ] Add docs 184 | 185 | ### Features or fixes 186 | 187 | - [X] Support hooks URL aliases for the same Python package 188 | - [ ] Support user configuration of aliases 189 | - [ ] Support `pdm config` and clear configuration precedence 190 | - [ ] Create a more verbose command 191 | - [ ] Add support for other lockfiles / project managers (pipenv, flit, hatch, etc.) 192 | - [ ] Expose a pre-commit hook to sync the lockfile 193 | - [x] Support nested `additional_dependencies`, (i.e. mypy types) 194 | - [ ] Support reading DB from a Python module? 195 | - [ ] Support reordering DB inputs (file/global config/python module/cli)? 196 | - [ ] Test using SSH/file dependencies? 197 | - [ ] Check ref existence before writing? 198 | - [ ] New feature to convert from pre-commit online to local? 199 | - [ ] Warning if pre-commit CI auto update is also set? 200 | - [x] Support automatic repository URL update (from legacy aliased repositories) 201 | 202 | 203 | ## Inspiration 204 | 205 | This project is inspired by @floatingpurr's [sync_with_pdm](https://github.com/floatingpurr/sync_with_pdm/) and [sync_with_poetry](https://github.com/floatingpurr/sync_with_poetry/). 206 | 207 | The code to install pre-commit hooks automatically is **adapted** from @vstrimaitis's [poetry-pre-commit-plugin](https://github.com/vstrimaitis/poetry-pre-commit-plugin/), licensed under GPL-3. 208 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Only the latest version is currently supported. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | In the case you would find any vulnerability, please reach out directly to the maintainer by email, using the email found in the `pyproject.toml`. 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "pdm.backend" 3 | requires = [ 4 | "pdm-backend", 5 | ] 6 | 7 | [project] 8 | name = "sync-pre-commit-lock" 9 | description = "PDM plugin to sync your pre-commit versions with your lockfile, and install them, all automatically." 10 | readme = "README.md" 11 | license = { file = "LICENSE" } 12 | authors = [ { name = "Gabriel Dugny", email = "sync-pre-commit-lock@dugny.me" } ] 13 | requires-python = ">=3.9" 14 | classifiers = [ 15 | "Development Status :: 3 - Alpha", 16 | "Environment :: Console", 17 | "Environment :: Plugins", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python", 22 | "Programming Language :: Python :: 3 :: Only", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.13", 28 | "Typing :: Typed", 29 | ] 30 | dynamic = [ 31 | "version", 32 | ] 33 | dependencies = [ 34 | "packaging>=24.1", 35 | "strictyaml>=1.7.3", 36 | "tomli>=2; python_version<'3.11'", 37 | "typing-extensions; python_version<'3.10'", 38 | ] 39 | optional-dependencies.pdm = [ 40 | "pdm>=2.7.4", 41 | ] 42 | optional-dependencies.poetry = [ 43 | "poetry>=1.6", 44 | ] 45 | urls."Bug Tracker" = "https://github.com/GabDug/sync-pre-commit-lock/issues" 46 | urls."Changelog" = "https://github.com/GabDug/sync-pre-commit-lock/releases" 47 | urls."Homepage" = "https://github.com/GabDug/sync-pre-commit-lock" 48 | entry-points.pdm.pdm-sync-pre-commit-lock = "sync_pre_commit_lock.pdm_plugin:register_pdm_plugin" 49 | entry-points."poetry.application.plugin".poetry-sync-pre-commit-lock = "sync_pre_commit_lock.poetry_plugin:SyncPreCommitLockPlugin" 50 | 51 | [tool.pdm] 52 | plugins = [ 53 | "-e .", 54 | ] 55 | 56 | [tool.pdm.version] 57 | source = "scm" 58 | write_to = "sync_pre_commit_lock/_version.py" 59 | write_template = "__version__: str = \"{}\"\n" 60 | 61 | [tool.pdm.scripts] 62 | fmt = { cmd = "ruff format .", help = "Run ruff formatter" } 63 | lint-mypy = { cmd = "mypy src", help = "Run mypy type checker" } 64 | # XXX(dugab): run mypy on tests as well 65 | lint-ruff = { cmd = "ruff check .", help = "Run ruff linter" } 66 | test-cov = { cmd = "pytest --junitxml=junit/test-results.xml --cov --cov-report=xml --cov-report=html --cov-report=term-missing", help = "Run tests with coverage" } 67 | test-all = { cmd = "tox", help = "Test against all supported versions" } 68 | test = { cmd = "pytest", help = "Run the test suite" } 69 | 70 | [tool.pdm.dev-dependencies] 71 | dev = [ 72 | "PyYAML>=6.0.1", 73 | "mypy>=1.4.1", 74 | "ruff>=0.0.275", 75 | "types-PyYAML>=6.0.12.10", 76 | "pytest>=7.4.0", 77 | "pytest-mock>=3.11.1", 78 | "pytest-cov>=4.1.0", 79 | "pre-commit>=3.3.3", 80 | "tomli>=2.0.1", 81 | "tox-gh>=1.3.1", 82 | "tox-pdm>=0.7.2", 83 | "tox>=4.14.2", 84 | "tox-uv>=1.7.0", 85 | ] 86 | testtox = [ 87 | "pytest>=8.1.1", 88 | "pytest-cov>=5.0.0", 89 | "pytest-mock>=3.14.0", 90 | "PyYAML>=6.0.1", 91 | ] 92 | 93 | [tool.ruff] 94 | line-length = 120 95 | respect-gitignore = true 96 | 97 | lint.extend-select = [ "D202", "D209", "EM101", "I001", "PTH", "PYI", "Q000", "RET", "S", "T", "TCH" ] 98 | lint.extend-ignore = [ "S101" ] 99 | 100 | [tool.pyproject-fmt] 101 | max_supported_python = "3.13" 102 | 103 | [tool.pytest.ini_options] 104 | minversion = "7.0" 105 | testpaths = [ "tests" ] 106 | norecursedirs = "*.egg .eggs dist build docs .tox .git __pycache__ node_modules .venv __pypackages__" 107 | addopts = "-ra --log-disable unearth.evaluator --log-disable unearth.collector --log-disable unearth.auth --log-disable pdm.termui" 108 | 109 | [tool.coverage.paths] 110 | source = [ 111 | "src", 112 | ] 113 | [tool.coverage.run] 114 | branch = true 115 | parallel = true 116 | include = [ "src/*" ] 117 | omit = [ "*/tests/*" ] 118 | source = [ "src", "tests" ] 119 | 120 | [tool.coverage.report] 121 | show_missing = true 122 | precision = 2 123 | exclude_lines = [ 124 | "def __repr__", 125 | "if TYPE_CHECKING:", 126 | "raise AssertionError", 127 | "raise NotImplementedError", 128 | "if __name__ == .__main__.:", 129 | 'class .*\bProtocol\):', 130 | '@(abc\.)?abstractmethod]', 131 | "except ImportError:", 132 | "# nocov", 133 | ] 134 | 135 | [tool.mypy] 136 | files = [ "src" ] 137 | strict = true 138 | 139 | [[tool.mypy.overrides]] 140 | module = "strictyaml" 141 | ignore_missing_imports = true 142 | -------------------------------------------------------------------------------- /scripts/db_md.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib 4 | import importlib.util 5 | import sys 6 | from pathlib import Path 7 | from typing import TYPE_CHECKING 8 | 9 | if TYPE_CHECKING: 10 | from sync_pre_commit_lock.db import PackageRepoMapping 11 | 12 | 13 | def update_readme_with_packages_list() -> None: 14 | """ 15 | Update the README file with a list of supported packages. 16 | 17 | This function reads the DEPENDENCY_MAPPING from the db.py file and generates 18 | a markdown content with a list of packages and their corresponding repositories. 19 | It then updates the README file by replacing the content between the start and end 20 | comments with the generated markdown content. 21 | 22 | Returns: 23 | None 24 | """ 25 | readme_file = Path(__file__).resolve().parent.parent / "README.md" 26 | 27 | DEPENDENCY_MAPPING = import_db() 28 | 29 | # Generate the markdown content 30 | markdown_content = "\n" + "\n".join( 31 | f"- [{package}]({data['repo']})" for package, data in DEPENDENCY_MAPPING.items() 32 | ) 33 | 34 | # Update the README file 35 | with readme_file.open("r+") as f: 36 | readme_content = f.read() 37 | start_comment = "" 38 | end_comment = "" 39 | start_index = readme_content.find(start_comment) + len(start_comment) 40 | end_index = readme_content.find(end_comment) 41 | updated_readme_content = ( 42 | readme_content[:start_index] + "\n" + markdown_content + "\n" + readme_content[end_index:] 43 | ) 44 | if updated_readme_content != readme_content: 45 | f.seek(0) 46 | f.write(updated_readme_content) 47 | f.truncate() 48 | print("Supported packages list has been added to the README file.") # noqa: T201 49 | sys.exit(1) 50 | 51 | 52 | def import_db() -> PackageRepoMapping: 53 | """ 54 | Imports the database module and returns the DEPENDENCY_MAPPING. 55 | 56 | This function imports the database module located at 'src/sync_pre_commit_lock/db.py' 57 | and returns the DEPENDENCY_MAPPING dictionary from the imported module. 58 | 59 | We don't import directly because pre-commit may not have the Python environment configured. 60 | 61 | Returns: 62 | dict: The DEPENDENCY_MAPPING dictionary from the imported database module. 63 | """ 64 | db_file = Path(__file__).resolve().parent.parent / "src/sync_pre_commit_lock/db.py" 65 | # Rest of the code... 66 | spec = importlib.util.spec_from_file_location("db", db_file) 67 | db = importlib.util.module_from_spec(spec) 68 | spec.loader.exec_module(db) 69 | return db.DEPENDENCY_MAPPING 70 | 71 | 72 | if __name__ == "__main__": 73 | update_readme_with_packages_list() 74 | -------------------------------------------------------------------------------- /src/sync_pre_commit_lock/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from typing import TYPE_CHECKING, Any, Final 5 | 6 | if TYPE_CHECKING: 7 | from sync_pre_commit_lock.pre_commit_config import PreCommitRepo 8 | 9 | PRE_COMMIT_CONFIG_FILENAME: Final[str] = ".pre-commit-config.yaml" 10 | 11 | 12 | class Printer(ABC): 13 | success_list_token: str 14 | 15 | @abstractmethod 16 | def __init__(self, *args: Any, **kwargs: Any) -> None: 17 | raise NotImplementedError 18 | 19 | @abstractmethod 20 | def debug(self, msg: str) -> None: 21 | raise NotImplementedError 22 | 23 | @abstractmethod 24 | def info(self, msg: str) -> None: 25 | raise NotImplementedError 26 | 27 | @abstractmethod 28 | def warning(self, msg: str) -> None: 29 | raise NotImplementedError 30 | 31 | @abstractmethod 32 | def error(self, msg: str) -> None: 33 | raise NotImplementedError 34 | 35 | @abstractmethod 36 | def success(self, msg: str) -> None: 37 | raise NotImplementedError 38 | 39 | def list_updated_packages(self, packages: dict[str, tuple[PreCommitRepo, PreCommitRepo]]) -> None: 40 | raise NotImplementedError 41 | -------------------------------------------------------------------------------- /src/sync_pre_commit_lock/actions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GabDug/sync-pre-commit-lock/b6e33bac981d8716b1e504efd158e50750d7bd94/src/sync_pre_commit_lock/actions/__init__.py -------------------------------------------------------------------------------- /src/sync_pre_commit_lock/actions/install_hooks.py: -------------------------------------------------------------------------------- 1 | # Modified from https://github.com/vstrimaitis/poetry-pre-commit-plugin/blob/master/src/poetry_pre_commit_plugin/plugin.py 2 | # Original code under GPLv3, written by Vytautas Strimaitis and contributors 3 | 4 | from __future__ import annotations 5 | 6 | import subprocess 7 | from pathlib import Path 8 | from typing import TYPE_CHECKING, ClassVar 9 | 10 | if TYPE_CHECKING: 11 | from collections.abc import Sequence 12 | 13 | from sync_pre_commit_lock import Printer 14 | 15 | 16 | class SetupPreCommitHooks: 17 | install_pre_commit_hooks_command: ClassVar[Sequence[str | bytes]] = ["pre-commit", "install"] 18 | check_pre_commit_version_command: ClassVar[Sequence[str | bytes]] = ["pre-commit", "--version"] 19 | 20 | def __init__(self, printer: Printer, dry_run: bool = False) -> None: 21 | self.printer = printer 22 | self.dry_run = dry_run 23 | 24 | def execute(self) -> None: 25 | if not self._is_pre_commit_package_installed(): 26 | self.printer.debug("pre-commit package is not installed (or detected). Skipping.") 27 | return 28 | 29 | git_root = self._get_git_directory_path() 30 | if git_root is None: 31 | self.printer.debug("Not in a git repository - can't install hooks. Skipping.") 32 | return 33 | 34 | if self._are_pre_commit_hooks_installed(git_root): 35 | self.printer.debug("pre-commit hooks already installed. Skipping.") 36 | return 37 | 38 | if self.dry_run is True: 39 | self.printer.debug("Dry run, skipping pre-commit hook installation.") 40 | return 41 | 42 | self._install_pre_commit_hooks() 43 | 44 | def _install_pre_commit_hooks(self) -> None: 45 | try: 46 | self.printer.info("Installing pre-commit hooks...") 47 | return_code = subprocess.check_call( # noqa: S603 48 | self.install_pre_commit_hooks_command, 49 | # XXX We probably want to see the output, at least in verbose mode or if it fails 50 | stdout=subprocess.DEVNULL, 51 | stderr=subprocess.DEVNULL, 52 | ) 53 | if return_code == 0: 54 | self.printer.info("pre-commit hooks successfully installed!") 55 | else: 56 | self.printer.error("Failed to install pre-commit hooks") 57 | except Exception as e: 58 | self.printer.error("Failed to install pre-commit hooks due to an unexpected error") 59 | self.printer.error(f"{e}") 60 | 61 | def _is_pre_commit_package_installed(self) -> bool: 62 | try: 63 | # Try is `pre-commit --version` works 64 | output = subprocess.check_output( # noqa: S603 65 | self.check_pre_commit_version_command, 66 | ).decode() 67 | except (subprocess.CalledProcessError, FileNotFoundError): 68 | return False 69 | else: 70 | return "pre-commit" in output 71 | 72 | @staticmethod 73 | def _are_pre_commit_hooks_installed(git_root: Path) -> bool: 74 | return (git_root / "hooks" / "pre-commit").exists() 75 | 76 | def _get_git_directory_path(self) -> Path | None: 77 | try: 78 | result = subprocess.check_output( # noqa: S603 79 | ["git", "rev-parse", "--show-toplevel"], # noqa: S607 80 | stderr=subprocess.PIPE, 81 | ) 82 | return Path(result.decode().strip()) / ".git" 83 | except subprocess.CalledProcessError as exc: 84 | self.printer.debug("Failed to get git root directory.") 85 | self.printer.debug(f"Git command stderr: {exc.stderr.decode()}") 86 | return None 87 | except FileNotFoundError: 88 | return None 89 | -------------------------------------------------------------------------------- /src/sync_pre_commit_lock/actions/sync_hooks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import TYPE_CHECKING, NamedTuple, Sequence 5 | 6 | from packaging.requirements import InvalidRequirement, Requirement 7 | from packaging.specifiers import SpecifierSet 8 | from packaging.utils import canonicalize_name 9 | 10 | from sync_pre_commit_lock.db import DEPENDENCY_MAPPING, REPOSITORY_ALIASES, PackageRepoMapping 11 | from sync_pre_commit_lock.pre_commit_config import PreCommitHook, PreCommitHookConfig, PreCommitRepo 12 | 13 | if TYPE_CHECKING: 14 | from pathlib import Path 15 | 16 | from sync_pre_commit_lock import Printer 17 | from sync_pre_commit_lock.config import SyncPreCommitLockConfig 18 | 19 | 20 | class GenericLockedPackage(NamedTuple): 21 | name: str 22 | version: str 23 | # Add original data here? 24 | 25 | 26 | class SyncPreCommitHooksVersion: 27 | def __init__( 28 | self, 29 | printer: Printer, 30 | pre_commit_config_file_path: Path, 31 | locked_packages: dict[str, GenericLockedPackage], 32 | plugin_config: SyncPreCommitLockConfig, 33 | dry_run: bool = False, 34 | ) -> None: 35 | self.printer = printer 36 | self.pre_commit_config_file_path = pre_commit_config_file_path 37 | self.locked_packages = locked_packages 38 | self.plugin_config = plugin_config 39 | self.dry_run = dry_run 40 | 41 | def execute(self) -> None: 42 | if self.plugin_config.disable_sync_from_lock: 43 | self.printer.debug("Sync pre-commit lock is disabled") 44 | return 45 | 46 | try: 47 | pre_commit_config_data = PreCommitHookConfig.from_yaml_file(self.pre_commit_config_file_path) 48 | except FileNotFoundError: 49 | self.printer.info( 50 | f"No pre-commit config file detected at {self.pre_commit_config_file_path}, skipping sync." 51 | ) 52 | return 53 | except ValueError as e: 54 | self.printer.error(f"Invalid pre-commit config file: {self.pre_commit_config_file_path}: {e}") 55 | return 56 | 57 | # XXX We should have the list of packages mapped, but already up to date and print it 58 | to_fix, in_sync = self.analyze_repos(pre_commit_config_data.repos_normalized) 59 | 60 | if len(to_fix) == 0 and len(in_sync) == 0: 61 | self.printer.info("No pre-commit hook detected that matches a locked package.") 62 | return 63 | if len(to_fix) == 0: 64 | packages_str = ", ".join( 65 | f"{self.mapping_reverse_by_url[pre_commit.repo]} ({pre_commit.rev})" for pre_commit in in_sync.values() 66 | ) 67 | self.printer.info(f"All pre-commit hooks are already up to date with the lockfile: {packages_str}") 68 | return 69 | 70 | self.printer.info("Detected pre-commit hooks that can be updated to match the lockfile:") 71 | self.printer.list_updated_packages( 72 | {self.mapping_reverse_by_url[repo.repo]: (repo, new_ver) for repo, new_ver in to_fix.items()} 73 | ) 74 | 75 | if self.dry_run: 76 | self.printer.info("Dry run, skipping pre-commit hook update.") 77 | return 78 | pre_commit_config_data.update_pre_commit_repo_versions(to_fix) 79 | self.printer.success(f"Pre-commit hooks have been updated in {self.pre_commit_config_file_path.name}!") 80 | 81 | @cached_property 82 | def mapping(self) -> PackageRepoMapping: 83 | return {**DEPENDENCY_MAPPING, **self.plugin_config.dependency_mapping} 84 | 85 | @cached_property 86 | def mapping_reverse_by_url(self) -> dict[str, str]: 87 | """Merge the default mapping with the user-provided mapping. Also build a reverse mapping by URL.""" 88 | mapping_reverse_by_url = {repo["repo"]: lib_name for lib_name, repo in self.mapping.items()} 89 | for canonical_name, aliases in REPOSITORY_ALIASES.items(): 90 | if canonical_name in mapping_reverse_by_url: 91 | for alias in aliases: 92 | mapping_reverse_by_url[alias] = mapping_reverse_by_url[canonical_name] 93 | # XXX Allow override / extend of aliases 94 | return mapping_reverse_by_url 95 | 96 | def get_pre_commit_repo_new_version( 97 | self, 98 | pre_commit_config_repo: PreCommitRepo, 99 | ) -> str | None: 100 | dependency = self.mapping[self.mapping_reverse_by_url[pre_commit_config_repo.repo]] 101 | dependency_name = self.mapping_reverse_by_url[pre_commit_config_repo.repo] 102 | locked_package = self.locked_packages.get(dependency_name) 103 | 104 | if not locked_package: 105 | self.printer.debug( 106 | f"Pre-commit hook {pre_commit_config_repo.repo} has a mapping to Python package `{dependency_name}`, " 107 | "but was not found in the lockfile" 108 | ) 109 | return None 110 | 111 | if "+" in locked_package.version: 112 | self.printer.debug( 113 | f"Pre-commit hook {pre_commit_config_repo.repo} has a mapping to Python package `{dependency_name}`, " 114 | f"but is skipped because the locked version `{locked_package.version}` contaims a `+`, " 115 | "which is a local version identifier." 116 | ) 117 | return None 118 | if locked_package.name in self.plugin_config.ignore: 119 | self.printer.debug(f"Ignoring {locked_package.name} from configuration.") 120 | return None 121 | 122 | self.printer.debug( 123 | f"Found mapping between pre-commit hook `{pre_commit_config_repo.repo}` and locked package `{locked_package.name}`." 124 | ) 125 | formatted_rev = dependency["rev"].replace("${rev}", str(locked_package.version)) 126 | if formatted_rev != pre_commit_config_repo.rev: 127 | self.printer.debug( 128 | f"Pre-commit hook {pre_commit_config_repo.repo} and locked package {locked_package.name} have different versions:\n" 129 | f" - Pre-commit hook ref: {pre_commit_config_repo.rev}\n" 130 | f" - Locked package version: {locked_package.version}" 131 | ) 132 | return formatted_rev 133 | 134 | self.printer.debug( 135 | f"Pre-commit hook {pre_commit_config_repo.repo} version already matches the version from the lockfile package." 136 | ) 137 | return None 138 | 139 | def get_pre_commit_repo_new_url(self, url: str) -> str: 140 | return self.mapping[self.mapping_reverse_by_url[url]]["repo"] 141 | 142 | def get_pre_commit_repo_new_hooks(self, hooks: Sequence[PreCommitHook]) -> Sequence[PreCommitHook]: 143 | return [self.get_pre_commit_repo_new_hook(hook) for hook in hooks] 144 | 145 | def get_pre_commit_repo_new_hook(self, hook: PreCommitHook) -> PreCommitHook: 146 | return PreCommitHook( 147 | hook.id, [self.get_pre_commit_repo_hook_new_dependency(dep) for dep in hook.additional_dependencies] 148 | ) 149 | 150 | def get_pre_commit_repo_hook_new_dependency(self, dependency: str) -> str: 151 | if "+" in dependency: 152 | self.printer.debug(f"Additional dependency {dependency} is a local version. Ignoring.") 153 | return dependency 154 | try: 155 | requirement = Requirement(dependency) 156 | except InvalidRequirement: 157 | self.printer.debug(f"Invalid additional dependency {dependency}. Ignoring.") 158 | return dependency 159 | normalized_name = canonicalize_name(requirement.name) 160 | if not (locked_version := self.locked_packages.get(normalized_name)): 161 | self.printer.debug(f"Additional dependency {dependency} not found in the lockfile. Ignoring.") 162 | return dependency 163 | requirement.specifier = SpecifierSet(f"=={locked_version.version}") 164 | return str(requirement) 165 | 166 | def analyze_repos( 167 | self, 168 | pre_commit_repos: set[PreCommitRepo], 169 | ) -> tuple[dict[PreCommitRepo, PreCommitRepo], dict[PreCommitRepo, PreCommitRepo]]: 170 | to_fix: dict[PreCommitRepo, PreCommitRepo] = {} 171 | in_sync: dict[PreCommitRepo, PreCommitRepo] = {} 172 | for pre_commit_repo in pre_commit_repos: 173 | if pre_commit_repo.repo not in self.mapping_reverse_by_url: 174 | self.printer.debug(f"Pre-commit hook {pre_commit_repo.repo} not found in the DB mapping") 175 | continue 176 | 177 | new_repo = PreCommitRepo( 178 | repo=self.get_pre_commit_repo_new_url(pre_commit_repo.repo), 179 | rev=self.get_pre_commit_repo_new_version(pre_commit_repo) or pre_commit_repo.rev, 180 | hooks=self.get_pre_commit_repo_new_hooks(pre_commit_repo.hooks), 181 | ) 182 | if new_repo != pre_commit_repo: 183 | to_fix[pre_commit_repo] = new_repo 184 | else: 185 | in_sync[pre_commit_repo] = pre_commit_repo 186 | 187 | return to_fix, in_sync 188 | -------------------------------------------------------------------------------- /src/sync_pre_commit_lock/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from dataclasses import dataclass, field 5 | from pathlib import Path 6 | from typing import TYPE_CHECKING, Any, Callable, TypedDict 7 | 8 | try: 9 | # 3.11+ 10 | import tomllib as toml # type: ignore[import,unused-ignore] 11 | except ImportError: 12 | import tomli as toml # type: ignore[no-redef,unused-ignore] 13 | 14 | 15 | if TYPE_CHECKING: 16 | from sync_pre_commit_lock.db import PackageRepoMapping 17 | 18 | pass 19 | 20 | ENV_PREFIX = "SYNC_PRE_COMMIT_LOCK" 21 | 22 | 23 | def env_as_bool(value: str) -> bool: 24 | return (value or "False").lower() in ("true", "1") 25 | 26 | 27 | def env_as_list(value: str) -> list[str]: 28 | return [v.strip() for v in (value or "").split(",")] 29 | 30 | 31 | def from_toml(data: dict[str, Any]) -> SyncPreCommitLockConfig: 32 | if len(data) == 0: 33 | return SyncPreCommitLockConfig() 34 | 35 | fields = {f.metadata.get("toml", f.name): f for f in SyncPreCommitLockConfig.__dataclass_fields__.values()} 36 | # XXX We should warn about unknown fields 37 | return SyncPreCommitLockConfig(**{fields[name].name: data[name] for name in data if name in fields}) 38 | 39 | 40 | def update_from_env(config: SyncPreCommitLockConfig) -> SyncPreCommitLockConfig: 41 | vars = { 42 | f.metadata["env"]: f for f in SyncPreCommitLockConfig.__dataclass_fields__.values() if f.metadata.get("env") 43 | } 44 | for var, specs in vars.items(): 45 | if value := os.getenv(f"{ENV_PREFIX}_{var}"): 46 | caster = specs.metadata.get("cast", lambda v: v) 47 | setattr(config, specs.name, caster(value)) 48 | return config 49 | 50 | 51 | class Metadata(TypedDict, total=False): 52 | """Configuration metadata known fields""" 53 | 54 | toml: str 55 | """Map the `toml` field""" 56 | env: str 57 | """Optionally map the environment variable suffix""" 58 | cast: Callable[[str], Any] 59 | """Optionally provide a cast function for environment variable""" 60 | 61 | 62 | @dataclass 63 | class SyncPreCommitLockConfig: 64 | automatically_install_hooks: bool = field( 65 | default=True, 66 | metadata=Metadata(toml="automatically-install-hooks", env="INSTALL", cast=env_as_bool), 67 | ) 68 | disable_sync_from_lock: bool = field( 69 | default=False, 70 | metadata=Metadata(toml="disable-sync-from-lock", env="DISABLED", cast=env_as_bool), 71 | ) 72 | ignore: list[str] = field( 73 | default_factory=list, 74 | metadata=Metadata(toml="ignore", env="IGNORE", cast=env_as_list), 75 | ) 76 | pre_commit_config_file: str = field( 77 | default=".pre-commit-config.yaml", 78 | metadata=Metadata(toml="pre-commit-config-file", env="PRE_COMMIT_FILE"), 79 | ) 80 | dependency_mapping: PackageRepoMapping = field( 81 | default_factory=dict, 82 | metadata=Metadata(toml="dependency-mapping"), 83 | ) 84 | 85 | 86 | def load_config(path: Path | None = None) -> SyncPreCommitLockConfig: 87 | """ 88 | Load the configuration from pyproject.toml file, and then from environment variables. 89 | 90 | Args: 91 | path (Path | None): The path to the pyproject.toml file. If None, defaults to "pyproject.toml". Best if provided by PDM or Poetry. 92 | 93 | Returns: 94 | SyncPreCommitLockConfig: The loaded configuration. 95 | """ 96 | path = path or Path("pyproject.toml") 97 | with path.open("rb") as file: 98 | config_dict = toml.load(file) 99 | 100 | tool_dict: dict[str, Any] = config_dict.get("tool", {}).get("sync-pre-commit-lock", {}) 101 | 102 | return update_from_env(from_toml(tool_dict)) 103 | -------------------------------------------------------------------------------- /src/sync_pre_commit_lock/db.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import TypedDict 3 | 4 | if sys.version_info >= (3, 10): 5 | from typing import TypeAlias 6 | else: 7 | from typing_extensions import TypeAlias 8 | 9 | 10 | class RepoInfo(TypedDict): 11 | repo: str 12 | rev: str 13 | 14 | 15 | PackageRepoMapping: TypeAlias = dict[str, RepoInfo] 16 | 17 | DEPENDENCY_MAPPING: PackageRepoMapping = { 18 | "autopep8": { 19 | "repo": "https://github.com/hhatto/autopep8", 20 | "rev": "v${rev}", 21 | }, 22 | "bandit": { 23 | "repo": "https://github.com/PyCQA/bandit", 24 | "rev": "${rev}", 25 | }, 26 | "black": { 27 | "repo": "https://github.com/psf/black-pre-commit-mirror", 28 | "rev": "${rev}", 29 | }, 30 | "check-jsonschema": { 31 | "repo": "https://github.com/python-jsonschema/check-jsonschema", 32 | "rev": "${rev}", 33 | }, 34 | "codespell": { 35 | "repo": "https://github.com/codespell-project/codespell", 36 | "rev": "v${rev}", 37 | }, 38 | "commitizen": { 39 | "repo": "https://github.com/commitizen-tools/commitizen", 40 | "rev": "v${rev}", 41 | }, 42 | "djade": { 43 | "repo": "https://github.com/adamchainz/djade-pre-commit", 44 | "rev": "${rev}", 45 | }, 46 | "djhtml": { 47 | "repo": "https://github.com/rtts/djhtml", 48 | "rev": "${rev}", 49 | }, 50 | "docformatter": { 51 | "repo": "https://github.com/PyCQA/docformatter", 52 | "rev": "${rev}", 53 | }, 54 | "flake8": { 55 | "repo": "https://github.com/pycqa/flake8", 56 | "rev": "${rev}", 57 | }, 58 | "flakeheaven": { 59 | "repo": "https://github.com/flakeheaven/flakeheaven", 60 | "rev": "${rev}", 61 | }, 62 | "isort": { 63 | "repo": "https://github.com/pycqa/isort", 64 | "rev": "${rev}", 65 | }, 66 | "mypy": { 67 | "repo": "https://github.com/pre-commit/mirrors-mypy", 68 | "rev": "v${rev}", 69 | }, 70 | "pdm": { 71 | "repo": "https://github.com/pdm-project/pdm", 72 | "rev": "${rev}", 73 | }, 74 | "poetry": { 75 | "repo": "https://github.com/python-poetry/poetry", 76 | "rev": "${rev}", 77 | }, 78 | "pycln": { 79 | "repo": "https://github.com/hadialqattan/pycln", 80 | "rev": "v${rev}", 81 | }, 82 | "pyroma": { 83 | "repo": "https://github.com/regebro/pyroma", 84 | "rev": "${rev}", 85 | }, 86 | "pyupgrade": { 87 | "repo": "https://github.com/asottile/pyupgrade", 88 | "rev": "v${rev}", 89 | }, 90 | "rtscheck": { 91 | "repo": "https://github.com/rstcheck/rstcheck", 92 | "rev": "v${rev}", 93 | }, 94 | "ruff": { 95 | "repo": "https://github.com/astral-sh/ruff-pre-commit", 96 | "rev": "v${rev}", 97 | }, 98 | "yamllint": { 99 | "repo": "https://github.com/adrienverge/yamllint", 100 | "rev": "v${rev}", 101 | }, 102 | } 103 | 104 | REPOSITORY_ALIASES: dict[str, tuple[str, ...]] = { 105 | "https://github.com/astral-sh/ruff-pre-commit": ("https://github.com/charliermarsh/ruff-pre-commit",), 106 | "https://github.com/psf/black-pre-commit-mirror": ("https://github.com/psf/black",), 107 | "https://github.com/hhatto/autopep8": ("https://github.com/pre-commit/mirrors-autopep8",), 108 | } 109 | -------------------------------------------------------------------------------- /src/sync_pre_commit_lock/pdm_plugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Iterable 4 | from typing import TYPE_CHECKING, Any, ClassVar, Union 5 | 6 | from packaging.requirements import Requirement 7 | from pdm import termui 8 | from pdm.__version__ import __version__ as pdm_version 9 | from pdm.cli.commands.base import BaseCommand 10 | from pdm.cli.options import dry_run_option 11 | from pdm.signals import post_install, post_lock 12 | from pdm.termui import Verbosity 13 | 14 | from sync_pre_commit_lock import ( 15 | Printer, 16 | ) 17 | from sync_pre_commit_lock.actions.install_hooks import SetupPreCommitHooks 18 | from sync_pre_commit_lock.actions.sync_hooks import GenericLockedPackage, SyncPreCommitHooksVersion 19 | from sync_pre_commit_lock.config import SyncPreCommitLockConfig, load_config 20 | from sync_pre_commit_lock.utils import url_diff 21 | 22 | if TYPE_CHECKING: 23 | import argparse 24 | from collections.abc import Sequence 25 | from pathlib import Path 26 | 27 | from pdm.core import Core 28 | from pdm.models.candidates import Candidate 29 | from pdm.models.repositories.lock import LockedRepository 30 | from pdm.project import Project 31 | from pdm.termui import UI 32 | 33 | from sync_pre_commit_lock.pre_commit_config import PreCommitHook, PreCommitRepo 34 | 35 | 36 | class PDMPrinter(Printer): 37 | success_list_token: str = f"[success]{termui.Emoji.SUCC}[/]" 38 | 39 | def __init__(self, ui: UI, with_prefix: bool = True, **_: Any): 40 | self.ui = ui 41 | self.plugin_prefix = "\\[sync-pre-commit-lock] " if with_prefix else "" 42 | 43 | def prefix_lines(self, msg: str) -> str: 44 | lines = msg.split("\n") 45 | return "\n".join(f"{self.plugin_prefix}{line}" for line in lines) 46 | 47 | def debug(self, msg: str) -> None: 48 | self.ui.echo(self.prefix_lines("[debug]" + msg + "[/debug]"), verbosity=Verbosity.DEBUG) 49 | 50 | def info(self, msg: str) -> None: 51 | self.ui.echo("[info]" + self.prefix_lines(msg) + "[/info]", verbosity=Verbosity.NORMAL) 52 | 53 | def warning(self, msg: str) -> None: 54 | self.ui.echo("[warning]" + self.prefix_lines(msg) + "[/warning]", verbosity=Verbosity.NORMAL) 55 | 56 | def error(self, msg: str) -> None: 57 | self.ui.echo("[error]" + self.prefix_lines(msg) + "[/error]", verbosity=Verbosity.NORMAL) 58 | 59 | def success(self, msg: str) -> None: 60 | self.ui.echo("[success]" + self.prefix_lines(msg) + "[/success]", verbosity=Verbosity.NORMAL) 61 | 62 | def _format_repo_url(self, old_repo_url: str, new_repo_url: str, package_name: str) -> str: 63 | url = url_diff(old_repo_url, new_repo_url, "[cyan]{[/][red]", "[/red][cyan] -> [/][green]", "[/][cyan]}[/]") 64 | return url.replace(package_name, f"[cyan][bold]{package_name}[/bold][/cyan]") 65 | 66 | def list_updated_packages(self, packages: dict[str, tuple[PreCommitRepo, PreCommitRepo]]) -> None: 67 | """ 68 | Args: 69 | packages: Dict of package name -> (repo, new_rev) 70 | """ 71 | self.ui.display_columns( 72 | [row for package, (old, new) in packages.items() for row in self._format_repo(package, old, new)] 73 | ) 74 | 75 | def _format_repo(self, package: str, old: PreCommitRepo, new: PreCommitRepo) -> Sequence[Sequence[str]]: 76 | new_version = new.rev != old.rev 77 | repo = ( 78 | f"[info]{self.plugin_prefix}[/info] {self.success_list_token}", 79 | f"[info]{self._format_repo_url(old.repo, new.repo, package)}[/info]", 80 | " ", 81 | f"[error]{old.rev}[/error]" if new_version else "", 82 | "[info]->[/info]" if new_version else "", 83 | f"[green]{new.rev}[/green]" if new_version else "", 84 | ) 85 | nb_hooks = len(old.hooks) 86 | hooks = [ 87 | row 88 | for idx, (old_hook, new_hook) in enumerate(zip(old.hooks, new.hooks)) 89 | for row in self._format_hook(old_hook, new_hook, idx + 1 == nb_hooks) 90 | ] 91 | return [repo, *hooks] if hooks else [repo] 92 | 93 | def _format_hook(self, old: PreCommitHook, new: PreCommitHook, last: bool) -> Sequence[Sequence[str]]: 94 | if not (nb_deps := len(old.additional_dependencies)): 95 | return [] 96 | hook = ( 97 | f"[info]{self.plugin_prefix}[/info]", 98 | f"{'└' if last else '├'} [cyan][bold]{old.id}[/bold][/cyan]", 99 | "", 100 | "", 101 | "", 102 | ) 103 | dependencies = [ 104 | self._format_additional_dependency(old_dep, new_dep, " " if last else "│", idx + 1 == nb_deps) 105 | for idx, (old_dep, new_dep) in enumerate(zip(old.additional_dependencies, new.additional_dependencies)) 106 | ] 107 | return (hook, *dependencies) 108 | 109 | def _format_additional_dependency(self, old: str, new: str, prefix: str, last: bool) -> Sequence[str]: 110 | old_req = Requirement(old) 111 | new_req = Requirement(new) 112 | return ( 113 | f"[info]{self.plugin_prefix}[/info]", 114 | f"{prefix} {'└' if last else '├'} [cyan][bold]{old_req.name}[/bold][/cyan]", 115 | " ", 116 | f"[error]{str(old_req.specifier).lstrip('==') or '*'}[/error]", 117 | "[info]->[/info]", 118 | f"[green]{str(new_req.specifier).lstrip('==')}[/green]", 119 | ) 120 | 121 | 122 | def register_pdm_plugin(core: Core) -> None: 123 | """Register the plugin to PDM Core.""" 124 | core.register_command(SyncPreCommitVersionsPDMCommand, "sync-pre-commit") 125 | printer = PDMPrinter(core.ui) 126 | printer.debug("Registered sync-pre-commit-lock plugin.") 127 | 128 | 129 | class PDMSetupPreCommitHooks(SetupPreCommitHooks): 130 | install_pre_commit_hooks_command: ClassVar[Sequence[str | bytes]] = ["pdm", "run", "pre-commit", "install"] 131 | check_pre_commit_version_command: ClassVar[Sequence[str | bytes]] = ["pdm", "run", "pre-commit", "--version"] 132 | 133 | 134 | class PDMSyncPreCommitHooksVersion(SyncPreCommitHooksVersion): 135 | pass 136 | 137 | 138 | @post_install.connect 139 | def on_pdm_install_setup_pre_commit(project: Project, *, dry_run: bool, **_: Any) -> None: 140 | printer = PDMPrinter(project.core.ui) 141 | project_root: Path = project.root 142 | plugin_config: SyncPreCommitLockConfig = load_config(project_root / project.PYPROJECT_FILENAME) 143 | printer.debug("Checking if pre-commit hooks are installed") 144 | 145 | if not plugin_config.automatically_install_hooks: 146 | printer.debug("Automatically installing pre-commit hooks is disabled. Skipping.") 147 | return 148 | action = PDMSetupPreCommitHooks(printer, dry_run=dry_run) 149 | file_path = project.root / plugin_config.pre_commit_config_file 150 | if not file_path.exists(): 151 | printer.info("No pre-commit config file found, skipping pre-commit hook check") 152 | return 153 | 154 | printer.debug("Pre-commit config file found. Setting up pre-commit hooks...") 155 | 156 | action.execute() 157 | 158 | 159 | if TYPE_CHECKING: 160 | Resolution = Union[dict[str, list[Candidate]], dict[str, Candidate]] 161 | 162 | 163 | def select_candidate(candidate: Union[Candidate, list[Candidate]]) -> Candidate | None: 164 | if isinstance(candidate, Iterable): 165 | return next(iter(candidate), None) 166 | return candidate 167 | 168 | 169 | @post_lock.connect 170 | def on_pdm_lock_check_pre_commit( 171 | project: Project, *, resolution: Resolution, dry_run: bool, with_prefix: bool = True, **_: Any 172 | ) -> None: 173 | project_root: Path = project.root 174 | plugin_config: SyncPreCommitLockConfig = load_config(project_root / project.PYPROJECT_FILENAME) 175 | printer = PDMPrinter(project.core.ui, with_prefix=with_prefix) 176 | 177 | file_path = project_root / plugin_config.pre_commit_config_file 178 | resolved_packages: dict[str, GenericLockedPackage] = { 179 | k: GenericLockedPackage(c.name, c.version) 180 | for k, v in resolution.items() 181 | if (c := select_candidate(v)) and c.name and c.version 182 | } 183 | # Adds pdm itself has it won't be part of the resolved dependencies 184 | resolved_packages["pdm"] = GenericLockedPackage("pdm", pdm_version) 185 | action = SyncPreCommitHooksVersion( 186 | printer=printer, 187 | pre_commit_config_file_path=file_path, 188 | locked_packages=resolved_packages, 189 | plugin_config=plugin_config, 190 | dry_run=dry_run, 191 | ) 192 | action.execute() 193 | 194 | 195 | class SyncPreCommitVersionsPDMCommand(BaseCommand): 196 | """Sync `.pre-commit-config.yaml` hooks versions with the lockfile""" 197 | 198 | # The class docstring acts as the description of the command, don't make it longer! 199 | 200 | def add_arguments(self, parser: argparse.ArgumentParser) -> None: 201 | dry_run_option.add_to_parser(parser) 202 | 203 | def handle(self, project: Project, options: argparse.Namespace) -> None: 204 | candidates = self._get_locked_repository(project).all_candidates 205 | 206 | on_pdm_lock_check_pre_commit(project, resolution=candidates, dry_run=options.dry_run, with_prefix=False) 207 | 208 | def _get_locked_repository(self, project: Project) -> LockedRepository: 209 | # `locked_repository` was deprecated in PDM 2.17 favour of `get_locked_repository`, try to use it first to avoid warning 210 | if hasattr(project, "get_locked_repository"): 211 | return project.get_locked_repository() 212 | return project.locked_repository 213 | -------------------------------------------------------------------------------- /src/sync_pre_commit_lock/poetry_plugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING, ClassVar 5 | 6 | from cleo.commands.command import Command 7 | from cleo.events.console_events import TERMINATE 8 | from cleo.events.console_terminate_event import ConsoleTerminateEvent 9 | from cleo.exceptions import CleoValueError 10 | from cleo.helpers import option 11 | from cleo.io.outputs.output import Verbosity 12 | from packaging.requirements import Requirement 13 | from poetry.__version__ import __version__ as poetry_version 14 | from poetry.console.application import Application 15 | from poetry.console.commands.add import AddCommand 16 | from poetry.console.commands.install import InstallCommand 17 | from poetry.console.commands.lock import LockCommand 18 | from poetry.console.commands.self.self_command import SelfCommand 19 | from poetry.console.commands.update import UpdateCommand 20 | from poetry.plugins.application_plugin import ApplicationPlugin 21 | 22 | from sync_pre_commit_lock import Printer 23 | from sync_pre_commit_lock.actions.install_hooks import SetupPreCommitHooks 24 | from sync_pre_commit_lock.actions.sync_hooks import GenericLockedPackage, SyncPreCommitHooksVersion 25 | from sync_pre_commit_lock.config import load_config 26 | from sync_pre_commit_lock.utils import url_diff 27 | 28 | if TYPE_CHECKING: 29 | from collections.abc import Sequence 30 | 31 | from cleo.events.event import Event 32 | from cleo.events.event_dispatcher import EventDispatcher 33 | from cleo.io.io import IO 34 | 35 | from sync_pre_commit_lock.pre_commit_config import PreCommitHook, PreCommitRepo 36 | 37 | 38 | class PoetryPrinter(Printer): 39 | success_list_token: str = "•" # noqa: S105 40 | 41 | def __init__(self, io: IO, with_prefix: bool = True) -> None: 42 | self.io = io 43 | self.plugin_prefix = "[sync-pre-commit-lock] " if with_prefix else "" 44 | 45 | def debug(self, msg: str) -> None: 46 | self.io.write_line(f"{self.plugin_prefix}{msg}", verbosity=Verbosity.DEBUG) 47 | 48 | def info(self, msg: str) -> None: 49 | self.io.write_line(f"{self.plugin_prefix}{msg}", verbosity=Verbosity.NORMAL) 50 | 51 | def warning(self, msg: str) -> None: 52 | return self.io.write_line(f"{self.plugin_prefix}{msg}", verbosity=Verbosity.NORMAL) 53 | 54 | def error(self, msg: str) -> None: 55 | return self.io.write_error_line(f"{self.plugin_prefix}{msg}", verbosity=Verbosity.NORMAL) 56 | 57 | def success(self, msg: str) -> None: 58 | return self.io.write_line(f"{self.plugin_prefix} {msg}", verbosity=Verbosity.NORMAL) 59 | 60 | def list_updated_packages(self, packages: dict[str, tuple[PreCommitRepo, PreCommitRepo]]) -> None: 61 | from cleo.ui.table import Table 62 | 63 | table = Table(self.io, style="compact") 64 | 65 | table.set_rows( 66 | [list(row) for package, (old, new) in packages.items() for row in self._format_repo(package, old, new)] 67 | ) 68 | 69 | table.render() 70 | 71 | def _format_repo(self, package: str, old: PreCommitRepo, new: PreCommitRepo) -> Sequence[Sequence[str]]: 72 | new_version = new.rev != old.rev 73 | repo = ( 74 | f"{self.plugin_prefix} {self.success_list_token}", 75 | self._format_repo_url(old.repo, new.repo, package), 76 | " ", 77 | f"{old.rev}" if new_version else "", 78 | "->" if new_version else "", 79 | f"{new.rev}" if new_version else "", 80 | ) 81 | nb_hooks = len(old.hooks) 82 | hooks = [ 83 | row 84 | for idx, (old_hook, new_hook) in enumerate(zip(old.hooks, new.hooks)) 85 | for row in self._format_hook(old_hook, new_hook, idx + 1 == nb_hooks) 86 | ] 87 | return [repo, *hooks] if hooks else [repo] 88 | 89 | def _format_repo_url(self, old_repo_url: str, new_repo_url: str, package_name: str) -> str: 90 | url = url_diff(old_repo_url, new_repo_url, "{", " -> ", "}") 91 | return url.replace(package_name, f"{package_name}") 92 | 93 | def _format_hook(self, old: PreCommitHook, new: PreCommitHook, last: bool) -> Sequence[Sequence[str]]: 94 | if not (nb_deps := len(old.additional_dependencies)): 95 | return [] 96 | hook = ( 97 | f"{self.plugin_prefix}", 98 | f"{'└' if last else '├'} {old.id}", 99 | "", 100 | "", 101 | "", 102 | ) 103 | dependencies = [ 104 | self._format_additional_dependency(old_dep, new_dep, " " if last else "│", idx + 1 == nb_deps) 105 | for idx, (old_dep, new_dep) in enumerate(zip(old.additional_dependencies, new.additional_dependencies)) 106 | ] 107 | return (hook, *dependencies) 108 | 109 | def _format_additional_dependency(self, old: str, new: str, prefix: str, last: bool) -> Sequence[str]: 110 | old_req = Requirement(old) 111 | new_req = Requirement(new) 112 | return ( 113 | f"{self.plugin_prefix}", 114 | f"{prefix} {'└' if last else '├'} {old_req.name}", 115 | " ", 116 | f"{str(old_req.specifier).lstrip('==') or '*'}", 117 | "->", 118 | f"{str(new_req.specifier).lstrip('==')}", 119 | ) 120 | 121 | 122 | class PoetrySetupPreCommitHooks(SetupPreCommitHooks): 123 | install_pre_commit_hooks_command: ClassVar[Sequence[str | bytes]] = ["poetry", "run", "pre-commit", "install"] 124 | check_pre_commit_version_command: ClassVar[Sequence[str | bytes]] = ["poetry", "run", "pre-commit", "--version"] 125 | 126 | 127 | def run_sync_pre_commit_version(printer: PoetryPrinter, dry_run: bool, application: Application) -> None: 128 | poetry_locked_packages = application.poetry.locker.locked_repository().packages 129 | locked_packages = {str(p.name): GenericLockedPackage(p.name, str(p.version)) for p in poetry_locked_packages} 130 | plugin_config = load_config(application.poetry.pyproject_path) 131 | file_path = Path().cwd() / plugin_config.pre_commit_config_file 132 | # Add poetry itself as it won't be part of the resolved dependencies 133 | locked_packages["poetry"] = GenericLockedPackage("poetry", poetry_version) 134 | 135 | SyncPreCommitHooksVersion( 136 | printer, 137 | pre_commit_config_file_path=file_path, 138 | plugin_config=plugin_config, 139 | locked_packages=locked_packages, 140 | dry_run=dry_run, 141 | ).execute() 142 | 143 | 144 | class SyncPreCommitLockPlugin(ApplicationPlugin): 145 | application: Application | None 146 | 147 | def activate(self, application: Application) -> None: 148 | assert application.event_dispatcher is not None 149 | application.event_dispatcher.add_listener(TERMINATE, self._handle_post_command) 150 | application.command_loader.register_factory("sync-pre-commit", sync_pre_commit_poetry_command_factory) 151 | self.application = application 152 | 153 | def _handle_post_command( 154 | self, event: ConsoleTerminateEvent | Event, event_name: str, dispatcher: EventDispatcher 155 | ) -> None: 156 | assert isinstance(event, ConsoleTerminateEvent) 157 | if event.exit_code != 0: 158 | # The command failed, so the plugin shouldn't do anything 159 | return 160 | 161 | command = event.command 162 | printer = PoetryPrinter(event.io) 163 | try: 164 | dry_run: bool = bool(command.option("dry-run")) 165 | except CleoValueError: 166 | dry_run = False 167 | 168 | if isinstance(command, SelfCommand): 169 | printer.debug("Poetry pre-commit plugin does not run for 'self' command.") 170 | return 171 | 172 | if any(isinstance(command, t) for t in [InstallCommand, AddCommand]): 173 | PoetrySetupPreCommitHooks(printer, dry_run=dry_run).execute() 174 | 175 | if any(isinstance(command, t) for t in [InstallCommand, AddCommand, LockCommand, UpdateCommand]): 176 | if self.application is None: 177 | msg = "self.application is None" 178 | raise RuntimeError(msg) 179 | 180 | # Get all locked dependencies from self.application 181 | run_sync_pre_commit_version(printer, dry_run, self.application) 182 | 183 | 184 | class SyncPreCommitPoetryCommand(Command): 185 | name = "sync-pre-commit" 186 | description = "Sync `.pre-commit-config.yaml` hooks versions with the lockfile" 187 | help = "Sync `.pre-commit-config.yaml` hooks versions with the lockfile" 188 | options = [ 189 | option( 190 | "dry-run", 191 | None, 192 | "Output the operations but do not update the pre-commit file.", 193 | ), 194 | ] 195 | 196 | def handle(self) -> int: 197 | # XXX(dugab): handle return codes 198 | if not self.application: 199 | msg = "self.application is None" 200 | raise RuntimeError(msg) 201 | assert isinstance(self.application, Application) 202 | run_sync_pre_commit_version(PoetryPrinter(self.io, with_prefix=False), False, self.application) 203 | return 0 204 | 205 | 206 | def sync_pre_commit_poetry_command_factory() -> SyncPreCommitPoetryCommand: 207 | return SyncPreCommitPoetryCommand() 208 | -------------------------------------------------------------------------------- /src/sync_pre_commit_lock/pre_commit_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import difflib 4 | from dataclasses import dataclass, field 5 | from functools import cached_property 6 | from typing import TYPE_CHECKING, Any 7 | 8 | import strictyaml as yaml 9 | from strictyaml import Any as AnyStrictYaml 10 | from strictyaml import MapCombined, Optional, Seq, Str 11 | 12 | from sync_pre_commit_lock.utils import normalize_git_url 13 | 14 | if TYPE_CHECKING: 15 | from collections.abc import Sequence 16 | from pathlib import Path 17 | 18 | schema = MapCombined( 19 | { 20 | Optional("repos"): Seq( 21 | MapCombined( 22 | { 23 | "repo": Str(), 24 | Optional("rev"): Str(), 25 | Optional("hooks"): Seq( 26 | MapCombined( 27 | { 28 | "id": Str(), 29 | Optional("additional_dependencies"): Seq(Str()), 30 | }, 31 | Str(), 32 | AnyStrictYaml(), 33 | ) 34 | ), 35 | }, 36 | Str(), 37 | AnyStrictYaml(), 38 | ), 39 | ) 40 | }, 41 | Str(), 42 | AnyStrictYaml(), 43 | ) 44 | 45 | 46 | @dataclass(frozen=True) 47 | class PreCommitHook: 48 | id: str 49 | additional_dependencies: Sequence[str] = field(default_factory=tuple) 50 | 51 | def __hash__(self) -> int: 52 | return hash((self.id, *self.additional_dependencies)) 53 | 54 | def __eq__(self, other: object) -> bool: 55 | return ( 56 | isinstance(other, PreCommitHook) 57 | and other.id == self.id 58 | and all( 59 | other_dep == self_dep 60 | for other_dep, self_dep in zip(other.additional_dependencies, self.additional_dependencies) 61 | ) 62 | ) 63 | 64 | 65 | @dataclass(frozen=True) 66 | class PreCommitRepo: 67 | repo: str 68 | rev: str # Check if is not loaded as float/int/other yolo 69 | hooks: Sequence[PreCommitHook] = field(default_factory=tuple) 70 | 71 | def __hash__(self) -> int: 72 | return hash((self.repo, self.rev, *[hook.__hash__() for hook in self.hooks])) 73 | 74 | def __eq__(self, other: object) -> bool: 75 | return ( 76 | isinstance(other, PreCommitRepo) 77 | and other.repo == self.repo 78 | and other.rev == self.rev 79 | and all(other_hook == self_hook for other_hook, self_hook in zip(other.hooks, self.hooks)) 80 | ) 81 | 82 | 83 | class PreCommitHookConfig: 84 | def __init__( 85 | self, 86 | raw_file_contents: str, 87 | pre_commit_config_file_path: Path, 88 | ) -> None: 89 | self.raw_file_contents = raw_file_contents 90 | self.yaml = yaml.dirty_load( 91 | raw_file_contents, schema=schema, allow_flow_style=True, label=str(pre_commit_config_file_path) 92 | ) 93 | 94 | self.pre_commit_config_file_path = pre_commit_config_file_path 95 | 96 | @cached_property 97 | def original_file_lines(self) -> list[str]: 98 | return self.raw_file_contents.splitlines(keepends=True) 99 | 100 | @property 101 | def data(self) -> Any: 102 | return self.yaml.data 103 | 104 | @classmethod 105 | def from_yaml_file(cls, file_path: Path) -> PreCommitHookConfig: 106 | with file_path.open("r") as stream: 107 | file_contents = stream.read() 108 | 109 | return PreCommitHookConfig(file_contents, file_path) 110 | 111 | @cached_property 112 | def repos(self) -> list[PreCommitRepo]: 113 | """Return the repos, excluding local repos.""" 114 | return [ 115 | PreCommitRepo( 116 | repo=repo["repo"], 117 | rev=repo["rev"], 118 | hooks=tuple( 119 | PreCommitHook(hook["id"], hook.get("additional_dependencies", tuple())) 120 | for hook in repo.get("hooks", tuple()) 121 | ), 122 | ) 123 | for repo in (self.data["repos"] or []) 124 | if "rev" in repo 125 | ] 126 | 127 | @cached_property 128 | def repos_normalized(self) -> set[PreCommitRepo]: 129 | return { 130 | PreCommitRepo( 131 | repo=normalize_git_url(repo.repo), 132 | rev=repo.rev, 133 | hooks=repo.hooks, 134 | ) 135 | for repo in self.repos 136 | } 137 | 138 | @cached_property 139 | def document_start_offset(self) -> int: 140 | """Return the line number where the YAML document starts.""" 141 | lines = self.raw_file_contents.split("\n") 142 | for i, line in enumerate(lines): 143 | # Trim leading/trailing whitespaces 144 | line = line.rstrip() 145 | # Skip if line is a comment or empty/whitespace 146 | if line.startswith("#") or line == "": 147 | continue 148 | # If line is '---', return line number + 1 149 | if line == "---": 150 | return i + 1 151 | return 0 152 | 153 | def update_pre_commit_repo_versions(self, new_versions: dict[PreCommitRepo, PreCommitRepo]) -> None: 154 | """Fix the pre-commit hooks to match the lockfile. Preserve comments and formatting as much as possible.""" 155 | if len(new_versions) == 0: 156 | return 157 | 158 | original_lines = self.original_file_lines 159 | updated_lines = original_lines[:] 160 | 161 | for repo_rev in self.yaml["repos"]: 162 | if "rev" not in repo_rev: 163 | continue 164 | 165 | repo, rev, hooks = repo_rev["repo"], repo_rev["rev"], repo_rev.get("hooks", tuple()) 166 | normalized_repo = PreCommitRepo( 167 | normalize_git_url(str(repo)), 168 | str(rev), 169 | tuple( 170 | PreCommitHook(str(hook["id"]), [str(dep) for dep in hook.get("additional_dependencies", tuple())]) 171 | for hook in hooks 172 | ), 173 | ) 174 | if not (updated_repo := new_versions.get(normalized_repo)): 175 | continue 176 | 177 | rev_line_number: int = rev.end_line + self.document_start_offset 178 | rev_line_idx: int = rev_line_number - 1 179 | original_rev_line: str = updated_lines[rev_line_idx] 180 | updated_lines[rev_line_idx] = original_rev_line.replace(str(rev), updated_repo.rev) 181 | 182 | for src_hook, old_hook, new_hook in zip(hooks, normalized_repo.hooks, updated_repo.hooks): 183 | if new_hook == old_hook: 184 | continue 185 | for src_dep, old_dep, new_dep in zip( 186 | src_hook.get("additional_dependencies", []), 187 | old_hook.additional_dependencies, 188 | new_hook.additional_dependencies, 189 | ): 190 | if old_dep == new_dep: 191 | continue 192 | dep_line_number: int = src_dep.end_line + self.document_start_offset 193 | dep_line_idx: int = dep_line_number - 1 194 | original_dep_line: str = updated_lines[dep_line_idx] 195 | updated_lines[dep_line_idx] = original_dep_line.replace(str(src_dep), new_dep) 196 | 197 | changes = difflib.ndiff(original_lines, updated_lines) 198 | change_count = sum(1 for change in changes if change[0] in ["+", "-"]) 199 | 200 | if change_count == 0: 201 | msg = "No changes to write, this should not happen" 202 | raise RuntimeError(msg) 203 | with self.pre_commit_config_file_path.open("w") as stream: 204 | stream.writelines(updated_lines) 205 | -------------------------------------------------------------------------------- /src/sync_pre_commit_lock/py.typed: -------------------------------------------------------------------------------- 1 | # Instruct type checkers to look for inline type annotations in this package. 2 | # See PEP 561. 3 | -------------------------------------------------------------------------------- /src/sync_pre_commit_lock/utils.py: -------------------------------------------------------------------------------- 1 | from os.path import commonprefix 2 | from urllib.parse import urlparse, urlunparse 3 | 4 | 5 | def normalize_git_url(url: str) -> str: 6 | """Normalize a git URL to https://, remove .git from the end of the path, and lowercase the hostname. 7 | 8 | If the URL is malformed, return the original URL. 9 | """ 10 | # Ignore local paths 11 | if "://" not in url: 12 | return url 13 | 14 | # Parse the URL 15 | parsed_url = urlparse(url) 16 | 17 | # Normalize the scheme: convert git, git+ssh, and ssh to https 18 | scheme = parsed_url.scheme 19 | if scheme in ["git", "git+ssh", "ssh"]: 20 | scheme = "https" 21 | 22 | # Lowercase the hostname and remove default port if it exists 23 | netloc = parsed_url.hostname.lower() if parsed_url.hostname else "" 24 | 25 | # If netloc is empty (git, ssh URLs), then path contains it. 26 | if not netloc: 27 | return url # malformed URL, we can't normalize it 28 | 29 | path = parsed_url.path 30 | 31 | # Remove .git from the end of path if it's there 32 | if path.endswith(".git"): 33 | path = path[:-4] 34 | 35 | # Reconstruct the URL 36 | normalized_url = urlunparse((scheme, netloc, path, None, None, None)) 37 | 38 | if normalized_url.endswith("/"): 39 | normalized_url = normalized_url[:-1] 40 | 41 | return normalized_url 42 | 43 | 44 | def url_diff(old: str, new: str, diff_open: str = "{", diff_separator: str = " -> ", diff_close: str = "}") -> str: 45 | """Represent a change of URL highlighting only the changed part""" 46 | if old == new: 47 | return new 48 | prefix = commonprefix((old, new)) 49 | old, new = old.removeprefix(prefix), new.removeprefix(prefix) 50 | suffix = commonprefix((old[::-1], new[::-1]))[::-1] 51 | old, new = old.removesuffix(suffix), new.removesuffix(suffix) 52 | return f"{prefix}{diff_open}{old}{diff_separator}{new}{diff_close}{suffix}" 53 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | try: 6 | import pdm # noqa: F401 7 | except ImportError: 8 | pass 9 | else: 10 | pytest_plugins = [ 11 | "pdm.pytest", 12 | ] 13 | 14 | 15 | @pytest.fixture 16 | def fixtures() -> Path: 17 | return Path(__file__).parent.joinpath("fixtures") 18 | -------------------------------------------------------------------------------- /tests/fixtures/pdm_project/.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/astral-sh/ruff-pre-commit 4 | rev: v0.1.0 5 | hooks: 6 | - id: ruff 7 | -------------------------------------------------------------------------------- /tests/fixtures/poetry_project/.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | default_language_version: 4 | python: python3.11 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.4.0 8 | hooks: 9 | - id: check-toml 10 | 11 | - repo: https://github.com/psf/black 12 | rev: 23.3.0 13 | hooks: 14 | - id: black 15 | 16 | - repo: https://github.com/charliermarsh/ruff-pre-commit 17 | rev: 'v0.0.275' 18 | hooks: 19 | - id: ruff 20 | args: [--fix, --exit-non-zero-on-fix] 21 | -------------------------------------------------------------------------------- /tests/fixtures/poetry_project/poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.6.0.dev0 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "black" 5 | version = "23.3.0" 6 | description = "The uncompromising code formatter." 7 | optional = false 8 | python-versions = ">=3.7" 9 | files = [ 10 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915"}, 11 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9"}, 12 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2"}, 13 | {file = "black-23.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c"}, 14 | {file = "black-23.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c"}, 15 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6"}, 16 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b"}, 17 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d"}, 18 | {file = "black-23.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70"}, 19 | {file = "black-23.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326"}, 20 | {file = "black-23.3.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:1d06691f1eb8de91cd1b322f21e3bfc9efe0c7ca1f0e1eb1db44ea367dff656b"}, 21 | {file = "black-23.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50cb33cac881766a5cd9913e10ff75b1e8eb71babf4c7104f2e9c52da1fb7de2"}, 22 | {file = "black-23.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e114420bf26b90d4b9daa597351337762b63039752bdf72bf361364c1aa05925"}, 23 | {file = "black-23.3.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:48f9d345675bb7fbc3dd85821b12487e1b9a75242028adad0333ce36ed2a6d27"}, 24 | {file = "black-23.3.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:714290490c18fb0126baa0fca0a54ee795f7502b44177e1ce7624ba1c00f2331"}, 25 | {file = "black-23.3.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:064101748afa12ad2291c2b91c960be28b817c0c7eaa35bec09cc63aa56493c5"}, 26 | {file = "black-23.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:562bd3a70495facf56814293149e51aa1be9931567474993c7942ff7d3533961"}, 27 | {file = "black-23.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:e198cf27888ad6f4ff331ca1c48ffc038848ea9f031a3b40ba36aced7e22f2c8"}, 28 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:3238f2aacf827d18d26db07524e44741233ae09a584273aa059066d644ca7b30"}, 29 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:f0bd2f4a58d6666500542b26354978218a9babcdc972722f4bf90779524515f3"}, 30 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:92c543f6854c28a3c7f39f4d9b7694f9a6eb9d3c5e2ece488c327b6e7ea9b266"}, 31 | {file = "black-23.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a150542a204124ed00683f0db1f5cf1c2aaaa9cc3495b7a3b5976fb136090ab"}, 32 | {file = "black-23.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:6b39abdfb402002b8a7d030ccc85cf5afff64ee90fa4c5aebc531e3ad0175ddb"}, 33 | {file = "black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4"}, 34 | {file = "black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"}, 35 | ] 36 | 37 | [package.dependencies] 38 | click = ">=8.0.0" 39 | mypy-extensions = ">=0.4.3" 40 | packaging = ">=22.0" 41 | pathspec = ">=0.9.0" 42 | platformdirs = ">=2" 43 | 44 | [package.extras] 45 | colorama = ["colorama (>=0.4.3)"] 46 | d = ["aiohttp (>=3.7.4)"] 47 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 48 | uvloop = ["uvloop (>=0.15.2)"] 49 | 50 | [[package]] 51 | name = "click" 52 | version = "8.1.3" 53 | description = "Composable command line interface toolkit" 54 | optional = false 55 | python-versions = ">=3.7" 56 | files = [ 57 | {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 58 | {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 59 | ] 60 | 61 | [package.dependencies] 62 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 63 | 64 | [[package]] 65 | name = "colorama" 66 | version = "0.4.6" 67 | description = "Cross-platform colored terminal text." 68 | optional = false 69 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 70 | files = [ 71 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 72 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 73 | ] 74 | 75 | [[package]] 76 | name = "isort" 77 | version = "5.12.0" 78 | description = "A Python utility / library to sort Python imports." 79 | optional = false 80 | python-versions = ">=3.8.0" 81 | files = [ 82 | {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, 83 | {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, 84 | ] 85 | 86 | [package.extras] 87 | colors = ["colorama (>=0.4.3)"] 88 | pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"] 89 | plugins = ["setuptools"] 90 | requirements-deprecated-finder = ["pip-api", "pipreqs"] 91 | 92 | [[package]] 93 | name = "mypy-extensions" 94 | version = "1.0.0" 95 | description = "Type system extensions for programs checked with the mypy type checker." 96 | optional = false 97 | python-versions = ">=3.5" 98 | files = [ 99 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 100 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 101 | ] 102 | 103 | [[package]] 104 | name = "packaging" 105 | version = "23.1" 106 | description = "Core utilities for Python packages" 107 | optional = false 108 | python-versions = ">=3.7" 109 | files = [ 110 | {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, 111 | {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, 112 | ] 113 | 114 | [[package]] 115 | name = "pathspec" 116 | version = "0.11.1" 117 | description = "Utility library for gitignore style pattern matching of file paths." 118 | optional = false 119 | python-versions = ">=3.7" 120 | files = [ 121 | {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, 122 | {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, 123 | ] 124 | 125 | [[package]] 126 | name = "platformdirs" 127 | version = "3.8.0" 128 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 129 | optional = false 130 | python-versions = ">=3.7" 131 | files = [ 132 | {file = "platformdirs-3.8.0-py3-none-any.whl", hash = "sha256:ca9ed98ce73076ba72e092b23d3c93ea6c4e186b3f1c3dad6edd98ff6ffcca2e"}, 133 | {file = "platformdirs-3.8.0.tar.gz", hash = "sha256:b0cabcb11063d21a0b261d557acb0a9d2126350e63b70cdf7db6347baea456dc"}, 134 | ] 135 | 136 | [package.extras] 137 | docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] 138 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)"] 139 | 140 | [metadata] 141 | lock-version = "2.0" 142 | python-versions = "^3.11" 143 | content-hash = "a58ef682e3baae73d110d03f548e12d256d583fc72132bb54e2d57e80c2d1c78" 144 | -------------------------------------------------------------------------------- /tests/fixtures/poetry_project/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "poetry.core.masonry.api" 3 | requires = [ 4 | "poetry-core", 5 | ] 6 | 7 | [tool.poetry] 8 | name = "sample-poetry-project" 9 | version = "0.1.0" 10 | description = "Sample project" 11 | authors = [ "Gabriel Dugny " ] 12 | readme = "README.md" 13 | packages = [ { include = "sample_poetry_project" } ] 14 | 15 | [tool.poetry.dependencies] 16 | python = "^3.11" 17 | 18 | [tool.poetry.group.dev.dependencies] 19 | black = "^23.3.0" 20 | isort = "^5.12.0" 21 | -------------------------------------------------------------------------------- /tests/fixtures/sample_pre_commit_config/pre-commit-config-document-separator.yaml: -------------------------------------------------------------------------------- 1 | 2 | # Many unused lines before document separator 3 | 4 | --- 5 | default_language_version: 6 | python: python3.11 7 | repos: 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v4.4.0 10 | hooks: 11 | - id: check-toml 12 | 13 | - repo: https://github.com/psf/black 14 | rev: 23.2.0 15 | hooks: 16 | - id: black 17 | 18 | - repo: https://github.com/charliermarsh/ruff-pre-commit 19 | rev: 'v0.0.275' 20 | hooks: 21 | - id: ruff 22 | args: [--fix, --exit-non-zero-on-fix] 23 | 24 | - repo: local 25 | hooks: 26 | - id: mypy 27 | name: mypy 28 | entry: mypy 29 | args: [src, tests, --color-output] 30 | language: system 31 | types: [python] 32 | pass_filenames: false 33 | require_serial: true 34 | -------------------------------------------------------------------------------- /tests/fixtures/sample_pre_commit_config/pre-commit-config-only-deps.expected.yaml: -------------------------------------------------------------------------------- 1 | 2 | # Many unused lines before document separator 3 | 4 | --- 5 | default_language_version: 6 | python: python3.11 7 | 8 | repos: 9 | 10 | - repo: https://github.com/pre-commit/mirrors-mypy 11 | # Some comment 12 | rev: v1.5.0 13 | hooks: 14 | - id: mypy 15 | additional_dependencies: 16 | - types-PyYAML==1.2.4 17 | # Some comment 18 | - types-requests==3.4.5 19 | -------------------------------------------------------------------------------- /tests/fixtures/sample_pre_commit_config/pre-commit-config-only-deps.yaml: -------------------------------------------------------------------------------- 1 | 2 | # Many unused lines before document separator 3 | 4 | --- 5 | default_language_version: 6 | python: python3.11 7 | 8 | repos: 9 | 10 | - repo: https://github.com/pre-commit/mirrors-mypy 11 | # Some comment 12 | rev: v1.5.0 13 | hooks: 14 | - id: mypy 15 | additional_dependencies: 16 | - types-PyYAML 17 | # Some comment 18 | - types-requests 19 | -------------------------------------------------------------------------------- /tests/fixtures/sample_pre_commit_config/pre-commit-config-start-empty-lines.yaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | default_language_version: 5 | python: python3.11 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v4.4.0 9 | hooks: 10 | - id: check-toml 11 | - id: trailing-whitespace 12 | - id: check-executables-have-shebangs 13 | - id: debug-statements 14 | - id: end-of-file-fixer 15 | - id: check-added-large-files 16 | - id: check-merge-conflict 17 | - id: fix-byte-order-marker 18 | 19 | - repo: https://github.com/charliermarsh/ruff-pre-commit 20 | # Ruff version. 21 | rev: 'v0.0.277' 22 | hooks: 23 | - id: ruff 24 | args: [--fix, --exit-non-zero-on-fix] 25 | - repo: https://github.com/psf/black 26 | rev: 23.3.0 27 | hooks: 28 | - id: black 29 | 30 | # XXX Fix the issue with documents 31 | -------------------------------------------------------------------------------- /tests/fixtures/sample_pre_commit_config/pre-commit-config-with-deps.expected.yaml: -------------------------------------------------------------------------------- 1 | 2 | # Many unused lines before document separator 3 | 4 | --- 5 | default_language_version: 6 | python: python3.11 7 | 8 | repos: 9 | 10 | - repo: https://github.com/pre-commit/mirrors-mypy 11 | # Some comment 12 | rev: v1.5.0 13 | hooks: 14 | - id: mypy 15 | additional_dependencies: 16 | - types-PyYAML==1.2.4 17 | # Some comment 18 | - types-requests==3.4.5 19 | -------------------------------------------------------------------------------- /tests/fixtures/sample_pre_commit_config/pre-commit-config-with-deps.yaml: -------------------------------------------------------------------------------- 1 | 2 | # Many unused lines before document separator 3 | 4 | --- 5 | default_language_version: 6 | python: python3.11 7 | 8 | repos: 9 | 10 | - repo: https://github.com/pre-commit/mirrors-mypy 11 | # Some comment 12 | rev: v1.0.0 13 | hooks: 14 | - id: mypy 15 | additional_dependencies: 16 | - types-PyYAML==1.2.4 17 | # Some comment 18 | - types-requests 19 | -------------------------------------------------------------------------------- /tests/fixtures/sample_pre_commit_config/pre-commit-config-with-local.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | repos: 4 | - repo: https://github.com/astral-sh/ruff-pre-commit 5 | rev: "v0.0.280" 6 | hooks: 7 | - id: ruff 8 | args: [--fix, --exit-non-zero-on-fix, --show-fixes] 9 | - rev: 23.7.0 10 | repo: https://github.com/psf/black 11 | hooks: 12 | - id: black 13 | - repo: local 14 | hooks: 15 | - id: mypy 16 | name: mypy 17 | entry: mypy 18 | args: [src, tests, --color-output] 19 | language: system 20 | types: [python] 21 | pass_filenames: false 22 | require_serial: true 23 | -------------------------------------------------------------------------------- /tests/fixtures/sample_pre_commit_config/pre-commit-config-with-one-liner-deps.expected.yaml: -------------------------------------------------------------------------------- 1 | 2 | # Many unused lines before document separator 3 | 4 | --- 5 | default_language_version: 6 | python: python3.11 7 | 8 | repos: 9 | 10 | - repo: https://github.com/pre-commit/mirrors-mypy 11 | # Some comment 12 | rev: v1.5.0 13 | hooks: 14 | - id: mypy 15 | additional_dependencies: [types-PyYAML==1.2.4, types-requests==3.4.5] 16 | -------------------------------------------------------------------------------- /tests/fixtures/sample_pre_commit_config/pre-commit-config-with-one-liner-deps.yaml: -------------------------------------------------------------------------------- 1 | 2 | # Many unused lines before document separator 3 | 4 | --- 5 | default_language_version: 6 | python: python3.11 7 | 8 | repos: 9 | 10 | - repo: https://github.com/pre-commit/mirrors-mypy 11 | # Some comment 12 | rev: v1.0.0 13 | hooks: 14 | - id: mypy 15 | additional_dependencies: [types-PyYAML, types-requests] 16 | -------------------------------------------------------------------------------- /tests/fixtures/sample_pre_commit_config/pre-commit-config-without-new-deps.expected.yaml: -------------------------------------------------------------------------------- 1 | 2 | # Many unused lines before document separator 3 | 4 | --- 5 | default_language_version: 6 | python: python3.11 7 | 8 | repos: 9 | 10 | - repo: https://github.com/pre-commit/mirrors-mypy 11 | # Some comment 12 | rev: v1.5.0 13 | hooks: 14 | - id: mypy 15 | additional_dependencies: 16 | - types-PyYAML==1.2.4 17 | # Some comment 18 | - types-requests==3.4.5 19 | -------------------------------------------------------------------------------- /tests/fixtures/sample_pre_commit_config/pre-commit-config-without-new-deps.yaml: -------------------------------------------------------------------------------- 1 | 2 | # Many unused lines before document separator 3 | 4 | --- 5 | default_language_version: 6 | python: python3.11 7 | 8 | repos: 9 | 10 | - repo: https://github.com/pre-commit/mirrors-mypy 11 | # Some comment 12 | rev: v1.0.0 13 | hooks: 14 | - id: mypy 15 | additional_dependencies: 16 | - types-PyYAML==1.2.4 17 | # Some comment 18 | - types-requests==3.4.5 19 | -------------------------------------------------------------------------------- /tests/fixtures/sample_pre_commit_config/pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | default_language_version: 3 | python: python3.11 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v4.4.0 7 | hooks: 8 | - id: check-toml 9 | - id: trailing-whitespace 10 | - id: check-executables-have-shebangs 11 | - id: debug-statements 12 | - id: end-of-file-fixer 13 | - id: check-added-large-files 14 | - id: check-merge-conflict 15 | - id: fix-byte-order-marker 16 | 17 | - repo: https://github.com/charliermarsh/ruff-pre-commit 18 | # Ruff version. 19 | rev: 'v0.0.277' 20 | hooks: 21 | - id: ruff 22 | args: [--fix, --exit-non-zero-on-fix] 23 | - repo: https://github.com/psf/black 24 | rev: 23.3.0 25 | hooks: 26 | - id: black 27 | -------------------------------------------------------------------------------- /tests/fixtures/sample_pre_commit_config/sample-django-stubs.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | default_language_version: 4 | python: python3.11 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.4.0 8 | hooks: 9 | - id: check-yaml 10 | - id: trailing-whitespace 11 | - id: check-executables-have-shebangs 12 | - id: debug-statements 13 | - id: check-merge-conflict 14 | - id: end-of-file-fixer 15 | - repo: https://github.com/astral-sh/ruff-pre-commit 16 | rev: v0.0.276 17 | hooks: 18 | - id: ruff 19 | args: ["--fix", "--exit-non-zero-on-fix"] 20 | - repo: https://github.com/psf/black 21 | rev: 23.3.0 22 | hooks: 23 | - id: black 24 | - repo: https://github.com/PyCQA/flake8 25 | rev: 6.0.0 26 | hooks: 27 | - id: flake8 28 | additional_dependencies: 29 | - flake8-no-pep420==2.6.0 30 | - flake8-pyi==23.5.0 31 | types: [] 32 | files: ^.*.pyi?$ 33 | 34 | ci: 35 | autofix_commit_msg: "[pre-commit.ci] auto fixes from pre-commit.com hooks" 36 | autofix_prs: true 37 | autoupdate_commit_msg: "[pre-commit.ci] pre-commit autoupdate" 38 | autoupdate_schedule: weekly 39 | submodules: false 40 | -------------------------------------------------------------------------------- /tests/test_actions/test_install_hooks.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from pathlib import Path 3 | from unittest.mock import MagicMock, call 4 | 5 | import pytest 6 | from pytest_mock import MockerFixture 7 | 8 | from sync_pre_commit_lock import Printer 9 | from sync_pre_commit_lock.actions.install_hooks import SetupPreCommitHooks 10 | 11 | 12 | class TestSetupPreCommitHooks: 13 | @pytest.fixture() 14 | def printer(self, mocker: MockerFixture) -> MagicMock: 15 | return mocker.MagicMock() 16 | 17 | @pytest.fixture() 18 | def mock_subprocess(self, mocker: MockerFixture) -> MagicMock: 19 | return mocker.patch("subprocess.check_output", autospec=True) 20 | 21 | @pytest.fixture() 22 | def mock_path_exists(self, mocker: MockerFixture) -> MagicMock: 23 | return mocker.patch.object(Path, "exists", autospec=True) 24 | 25 | def test_execute_pre_commit_not_installed(self, printer: Printer, mock_subprocess: MagicMock): 26 | mock_subprocess.return_value.decode.return_value = "fail" 27 | setup = SetupPreCommitHooks(printer, dry_run=False) 28 | setup._is_pre_commit_package_installed = MagicMock(return_value=False) 29 | setup.execute() 30 | assert printer.debug.call_count == 1 31 | assert printer.debug.call_args == call("pre-commit package is not installed (or detected). Skipping.") 32 | 33 | def test_execute_not_in_git_repo(self, printer: MagicMock, mocker: MockerFixture) -> None: 34 | mocker.patch( 35 | "subprocess.check_output", side_effect=subprocess.CalledProcessError(1, "git", b"error", b"output") 36 | ) 37 | mocker.patch("subprocess.check_call", return_value=0) 38 | 39 | setup = SetupPreCommitHooks(printer, dry_run=False) 40 | setup._is_pre_commit_package_installed = MagicMock(return_value=True) 41 | setup.execute() 42 | assert printer.debug.call_count == 3 43 | assert printer.debug.call_args == call("Not in a git repository - can't install hooks. Skipping.") 44 | 45 | def test_execute_pre_commit_hooks_already_installed( 46 | self, printer, mock_subprocess, mock_path_exists, mocker 47 | ) -> None: 48 | mock_subprocess.return_value.decode.return_value = "pre-commit" 49 | mocker.patch("subprocess.check_output", return_value=b"git_path") 50 | mock_path_exists.return_value = True 51 | setup = SetupPreCommitHooks(printer, dry_run=False) 52 | # Mock _is_pre_commit_package_installed 53 | setup._is_pre_commit_package_installed = MagicMock(return_value=True) 54 | setup.execute() 55 | assert printer.debug.call_count == 1 56 | assert printer.debug.call_args == call("pre-commit hooks already installed. Skipping.") 57 | 58 | def test_execute_dry_run(self, printer, mock_subprocess, mock_path_exists, mocker) -> None: 59 | mock_subprocess.return_value.decode.return_value = "pre-commit" 60 | mocker.patch("subprocess.check_output", return_value=b"git_path") 61 | mock_path_exists.return_value = False 62 | setup = SetupPreCommitHooks(printer, dry_run=True) 63 | setup._is_pre_commit_package_installed = MagicMock(return_value=True) 64 | setup.execute() 65 | assert printer.debug.call_count == 1 66 | assert printer.debug.call_args == call("Dry run, skipping pre-commit hook installation.") 67 | 68 | def test_execute_install_hooks(self, printer, mock_subprocess, mock_path_exists, mocker) -> None: 69 | mock_subprocess.return_value.decode.return_value = "pre-commit" 70 | mocker.patch("subprocess.check_output", return_value=b"git_path") 71 | mock_path_exists.return_value = False 72 | mocker.patch("subprocess.check_call", return_value=0) 73 | setup = SetupPreCommitHooks(printer, dry_run=False) 74 | setup._is_pre_commit_package_installed = MagicMock(return_value=True) 75 | setup.execute() 76 | assert printer.info.call_count == 2 77 | printer.info.assert_has_calls( 78 | [call("Installing pre-commit hooks..."), call("pre-commit hooks successfully installed!")] 79 | ) 80 | 81 | def test_install_pre_commit_hooks_success(self, printer, mocker) -> None: 82 | mocked_check_call = mocker.patch("subprocess.check_call", return_value=0) 83 | setup = SetupPreCommitHooks(printer, dry_run=False) 84 | setup._install_pre_commit_hooks() 85 | assert printer.info.call_count == 2 86 | printer.info.assert_has_calls( 87 | [call("Installing pre-commit hooks..."), call("pre-commit hooks successfully installed!")] 88 | ) 89 | mocked_check_call.assert_called_once() 90 | 91 | def test_install_pre_commit_hooks_error(self, printer, mocker) -> None: 92 | mocked_check_call = mocker.patch("subprocess.check_call", side_effect=subprocess.CalledProcessError(1, "cmd")) 93 | setup = SetupPreCommitHooks(printer, dry_run=False) 94 | setup._install_pre_commit_hooks() 95 | assert printer.info.call_count == 1 96 | assert printer.error.call_count == 2 97 | printer.info.assert_has_calls([call("Installing pre-commit hooks...")]) 98 | printer.error.assert_has_calls( 99 | [ 100 | call("Failed to install pre-commit hooks due to an unexpected error"), 101 | call("Command 'cmd' returned non-zero exit status 1."), 102 | ] 103 | ) 104 | mocked_check_call.assert_called_once() 105 | 106 | def test_is_pre_commit_package_installed_true(self, printer, mocker) -> None: 107 | mocked_check_output = mocker.patch("subprocess.check_output", return_value=b"pre-commit 2.9.3") 108 | setup = SetupPreCommitHooks(printer, dry_run=False) 109 | assert setup._is_pre_commit_package_installed() is True 110 | mocked_check_output.assert_called_once() 111 | 112 | def test_is_pre_commit_package_installed_error(self, printer, mocker) -> None: 113 | mocked_check_output = mocker.patch("subprocess.check_output", side_effect=FileNotFoundError()) 114 | setup = SetupPreCommitHooks(printer, dry_run=False) 115 | assert setup._is_pre_commit_package_installed() is False 116 | mocked_check_output.assert_called_once() 117 | 118 | def test_install_pre_commit_hooks_non_zero_return_code(self, printer, mocker) -> None: 119 | mocked_check_call = mocker.patch("subprocess.check_call", return_value=1) 120 | setup = SetupPreCommitHooks(printer, dry_run=False) 121 | setup._install_pre_commit_hooks() 122 | assert printer.info.call_count == 1 123 | assert printer.error.call_count == 1 124 | printer.info.assert_has_calls([call("Installing pre-commit hooks...")]) 125 | printer.error.assert_has_calls([call("Failed to install pre-commit hooks")]) 126 | mocked_check_call.assert_called_once() 127 | -------------------------------------------------------------------------------- /tests/test_actions/test_sync_hooks.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from unittest.mock import MagicMock, patch 3 | 4 | import pytest 5 | 6 | from sync_pre_commit_lock import Printer 7 | from sync_pre_commit_lock.actions.sync_hooks import ( 8 | GenericLockedPackage, 9 | SyncPreCommitHooksVersion, 10 | ) 11 | from sync_pre_commit_lock.config import SyncPreCommitLockConfig 12 | from sync_pre_commit_lock.db import RepoInfo 13 | from sync_pre_commit_lock.pre_commit_config import PreCommitHook, PreCommitHookConfig, PreCommitRepo 14 | 15 | 16 | def test_execute_returns_early_when_disabled() -> None: 17 | printer = MagicMock(spec=Printer) 18 | pre_commit_config_file_path = MagicMock(spec=Path) 19 | locked_packages: dict[str, GenericLockedPackage] = {} 20 | plugin_config: SyncPreCommitLockConfig = MagicMock(spec=SyncPreCommitLockConfig) 21 | plugin_config.disable_sync_from_lock = True 22 | dry_run = False 23 | 24 | syncer = SyncPreCommitHooksVersion( 25 | printer=printer, 26 | pre_commit_config_file_path=pre_commit_config_file_path, 27 | locked_packages=locked_packages, 28 | plugin_config=plugin_config, 29 | dry_run=dry_run, 30 | ) 31 | syncer.execute() 32 | printer.debug.assert_called_once_with("Sync pre-commit lock is disabled") 33 | 34 | 35 | @patch("sync_pre_commit_lock.pre_commit_config.PreCommitHookConfig.from_yaml_file") 36 | @patch.object(SyncPreCommitHooksVersion, "analyze_repos") 37 | def test_execute_returns_early_during_dry_run(mock_analyze_repos: MagicMock, mock_from_yaml_file: MagicMock) -> None: 38 | printer = MagicMock(spec=Printer) 39 | pre_commit_config_file_path = MagicMock(spec=Path) 40 | locked_packages: dict[str, GenericLockedPackage] = {} 41 | plugin_config = MagicMock(spec=SyncPreCommitLockConfig) 42 | plugin_config.disable_sync_from_lock = False 43 | dry_run = True 44 | 45 | syncer = SyncPreCommitHooksVersion( 46 | printer=printer, 47 | pre_commit_config_file_path=pre_commit_config_file_path, 48 | locked_packages=locked_packages, 49 | plugin_config=plugin_config, 50 | dry_run=dry_run, 51 | ) 52 | 53 | # Mocks 54 | pre_commit_config = MagicMock(spec=PreCommitHookConfig) 55 | mock_from_yaml_file.return_value = pre_commit_config 56 | syncer.mapping_reverse_by_url = {"repo1": "somepkg"} 57 | mock_analyze_repos.return_value = {PreCommitRepo("repo1", "rev1"): "rev2"}, {} 58 | 59 | syncer.execute() 60 | 61 | # Assertions 62 | mock_analyze_repos.assert_called_once() 63 | pre_commit_config.update_pre_commit_repo_versions.assert_not_called() 64 | printer.info.assert_called_with("Dry run, skipping pre-commit hook update.") 65 | 66 | 67 | @patch("sync_pre_commit_lock.pre_commit_config.PreCommitHookConfig.from_yaml_file", side_effect=FileNotFoundError()) 68 | def test_execute_handles_file_not_found(mock_from_yaml_file: MagicMock) -> None: 69 | printer = MagicMock(spec=Printer) 70 | pre_commit_config_file_path = MagicMock(spec=Path) 71 | locked_packages: dict[str, GenericLockedPackage] = {} 72 | plugin_config = MagicMock(spec=SyncPreCommitLockConfig) 73 | plugin_config.disable_sync_from_lock = False 74 | dry_run = False 75 | 76 | syncer = SyncPreCommitHooksVersion( 77 | printer=printer, 78 | pre_commit_config_file_path=pre_commit_config_file_path, 79 | locked_packages=locked_packages, 80 | plugin_config=plugin_config, 81 | dry_run=dry_run, 82 | ) 83 | syncer.execute() 84 | printer.info.assert_called_once_with( 85 | f"No pre-commit config file detected at {pre_commit_config_file_path}, skipping sync." 86 | ) 87 | 88 | 89 | @patch("sync_pre_commit_lock.pre_commit_config.PreCommitHookConfig.from_yaml_file", side_effect=ValueError()) 90 | def test_execute_handles_file_invalid(mock_from_yaml_file: MagicMock) -> None: 91 | printer = MagicMock(spec=Printer) 92 | pre_commit_config_file_path = MagicMock(spec=Path) 93 | locked_packages: dict[str, GenericLockedPackage] = {} 94 | plugin_config = MagicMock(spec=SyncPreCommitLockConfig) 95 | plugin_config.disable_sync_from_lock = False 96 | dry_run = False 97 | 98 | syncer = SyncPreCommitHooksVersion( 99 | printer=printer, 100 | pre_commit_config_file_path=pre_commit_config_file_path, 101 | locked_packages=locked_packages, 102 | plugin_config=plugin_config, 103 | dry_run=dry_run, 104 | ) 105 | syncer.execute() 106 | printer.error.assert_called_once_with(f"Invalid pre-commit config file: {pre_commit_config_file_path}: ") 107 | 108 | 109 | @patch("sync_pre_commit_lock.pre_commit_config.PreCommitHookConfig.from_yaml_file") 110 | @patch.object(SyncPreCommitHooksVersion, "analyze_repos") 111 | def test_execute_synchronizes_hooks(mock_analyze_repos: MagicMock, mock_from_yaml_file: MagicMock) -> None: 112 | printer = MagicMock(spec=Printer) 113 | pre_commit_config_file_path = MagicMock(spec=Path) 114 | pre_commit_config_file_path.name = ".pre-commit-config.yaml" 115 | locked_packages: dict[str, GenericLockedPackage] = {} 116 | plugin_config = MagicMock(spec=SyncPreCommitLockConfig) 117 | plugin_config.disable_sync_from_lock = False 118 | dry_run = False 119 | 120 | syncer = SyncPreCommitHooksVersion( 121 | printer=printer, 122 | pre_commit_config_file_path=pre_commit_config_file_path, 123 | locked_packages=locked_packages, 124 | plugin_config=plugin_config, 125 | dry_run=dry_run, 126 | ) 127 | 128 | # Mocks 129 | pre_commit_config = MagicMock(spec=PreCommitHookConfig) 130 | mock_from_yaml_file.return_value = pre_commit_config 131 | syncer.mapping_reverse_by_url = {"repo1": "somepkg"} 132 | mock_analyze_repos.return_value = {PreCommitRepo("repo1", "rev1"): "rev2"}, {} 133 | 134 | syncer.execute() 135 | 136 | # Assertions 137 | mock_analyze_repos.assert_called_once() 138 | pre_commit_config.update_pre_commit_repo_versions.assert_called_once_with({PreCommitRepo("repo1", "rev1"): "rev2"}) 139 | printer.success.assert_called_with("Pre-commit hooks have been updated in .pre-commit-config.yaml!") 140 | 141 | 142 | @patch("sync_pre_commit_lock.pre_commit_config.PreCommitHookConfig.from_yaml_file") 143 | @patch.object(SyncPreCommitHooksVersion, "analyze_repos") 144 | def test_execute_synchronizes_hooks_no_match(mock_analyze_repos: MagicMock, mock_from_yaml_file: MagicMock) -> None: 145 | printer = MagicMock(spec=Printer) 146 | pre_commit_config_file_path = MagicMock(spec=Path) 147 | locked_packages: dict[str, GenericLockedPackage] = {} 148 | plugin_config = MagicMock(spec=SyncPreCommitLockConfig) 149 | plugin_config.disable_sync_from_lock = False 150 | dry_run = False 151 | 152 | syncer = SyncPreCommitHooksVersion( 153 | printer=printer, 154 | pre_commit_config_file_path=pre_commit_config_file_path, 155 | locked_packages=locked_packages, 156 | plugin_config=plugin_config, 157 | dry_run=dry_run, 158 | ) 159 | syncer.mapping = {} 160 | 161 | # Mocks 162 | pre_commit_config = MagicMock(spec=PreCommitHookConfig) 163 | mock_from_yaml_file.return_value = pre_commit_config 164 | mock_analyze_repos.return_value = {}, {} 165 | 166 | syncer.execute() 167 | 168 | # Assertions 169 | mock_analyze_repos.assert_called_once() 170 | pre_commit_config.update_pre_commit_repo_versions.assert_not_called() 171 | printer.info.assert_called_with("No pre-commit hook detected that matches a locked package.") 172 | 173 | 174 | def test_get_pre_commit_repo_new_version() -> None: 175 | printer = MagicMock(spec=Printer) 176 | pre_commit_config_file_path = MagicMock(spec=Path) 177 | locked_packages: dict[str, GenericLockedPackage] = {"lib-name": GenericLockedPackage("lib-name", "2.0.0")} 178 | plugin_config = MagicMock(spec=SyncPreCommitLockConfig) 179 | plugin_config.ignore = [] 180 | syncer = SyncPreCommitHooksVersion( 181 | printer=printer, 182 | pre_commit_config_file_path=pre_commit_config_file_path, 183 | locked_packages=locked_packages, 184 | plugin_config=plugin_config, 185 | ) 186 | pre_commit_config_repo = PreCommitRepo("repo_url", "1.2.3") 187 | syncer.mapping = {"lib-name": {"repo": "repo_url", "rev": "${rev}"}} 188 | 189 | new_version = syncer.get_pre_commit_repo_new_version(pre_commit_config_repo) 190 | 191 | assert new_version == "2.0.0" 192 | 193 | 194 | @patch.object(SyncPreCommitHooksVersion, "get_pre_commit_repo_new_version") 195 | def test_analyze_repos(mock_get_pre_commit_repo_new_version: MagicMock) -> None: 196 | printer = MagicMock(spec=Printer) 197 | pre_commit_config_file_path = MagicMock(spec=Path) 198 | locked_packages: dict[str, GenericLockedPackage] = {"lib-name": GenericLockedPackage("lib-name", "2.0.0")} 199 | plugin_config = MagicMock(spec=SyncPreCommitLockConfig) 200 | 201 | syncer = SyncPreCommitHooksVersion( 202 | printer=printer, 203 | pre_commit_config_file_path=pre_commit_config_file_path, 204 | locked_packages=locked_packages, 205 | plugin_config=plugin_config, 206 | ) 207 | mock_get_pre_commit_repo_new_version.return_value = "2.0.0" 208 | pre_commit_repos = {PreCommitRepo("https://repo_url", "1.2.3")} 209 | syncer.mapping = {"lib-name": {"repo": "https://repo_url", "rev": "${rev}"}} 210 | syncer.mapping_reverse_by_url = {"https://repo_url": "lib-name"} 211 | 212 | to_fix, _ = syncer.analyze_repos(pre_commit_repos) 213 | 214 | assert to_fix == {PreCommitRepo("https://repo_url", "1.2.3"): PreCommitRepo("https://repo_url", "2.0.0")} 215 | 216 | 217 | def test_build_mapping() -> None: 218 | printer = MagicMock(spec=Printer) 219 | pre_commit_config_file_path = MagicMock(spec=Path) 220 | locked_packages: dict[str, GenericLockedPackage] = {} 221 | plugin_config = MagicMock(spec=SyncPreCommitLockConfig) 222 | 223 | syncer = SyncPreCommitHooksVersion( 224 | printer=printer, 225 | pre_commit_config_file_path=pre_commit_config_file_path, 226 | locked_packages=locked_packages, 227 | plugin_config=plugin_config, 228 | ) 229 | plugin_config.dependency_mapping = {"new_lib": {"repo": "new_repo_url", "rev": "${rev}"}} 230 | 231 | assert "new_lib" in syncer.mapping 232 | assert syncer.mapping["new_lib"]["repo"] == "new_repo_url" 233 | assert "new_repo_url" in syncer.mapping_reverse_by_url 234 | assert syncer.mapping_reverse_by_url["new_repo_url"] == "new_lib" 235 | 236 | 237 | def test_get_pre_commit_repo_new_version_ignored() -> None: 238 | printer = MagicMock(spec=Printer) 239 | pre_commit_config_file_path = MagicMock(spec=Path) 240 | locked_packages: dict[str, GenericLockedPackage] = {"lib-name": GenericLockedPackage("lib-name", "2.0.0")} 241 | plugin_config = MagicMock(spec=SyncPreCommitLockConfig) 242 | plugin_config.ignore = ["lib-name"] 243 | 244 | syncer = SyncPreCommitHooksVersion( 245 | printer=printer, 246 | pre_commit_config_file_path=pre_commit_config_file_path, 247 | locked_packages=locked_packages, 248 | plugin_config=plugin_config, 249 | ) 250 | syncer.mapping = {"lib-name": RepoInfo(repo="repo_url", rev="${rev}")} 251 | 252 | pre_commit_config_repo = PreCommitRepo("repo_url", "1.2.3") 253 | 254 | new_version = syncer.get_pre_commit_repo_new_version(pre_commit_config_repo) 255 | 256 | assert new_version is None 257 | 258 | 259 | def test_get_pre_commit_repo_new_version_version_match() -> None: 260 | printer = MagicMock(spec=Printer) 261 | pre_commit_config_file_path = MagicMock(spec=Path) 262 | locked_packages: dict[str, GenericLockedPackage] = {"lib-name": GenericLockedPackage("lib-name", "1.2.3")} 263 | plugin_config = MagicMock(spec=SyncPreCommitLockConfig) 264 | plugin_config.ignore = [] 265 | 266 | syncer = SyncPreCommitHooksVersion( 267 | printer=printer, 268 | pre_commit_config_file_path=pre_commit_config_file_path, 269 | locked_packages=locked_packages, 270 | plugin_config=plugin_config, 271 | ) 272 | 273 | pre_commit_config_repo = PreCommitRepo("repo_url", "1.2.3") 274 | syncer.mapping = {"lib-name": RepoInfo(repo="repo_url", rev="${rev}")} 275 | 276 | new_version = syncer.get_pre_commit_repo_new_version(pre_commit_config_repo) 277 | 278 | assert new_version is None 279 | 280 | 281 | @pytest.mark.parametrize( 282 | "dependency, expected", 283 | [ 284 | pytest.param("dep==1.2.3", "dep==1.2.3", id="same"), 285 | pytest.param("dep", "dep==1.2.3", id="locked"), 286 | pytest.param("other", "other", id="not-in-lock"), 287 | pytest.param("dep<>unparsable", "dep<>unparsable", id="unparsable"), 288 | pytest.param("dep==1.0.0+dev", "dep==1.0.0+dev", id="local"), 289 | pytest.param("Dep", "Dep==1.2.3", id="casing"), 290 | ], 291 | ) 292 | def test_get_pre_commit_repo_hook_new_dependency(dependency: str, expected: str) -> None: 293 | printer = MagicMock(spec=Printer) 294 | pre_commit_config_file_path = MagicMock(spec=Path) 295 | locked_packages: dict[str, GenericLockedPackage] = {"dep": GenericLockedPackage("dep", "1.2.3")} 296 | plugin_config = MagicMock(spec=SyncPreCommitLockConfig) 297 | plugin_config.ignore = [] 298 | 299 | syncer = SyncPreCommitHooksVersion( 300 | printer=printer, 301 | pre_commit_config_file_path=pre_commit_config_file_path, 302 | locked_packages=locked_packages, 303 | plugin_config=plugin_config, 304 | ) 305 | 306 | assert syncer.get_pre_commit_repo_hook_new_dependency(dependency) == expected 307 | 308 | 309 | def test_analyze_repos_repo_not_in_mapping() -> None: 310 | printer = MagicMock(spec=Printer) 311 | pre_commit_config_file_path = MagicMock(spec=Path) 312 | locked_packages: dict[str, GenericLockedPackage] = {} 313 | plugin_config = MagicMock(spec=SyncPreCommitLockConfig) 314 | 315 | syncer = SyncPreCommitHooksVersion( 316 | printer=printer, 317 | pre_commit_config_file_path=pre_commit_config_file_path, 318 | locked_packages=locked_packages, 319 | plugin_config=plugin_config, 320 | ) 321 | 322 | pre_commit_repos = {PreCommitRepo("repo_url", "1.2.3")} 323 | syncer.mapping = {} 324 | 325 | result, _ = syncer.analyze_repos(pre_commit_repos) 326 | 327 | assert result == {} 328 | 329 | 330 | def test_analyze_repos_dependency_not_locked() -> None: 331 | printer = MagicMock(spec=Printer) 332 | pre_commit_config_file_path = MagicMock(spec=Path) 333 | locked_packages: dict[str, GenericLockedPackage] = {} 334 | plugin_config = MagicMock(spec=SyncPreCommitLockConfig) 335 | 336 | syncer = SyncPreCommitHooksVersion( 337 | printer=printer, 338 | pre_commit_config_file_path=pre_commit_config_file_path, 339 | locked_packages=locked_packages, 340 | plugin_config=plugin_config, 341 | ) 342 | 343 | pre_commit_repos = {PreCommitRepo("repo_url", "1.2.3")} 344 | syncer.mapping = {"lib-name": {"repo": "repo_url", "rev": "${rev}"}} 345 | 346 | result, _ = syncer.analyze_repos(pre_commit_repos) 347 | 348 | assert result == {} 349 | 350 | 351 | def test_analyze_repos_no_new_version() -> None: 352 | printer = MagicMock(spec=Printer) 353 | pre_commit_config_file_path = MagicMock(spec=Path) 354 | locked_packages: dict[str, GenericLockedPackage] = {"lib-name": MagicMock(version="1.2.3")} 355 | plugin_config = MagicMock(spec=SyncPreCommitLockConfig) 356 | plugin_config.ignore = [] 357 | 358 | syncer = SyncPreCommitHooksVersion( 359 | printer=printer, 360 | pre_commit_config_file_path=pre_commit_config_file_path, 361 | locked_packages=locked_packages, 362 | plugin_config=plugin_config, 363 | ) 364 | 365 | pre_commit_repos = {PreCommitRepo("repo_url", "1.2.3")} 366 | syncer.mapping = {"lib-name": RepoInfo(repo="repo_url", rev="${rev}")} 367 | 368 | result, _ = syncer.analyze_repos(pre_commit_repos) 369 | 370 | assert result == {} 371 | 372 | 373 | def test_analyze_repos_local() -> None: 374 | printer = MagicMock(spec=Printer) 375 | pre_commit_config_file_path = MagicMock(spec=Path) 376 | locked_packages: dict[str, GenericLockedPackage] = {"lib-name": MagicMock(version="0.1.1+dev")} 377 | plugin_config = MagicMock(spec=SyncPreCommitLockConfig) 378 | plugin_config.ignore = [] 379 | 380 | syncer = SyncPreCommitHooksVersion( 381 | printer=printer, 382 | pre_commit_config_file_path=pre_commit_config_file_path, 383 | locked_packages=locked_packages, 384 | plugin_config=plugin_config, 385 | ) 386 | 387 | pre_commit_repos = {PreCommitRepo("repo_url", "1.2.3")} 388 | syncer.mapping = {"lib-name": RepoInfo(repo="repo_url", rev="${rev}")} 389 | 390 | result, _ = syncer.analyze_repos(pre_commit_repos) 391 | 392 | assert result == {} 393 | 394 | 395 | def test_analyze_repos_additional_dependencies() -> None: 396 | printer = MagicMock(spec=Printer) 397 | pre_commit_config_file_path = MagicMock(spec=Path) 398 | locked_packages: dict[str, GenericLockedPackage] = {"lib-name": GenericLockedPackage("lib-name", "2.0.0")} 399 | plugin_config = SyncPreCommitLockConfig() 400 | 401 | syncer = SyncPreCommitHooksVersion( 402 | printer=printer, 403 | pre_commit_config_file_path=pre_commit_config_file_path, 404 | locked_packages=locked_packages, 405 | plugin_config=plugin_config, 406 | ) 407 | pre_commit_repo = PreCommitRepo("https://repo_url", "1.2.3", [PreCommitHook("hook", ["lib-name==1.2.2"])]) 408 | pre_commit_repos = {pre_commit_repo} 409 | syncer.mapping = {"lib-name": {"repo": "https://repo_url", "rev": "${rev}"}} 410 | 411 | to_fix, _ = syncer.analyze_repos(pre_commit_repos) 412 | 413 | assert to_fix == { 414 | pre_commit_repo: PreCommitRepo("https://repo_url", "2.0.0", [PreCommitHook("hook", ["lib-name==2.0.0"])]) 415 | } 416 | 417 | 418 | def test_analyze_repos_additional_dependencies_preserve_extras() -> None: 419 | printer = MagicMock(spec=Printer) 420 | pre_commit_config_file_path = MagicMock(spec=Path) 421 | locked_packages: dict[str, GenericLockedPackage] = {"lib-name": GenericLockedPackage("lib-name", "2.0.0")} 422 | plugin_config = SyncPreCommitLockConfig() 423 | 424 | syncer = SyncPreCommitHooksVersion( 425 | printer=printer, 426 | pre_commit_config_file_path=pre_commit_config_file_path, 427 | locked_packages=locked_packages, 428 | plugin_config=plugin_config, 429 | ) 430 | pre_commit_repo = PreCommitRepo( 431 | "https://repo_url", "1.2.3", [PreCommitHook("hook", ["lib-name[with,extras]==1.2.2"])] 432 | ) 433 | pre_commit_repos = {pre_commit_repo} 434 | syncer.mapping = {"lib-name": {"repo": "https://repo_url", "rev": "${rev}"}} 435 | 436 | to_fix, _ = syncer.analyze_repos(pre_commit_repos) 437 | 438 | assert to_fix == { 439 | pre_commit_repo: PreCommitRepo( 440 | "https://repo_url", "2.0.0", [PreCommitHook("hook", ["lib-name[extras,with]==2.0.0"])] 441 | ) 442 | } 443 | 444 | 445 | def test_analyze_repos_not_in_lock_but_additional_dependencies() -> None: 446 | printer = MagicMock(spec=Printer) 447 | pre_commit_config_file_path = MagicMock(spec=Path) 448 | locked_packages: dict[str, GenericLockedPackage] = {"lib-name": GenericLockedPackage("lib-name", "2.0.0")} 449 | plugin_config = SyncPreCommitLockConfig() 450 | 451 | syncer = SyncPreCommitHooksVersion( 452 | printer=printer, 453 | pre_commit_config_file_path=pre_commit_config_file_path, 454 | locked_packages=locked_packages, 455 | plugin_config=plugin_config, 456 | ) 457 | pre_commit_repo = PreCommitRepo("https://repo_url", "1.2.3", [PreCommitHook("hook", ["lib-name==1.2.2"])]) 458 | pre_commit_repos = {pre_commit_repo} 459 | syncer.mapping = {"not_lib": {"repo": "https://repo_url", "rev": "${rev}"}} 460 | 461 | to_fix, _ = syncer.analyze_repos(pre_commit_repos) 462 | 463 | assert to_fix == { 464 | pre_commit_repo: PreCommitRepo("https://repo_url", "1.2.3", [PreCommitHook("hook", ["lib-name==2.0.0"])]) 465 | } 466 | 467 | 468 | def test_analyze_repos_local_but_additional_dependencies() -> None: 469 | printer = MagicMock(spec=Printer) 470 | pre_commit_config_file_path = MagicMock(spec=Path) 471 | locked_packages: dict[str, GenericLockedPackage] = { 472 | "lib-name": GenericLockedPackage("lib-name", "2.0.0"), 473 | "local_lib": GenericLockedPackage("local_lib", "1.0.0+dev"), 474 | } 475 | plugin_config = SyncPreCommitLockConfig() 476 | 477 | syncer = SyncPreCommitHooksVersion( 478 | printer=printer, 479 | pre_commit_config_file_path=pre_commit_config_file_path, 480 | locked_packages=locked_packages, 481 | plugin_config=plugin_config, 482 | ) 483 | pre_commit_repo = PreCommitRepo("https://repo_url", "1.2.3", [PreCommitHook("hook", ["lib-name==1.2.2"])]) 484 | pre_commit_repos = {pre_commit_repo} 485 | syncer.mapping = {"local_lib": {"repo": "https://repo_url", "rev": "${rev}"}} 486 | 487 | to_fix, _ = syncer.analyze_repos(pre_commit_repos) 488 | 489 | assert to_fix == { 490 | pre_commit_repo: PreCommitRepo("https://repo_url", "1.2.3", [PreCommitHook("hook", ["lib-name==2.0.0"])]) 491 | } 492 | 493 | 494 | MOCK_DEP_MAPPING = {"dep": {"repo": "https://some.place", "rev": "${dev}"}} 495 | MOCK_REPO_ALIASES = {"https://some.place": ("https://some.old.place",)} 496 | 497 | 498 | @patch("sync_pre_commit_lock.actions.sync_hooks.DEPENDENCY_MAPPING", MOCK_DEP_MAPPING) 499 | @patch("sync_pre_commit_lock.actions.sync_hooks.REPOSITORY_ALIASES", MOCK_REPO_ALIASES) 500 | def test_analyze_repos_renamed() -> None: 501 | printer = MagicMock(spec=Printer) 502 | pre_commit_config_file_path = MagicMock(spec=Path) 503 | locked_packages: dict[str, GenericLockedPackage] = {"ruff": GenericLockedPackage("dep", "1.2.3")} 504 | plugin_config = SyncPreCommitLockConfig() 505 | 506 | syncer = SyncPreCommitHooksVersion( 507 | printer=printer, 508 | pre_commit_config_file_path=pre_commit_config_file_path, 509 | locked_packages=locked_packages, 510 | plugin_config=plugin_config, 511 | ) 512 | pre_commit_repo = PreCommitRepo("https://some.old.place", "1.2.3") 513 | pre_commit_repos = {pre_commit_repo} 514 | 515 | to_fix, _ = syncer.analyze_repos(pre_commit_repos) 516 | 517 | assert to_fix == {pre_commit_repo: PreCommitRepo("https://some.place", "1.2.3")} 518 | 519 | 520 | @patch("sync_pre_commit_lock.actions.sync_hooks.DEPENDENCY_MAPPING", MOCK_DEP_MAPPING) 521 | @patch("sync_pre_commit_lock.actions.sync_hooks.REPOSITORY_ALIASES", MOCK_REPO_ALIASES) 522 | def test_analyze_repos_already_last_url() -> None: 523 | printer = MagicMock(spec=Printer) 524 | pre_commit_config_file_path = MagicMock(spec=Path) 525 | locked_packages: dict[str, GenericLockedPackage] = {"ruff": GenericLockedPackage("dep", "1.2.3")} 526 | plugin_config = SyncPreCommitLockConfig() 527 | 528 | syncer = SyncPreCommitHooksVersion( 529 | printer=printer, 530 | pre_commit_config_file_path=pre_commit_config_file_path, 531 | locked_packages=locked_packages, 532 | plugin_config=plugin_config, 533 | ) 534 | pre_commit_repo = PreCommitRepo("https://some.place", "1.2.3") 535 | pre_commit_repos = {pre_commit_repo} 536 | 537 | to_fix, _ = syncer.analyze_repos(pre_commit_repos) 538 | 539 | assert to_fix == {} 540 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | import pytest 4 | 5 | from sync_pre_commit_lock.config import SyncPreCommitLockConfig, from_toml, load_config, update_from_env 6 | from sync_pre_commit_lock.db import RepoInfo 7 | 8 | 9 | def test_from_toml() -> None: 10 | data = { 11 | "disable-sync-from-lock": True, 12 | "ignore": ["a", "b"], 13 | "pre-commit-config-file": ".test-config.yaml", 14 | "dependency-mapping": {"pytest": {"repo": "pytest", "rev": "${ver}"}}, 15 | } 16 | expected_config = SyncPreCommitLockConfig( 17 | disable_sync_from_lock=True, 18 | ignore=["a", "b"], 19 | pre_commit_config_file=".test-config.yaml", 20 | dependency_mapping={"pytest": RepoInfo(repo="pytest", rev="${ver}")}, 21 | ) 22 | 23 | actual_config = from_toml(data) 24 | 25 | assert actual_config == expected_config 26 | 27 | 28 | def test_update_from_env(monkeypatch: pytest.MonkeyPatch) -> None: 29 | monkeypatch.setenv("SYNC_PRE_COMMIT_LOCK_DISABLED", "1") 30 | monkeypatch.setenv("SYNC_PRE_COMMIT_LOCK_INSTALL", "false") 31 | monkeypatch.setenv("SYNC_PRE_COMMIT_LOCK_IGNORE", "a, b") 32 | monkeypatch.setenv("SYNC_PRE_COMMIT_LOCK_PRE_COMMIT_FILE", ".test-config.yaml") 33 | expected_config = SyncPreCommitLockConfig( 34 | automatically_install_hooks=False, 35 | disable_sync_from_lock=True, 36 | ignore=["a", "b"], 37 | pre_commit_config_file=".test-config.yaml", 38 | dependency_mapping={}, 39 | ) 40 | 41 | actual_config = update_from_env(SyncPreCommitLockConfig()) 42 | 43 | assert actual_config == expected_config 44 | 45 | 46 | def test_sync_pre_commit_lock_config() -> None: 47 | config = SyncPreCommitLockConfig( 48 | disable_sync_from_lock=True, 49 | ignore=["a", "b"], 50 | pre_commit_config_file=".test-config.yaml", 51 | dependency_mapping={"pytest": RepoInfo(repo="pytest", rev="${ver}")}, 52 | ) 53 | 54 | assert config.disable_sync_from_lock is True 55 | assert config.ignore == ["a", "b"] 56 | assert config.pre_commit_config_file == ".test-config.yaml" 57 | assert config.dependency_mapping == {"pytest": {"repo": "pytest", "rev": "${ver}"}} 58 | 59 | 60 | @patch("sync_pre_commit_lock.config.toml.load", return_value={"tool": {"sync-pre-commit-lock": {}}}) 61 | @patch("builtins.open", new_callable=MagicMock) 62 | def test_load_config_with_empty_tool_dict(mock_open: MagicMock, mock_load: MagicMock) -> None: 63 | expected_config = SyncPreCommitLockConfig() 64 | mock_path = MagicMock() 65 | mock_path.open = mock_open(read_data="dummy_stream") 66 | actual_config = load_config(mock_path) 67 | 68 | assert actual_config == expected_config 69 | mock_path.open.assert_called_once_with("rb") 70 | mock_load.assert_called_once() 71 | 72 | 73 | @patch("sync_pre_commit_lock.config.toml.load", return_value={"tool": {"sync-pre-commit-lock": {"disable": True}}}) 74 | @patch("builtins.open", new_callable=MagicMock) 75 | @patch("sync_pre_commit_lock.config.from_toml", return_value=SyncPreCommitLockConfig(disable_sync_from_lock=True)) 76 | def test_load_config_with_data(mock_from_toml: MagicMock, mock_open: MagicMock, mock_load: MagicMock) -> None: 77 | expected_config = SyncPreCommitLockConfig(disable_sync_from_lock=True) 78 | mock_path = MagicMock() 79 | mock_path.open = mock_open(read_data="dummy_stream") 80 | actual_config = load_config(mock_path) 81 | 82 | assert actual_config == expected_config 83 | mock_path.open.assert_called_once_with("rb") 84 | mock_load.assert_called_once() 85 | mock_from_toml.assert_called_once_with({"disable": True}) 86 | 87 | 88 | @patch("sync_pre_commit_lock.config.toml.load", return_value={"tool": {"sync-pre-commit-lock": {"ignore": ["fake"]}}}) 89 | @patch("builtins.open", new_callable=MagicMock) 90 | def test_env_override_config(mock_open: MagicMock, mock_load: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: 91 | monkeypatch.setenv("SYNC_PRE_COMMIT_LOCK_DISABLED", "true") 92 | monkeypatch.setenv("SYNC_PRE_COMMIT_LOCK_IGNORE", "a, b") 93 | expected_config = SyncPreCommitLockConfig( 94 | disable_sync_from_lock=True, 95 | ignore=["a", "b"], 96 | ) 97 | mock_path = MagicMock() 98 | mock_path.open = mock_open(read_data="dummy_stream") 99 | actual_config = load_config(mock_path) 100 | 101 | assert actual_config == expected_config 102 | mock_path.open.assert_called_once_with("rb") 103 | -------------------------------------------------------------------------------- /tests/test_db.py: -------------------------------------------------------------------------------- 1 | from sync_pre_commit_lock.db import DEPENDENCY_MAPPING 2 | from sync_pre_commit_lock.utils import normalize_git_url 3 | 4 | 5 | def test_all_urls_already_normalized() -> None: 6 | for repos in DEPENDENCY_MAPPING.values(): 7 | assert normalize_git_url(repos["repo"]) == repos["repo"] 8 | -------------------------------------------------------------------------------- /tests/test_pdm/test_pdm_integration.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import shutil 4 | from typing import TYPE_CHECKING 5 | 6 | import pytest 7 | 8 | from sync_pre_commit_lock import PRE_COMMIT_CONFIG_FILENAME 9 | 10 | pytest.importorskip("pdm") 11 | 12 | if TYPE_CHECKING: 13 | from pathlib import Path 14 | 15 | from pdm.project import Project 16 | from pdm.pytest import PDMCallable 17 | 18 | 19 | @pytest.fixture 20 | def project(project: Project, fixtures: Path) -> Project: 21 | shutil.copy(fixtures / "pdm_project" / PRE_COMMIT_CONFIG_FILENAME, project.root) 22 | 23 | return project 24 | 25 | 26 | def test_pdm_lock(pdm: PDMCallable, project: Project): 27 | project.pyproject.settings["dev-dependencies"] = {"lint": ["ruff"]} 28 | project.pyproject.write() 29 | 30 | pdm("lock -v", obj=project, strict=True) 31 | 32 | pre_commit_config = (project.root / PRE_COMMIT_CONFIG_FILENAME).read_text() 33 | 34 | assert "rev: v" in pre_commit_config 35 | assert "rev: v0.1.0" not in pre_commit_config 36 | 37 | 38 | def test_pdm_install(pdm: PDMCallable, project: Project): 39 | # Needed by pdm 2.7 40 | # See: https://github.com/pdm-project/pdm/issues/917 41 | project.pyproject.metadata["requires-python"] = ">=3.9" 42 | project.pyproject.write() 43 | pdm("add ruff==0.6.7 -v", obj=project, strict=True) 44 | 45 | pre_commit_config = (project.root / PRE_COMMIT_CONFIG_FILENAME).read_text() 46 | 47 | assert "rev: v0.6.7" in pre_commit_config 48 | -------------------------------------------------------------------------------- /tests/test_pdm/test_pdm_plugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from unittest import mock 5 | 6 | import pytest 7 | 8 | pdm_module = pytest.importorskip("pdm") 9 | # ruff: noqa: E402 10 | from pdm.core import Core 11 | from pdm.project import Project 12 | from pdm.termui import UI 13 | 14 | from sync_pre_commit_lock.config import SyncPreCommitLockConfig 15 | from sync_pre_commit_lock.pdm_plugin import ( 16 | PDMPrinter, 17 | PDMSetupPreCommitHooks, 18 | ) 19 | from sync_pre_commit_lock.pre_commit_config import PreCommitHook, PreCommitRepo 20 | 21 | # Create the mock objects 22 | 23 | 24 | @pytest.fixture() 25 | def project() -> Project: 26 | x = mock.MagicMock(spec=Project) 27 | x.root = mock.MagicMock(spec=Path) 28 | x.core = mock.MagicMock(spec=Core) 29 | x.core.ui = mock.MagicMock(spec=UI) 30 | return x 31 | 32 | 33 | config_mock = mock.create_autospec(SyncPreCommitLockConfig, instance=True) 34 | printer_mock = mock.create_autospec(PDMPrinter, instance=True) 35 | action_mock = mock.create_autospec(PDMSetupPreCommitHooks, instance=True) 36 | 37 | 38 | def test_on_pdm_install_setup_pre_commit_auto_install_disabled(project: mock.MagicMock) -> None: 39 | config_mock.automatically_install_hooks = False 40 | with ( 41 | mock.patch("sync_pre_commit_lock.pdm_plugin.PDMPrinter", return_value=printer_mock), 42 | mock.patch("sync_pre_commit_lock.pdm_plugin.load_config", return_value=config_mock), 43 | ): 44 | from sync_pre_commit_lock.pdm_plugin import on_pdm_install_setup_pre_commit 45 | 46 | on_pdm_install_setup_pre_commit(project, dry_run=False) 47 | printer_mock.debug.assert_any_call("Automatically installing pre-commit hooks is disabled. Skipping.") 48 | 49 | 50 | def test_on_pdm_install_setup_pre_commit_no_config_file(tmp_path: Path, project: Project) -> None: 51 | config_mock.automatically_install_hooks = True 52 | config_mock.pre_commit_config_file = SyncPreCommitLockConfig.pre_commit_config_file 53 | project.root = tmp_path 54 | with ( 55 | mock.patch("sync_pre_commit_lock.pdm_plugin.PDMPrinter", return_value=printer_mock), 56 | mock.patch("sync_pre_commit_lock.pdm_plugin.load_config", return_value=config_mock), 57 | ): 58 | from sync_pre_commit_lock.pdm_plugin import on_pdm_install_setup_pre_commit 59 | 60 | on_pdm_install_setup_pre_commit(project, dry_run=False) 61 | printer_mock.info.assert_called_once_with("No pre-commit config file found, skipping pre-commit hook check") 62 | 63 | 64 | def test_on_pdm_install_setup_pre_commit_success(project: Project) -> None: 65 | config_mock.automatically_install_hooks = True 66 | config_mock.pre_commit_config_file = SyncPreCommitLockConfig.pre_commit_config_file 67 | project.root = ( 68 | Path(__file__).parent.parent / "fixtures" / "poetry_project" 69 | ) # Assuming config file exists at this path 70 | with ( 71 | mock.patch("sync_pre_commit_lock.pdm_plugin.load_config", return_value=config_mock), 72 | mock.patch("sync_pre_commit_lock.pdm_plugin.PDMSetupPreCommitHooks", return_value=action_mock), 73 | ): 74 | from sync_pre_commit_lock.pdm_plugin import on_pdm_install_setup_pre_commit 75 | 76 | on_pdm_install_setup_pre_commit(project, dry_run=False) 77 | 78 | action_mock.execute.assert_called_once() 79 | 80 | 81 | def test_pdm_printer_list_success(capsys: pytest.CaptureFixture[str]) -> None: 82 | printer = PDMPrinter(UI()) 83 | 84 | printer.list_updated_packages( 85 | { 86 | "package": ( 87 | PreCommitRepo("https://repo1.local/test", "rev1", [PreCommitHook("hook")]), 88 | PreCommitRepo("https://repo1.local/test", "rev2", [PreCommitHook("hook")]), 89 | ) 90 | } 91 | ) 92 | captured = capsys.readouterr() 93 | 94 | assert "[sync-pre-commit-lock] ✔ https://repo1.local/test rev1 -> rev2" in captured.out 95 | 96 | 97 | def test_pdm_printer_list_success_additional_dependency(capsys: pytest.CaptureFixture[str]) -> None: 98 | printer = PDMPrinter(UI()) 99 | 100 | printer.list_updated_packages( 101 | { 102 | "package": ( 103 | PreCommitRepo("https://repo1.local/test", "rev1", [PreCommitHook("hook", ["dep"])]), 104 | PreCommitRepo("https://repo1.local/test", "rev1", [PreCommitHook("hook", ["dep==0.1.2"])]), 105 | ) 106 | } 107 | ) 108 | captured = capsys.readouterr() 109 | 110 | assert "[sync-pre-commit-lock] ✔ https://repo1.local/test" in captured.out 111 | assert "[sync-pre-commit-lock] └ hook" in captured.out 112 | assert "[sync-pre-commit-lock] └ dep * -> 0.1.2" in captured.out 113 | 114 | 115 | def test_pdm_printer_list_success_repo_with_multiple_hooks_and_additional_dependency( 116 | capsys: pytest.CaptureFixture[str], 117 | ) -> None: 118 | printer = PDMPrinter(UI()) 119 | 120 | printer.list_updated_packages( 121 | { 122 | "package": ( 123 | PreCommitRepo( 124 | repo="https://repo1.local/test", 125 | rev="rev1", 126 | hooks=[ 127 | PreCommitHook("1st-hook", ["dep==0.1.2", "other==0.42"]), 128 | PreCommitHook("2nd-hook", ["dep", "other>=0.42"]), 129 | ], 130 | ), 131 | PreCommitRepo( 132 | repo="https://repo1.local/test", 133 | rev="rev2", 134 | hooks=[ 135 | PreCommitHook("1st-hook", ["dep==0.1.2", "other==3.4.5"]), 136 | PreCommitHook("2st-hook", ["dep==0.1.2", "other==3.4.5"]), 137 | ], 138 | ), 139 | ) 140 | } 141 | ) 142 | captured = capsys.readouterr() 143 | 144 | assert "[sync-pre-commit-lock] ✔ https://repo1.local/test rev1 -> rev2" in captured.out 145 | assert "[sync-pre-commit-lock] ├ 1st-hook" in captured.out 146 | assert "[sync-pre-commit-lock] │ └ other 0.42 -> 3.4.5" in captured.out 147 | assert "[sync-pre-commit-lock] └ 2nd-hook" in captured.out 148 | assert "[sync-pre-commit-lock] ├ dep * -> 0.1.2" in captured.out 149 | assert "[sync-pre-commit-lock] └ other >=0.42 -> 3.4.5" in captured.out 150 | 151 | 152 | def test_pdm_printer_list_success_renamed_repository(capsys: pytest.CaptureFixture[str]) -> None: 153 | printer = PDMPrinter(UI()) 154 | 155 | printer.list_updated_packages( 156 | { 157 | "package": ( 158 | PreCommitRepo("https://old.repo.local/test", "rev1", [PreCommitHook("hook")]), 159 | PreCommitRepo("https://new.repo.local/test", "rev2", [PreCommitHook("hook")]), 160 | ), 161 | } 162 | ) 163 | captured = capsys.readouterr() 164 | 165 | assert "[sync-pre-commit-lock] ✔ https://{old -> new}.repo.local/test rev1 -> rev2" in captured.out 166 | -------------------------------------------------------------------------------- /tests/test_pdm/test_pdm_sync_pre_commit_hook.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING 5 | from unittest.mock import MagicMock, patch 6 | 7 | import pytest 8 | from packaging.version import Version 9 | 10 | pdm_module = pytest.importorskip("pdm") 11 | # ruff: noqa: E402 12 | from pdm.__version__ import __version__ as pdm_version 13 | from pdm.core import Core 14 | from pdm.models.candidates import Candidate 15 | from pdm.models.requirements import NamedRequirement 16 | from pdm.project import Project 17 | from pdm.termui import UI 18 | 19 | from sync_pre_commit_lock import ( 20 | Printer, 21 | ) 22 | from sync_pre_commit_lock.config import SyncPreCommitLockConfig 23 | from sync_pre_commit_lock.pdm_plugin import on_pdm_lock_check_pre_commit, register_pdm_plugin 24 | 25 | if TYPE_CHECKING: 26 | from sync_pre_commit_lock.pdm_plugin import Resolution 27 | 28 | 29 | @pytest.fixture() 30 | def project() -> Project: 31 | x = MagicMock(spec=Project) 32 | x.root = MagicMock(spec=Path) 33 | x.core = MagicMock(spec=Core) 34 | x.core.ui = MagicMock(spec=UI) 35 | return x 36 | 37 | 38 | @pytest.fixture() 39 | def printer() -> Printer: 40 | x = MagicMock(spec=Printer) 41 | x.debug = MagicMock() 42 | x.info = MagicMock() 43 | x.warning = MagicMock() 44 | x.error = MagicMock() 45 | return x 46 | 47 | 48 | @pytest.fixture 49 | def resolution() -> Resolution: 50 | """ 51 | Mock resolution depending on pdm version 52 | """ 53 | candidate = Candidate(NamedRequirement("some-library"), "1.0.0", "https://example.com/some-library") 54 | return {"some-library": [candidate] if Version(pdm_version) >= Version("2.17") else candidate} 55 | 56 | 57 | def test_register_pdm_plugin(project: Project) -> None: 58 | core = project.core 59 | register_pdm_plugin(core) 60 | # As function has no implementation currently, nothing to assert 61 | assert core.ui.echo.call_count == 1 62 | 63 | 64 | @patch("sync_pre_commit_lock.pdm_plugin.load_config") 65 | def test_on_pdm_lock_check_pre_commit(mock_load_config: MagicMock, project: MagicMock, resolution: Resolution) -> None: 66 | mock_load_config.return_value = SyncPreCommitLockConfig(disable_sync_from_lock=True) 67 | on_pdm_lock_check_pre_commit(project, dry_run=False, resolution=resolution) 68 | mock_load_config.assert_called_once() 69 | -------------------------------------------------------------------------------- /tests/test_poetry/test_poetry_plugin.py: -------------------------------------------------------------------------------- 1 | import re 2 | from unittest.mock import MagicMock, patch 3 | 4 | import pytest 5 | 6 | poetry_module = pytest.importorskip("poetry") 7 | # ruff: noqa: E402 8 | from cleo.events.console_terminate_event import ConsoleTerminateEvent 9 | from poetry.console.application import Application 10 | from poetry.console.commands.install import InstallCommand 11 | from poetry.console.commands.lock import LockCommand 12 | from poetry.console.commands.self.self_command import SelfCommand 13 | 14 | from sync_pre_commit_lock.poetry_plugin import SyncPreCommitLockPlugin, SyncPreCommitPoetryCommand 15 | from sync_pre_commit_lock.pre_commit_config import PreCommitHook, PreCommitRepo 16 | 17 | 18 | def test_activate() -> None: 19 | application = MagicMock() 20 | plugin = SyncPreCommitLockPlugin() 21 | 22 | plugin.activate(application) 23 | 24 | application.event_dispatcher.add_listener.assert_called_once() 25 | 26 | 27 | def test_handle_post_command_exit_code_not_zero() -> None: 28 | event = MagicMock(spec=ConsoleTerminateEvent, exit_code=1) 29 | event_name = "event_name" 30 | dispatcher = MagicMock() 31 | 32 | plugin = SyncPreCommitLockPlugin() 33 | 34 | plugin._handle_post_command(event, event_name, dispatcher) 35 | 36 | event.io.write_line.assert_not_called() 37 | 38 | 39 | @patch("sync_pre_commit_lock.poetry_plugin.PoetrySetupPreCommitHooks.execute") 40 | @patch("sync_pre_commit_lock.config.toml.load", return_value={"tool": {"sync-pre-commit-lock": {}}}) 41 | def test_handle_post_command_install_add_commands(mocked_execute: MagicMock, mock_load: MagicMock) -> None: 42 | event = MagicMock( 43 | spec=ConsoleTerminateEvent, 44 | exit_code=0, 45 | command=MagicMock(spec=InstallCommand, option=MagicMock(return_value=True)), 46 | ) 47 | event_name = "event_name" 48 | dispatcher = MagicMock() 49 | 50 | plugin = SyncPreCommitLockPlugin() 51 | plugin.application = MagicMock(spec=Application, instance=True) 52 | plugin._handle_post_command(event, event_name, dispatcher) 53 | 54 | mocked_execute.assert_called_once() 55 | 56 | 57 | def test_handle_post_command_self_command() -> None: 58 | event = MagicMock(spec=ConsoleTerminateEvent, exit_code=0, command=MagicMock(spec=SelfCommand)) 59 | event_name = "event_name" 60 | dispatcher = MagicMock() 61 | 62 | plugin = SyncPreCommitLockPlugin() 63 | 64 | plugin._handle_post_command(event, event_name, dispatcher) 65 | 66 | event.io.write_line.assert_called_once() 67 | 68 | 69 | @patch("sync_pre_commit_lock.poetry_plugin.SyncPreCommitHooksVersion.execute") 70 | @patch("sync_pre_commit_lock.config.toml.load", return_value={"tool": {"sync-pre-commit-lock": {}}}) 71 | def test_handle_post_command_install_add_lock_update_commands(mocked_execute: MagicMock, mock_load: MagicMock) -> None: 72 | event = MagicMock( 73 | spec=ConsoleTerminateEvent, 74 | exit_code=0, 75 | command=MagicMock(spec=LockCommand, option=MagicMock(return_value=True)), 76 | ) 77 | event_name = "event_name" 78 | dispatcher = MagicMock() 79 | 80 | plugin = SyncPreCommitLockPlugin() 81 | plugin.application = MagicMock() 82 | plugin.application.poetry.locker.locked_repository.return_value.packages = [MagicMock()] 83 | 84 | plugin._handle_post_command(event, event_name, dispatcher) 85 | 86 | mocked_execute.assert_called_once() 87 | 88 | 89 | def test_handle_post_command_application_none() -> None: 90 | event = MagicMock( 91 | spec=ConsoleTerminateEvent, 92 | exit_code=0, 93 | command=MagicMock(spec=LockCommand, option=MagicMock(return_value=True)), 94 | ) 95 | event_name = "event_name" 96 | dispatcher = MagicMock() 97 | 98 | plugin = SyncPreCommitLockPlugin() 99 | # As if the plugin was not activated 100 | plugin.application = None 101 | 102 | try: 103 | plugin._handle_post_command(event, event_name, dispatcher) 104 | except RuntimeError: 105 | assert True 106 | else: 107 | pytest.fail("RuntimeError not raised") 108 | 109 | 110 | def test_poetry_printer_list_success(capsys: pytest.CaptureFixture[str]) -> None: 111 | from cleo.io.inputs.input import Input 112 | from cleo.io.io import IO 113 | from cleo.io.outputs.output import Output 114 | 115 | from sync_pre_commit_lock.poetry_plugin import PoetryPrinter 116 | 117 | output = Output() 118 | 119 | def _write(message: str, new_line: bool = False): 120 | print(message) # noqa: T201 121 | 122 | output._write = _write 123 | printer = PoetryPrinter(IO(input=Input(), output=output, error_output=output)) 124 | 125 | printer.list_updated_packages( 126 | { 127 | "package": ( 128 | PreCommitRepo("https://repo1.local/test", "rev1", [PreCommitHook("hook")]), 129 | PreCommitRepo("https://repo1.local/test", "rev2", [PreCommitHook("hook")]), 130 | ) 131 | } 132 | ) 133 | captured = capsys.readouterr() 134 | # Remove all <..> tags, as we don't have the real parser 135 | out = re.sub(r"<[^>]*>", "", captured.out) 136 | 137 | assert "[sync-pre-commit-lock] • https://repo1.local/test rev1 -> rev2" in out 138 | 139 | 140 | def test_poetry_printer_list_success_additional_dependency(capsys: pytest.CaptureFixture[str]) -> None: 141 | from cleo.io.inputs.input import Input 142 | from cleo.io.io import IO 143 | from cleo.io.outputs.output import Output 144 | 145 | from sync_pre_commit_lock.poetry_plugin import PoetryPrinter 146 | 147 | output = Output() 148 | 149 | def _write(message: str, new_line: bool = False): 150 | print(message) # noqa: T201 151 | 152 | output._write = _write 153 | printer = PoetryPrinter(IO(input=Input(), output=output, error_output=output)) 154 | 155 | printer.list_updated_packages( 156 | { 157 | "package": ( 158 | PreCommitRepo("https://repo1.local/test", "rev1", [PreCommitHook("hook", ["dep"])]), 159 | PreCommitRepo("https://repo1.local/test", "rev1", [PreCommitHook("hook", ["dep==0.1.2"])]), 160 | ) 161 | } 162 | ) 163 | captured = capsys.readouterr() 164 | # Remove all <..> tags, as we don't have the real parser 165 | out = re.sub(r"<[^>]*>", "", captured.out) 166 | 167 | assert "[sync-pre-commit-lock] • https://repo1.local/test" in out 168 | assert "[sync-pre-commit-lock] └ hook" in out 169 | assert "[sync-pre-commit-lock] └ dep * -> 0.1.2" in out 170 | 171 | 172 | def test_poetry_printer_list_success_with_multiple_hooks_and_additional_dependency( 173 | capsys: pytest.CaptureFixture[str], 174 | ) -> None: 175 | from cleo.io.inputs.input import Input 176 | from cleo.io.io import IO 177 | from cleo.io.outputs.output import Output 178 | 179 | from sync_pre_commit_lock.poetry_plugin import PoetryPrinter 180 | 181 | output = Output() 182 | 183 | def _write(message: str, new_line: bool = False): 184 | print(message) # noqa: T201 185 | 186 | output._write = _write 187 | printer = PoetryPrinter(IO(input=Input(), output=output, error_output=output)) 188 | 189 | printer.list_updated_packages( 190 | { 191 | "package": ( 192 | PreCommitRepo( 193 | repo="https://repo1.local/test", 194 | rev="rev1", 195 | hooks=[ 196 | PreCommitHook("1st-hook", ["dep==0.1.2", "other==0.42"]), 197 | PreCommitHook("2nd-hook", ["dep", "other>=0.42"]), 198 | ], 199 | ), 200 | PreCommitRepo( 201 | repo="https://repo1.local/test", 202 | rev="rev2", 203 | hooks=[ 204 | PreCommitHook("1st-hook", ["dep==0.1.2", "other==3.4.5"]), 205 | PreCommitHook("2st-hook", ["dep==0.1.2", "other==3.4.5"]), 206 | ], 207 | ), 208 | ) 209 | } 210 | ) 211 | captured = capsys.readouterr() 212 | # Remove all <..> tags, as we don't have the real parser 213 | out = re.sub(r"<[^>]*>", "", captured.out) 214 | 215 | assert "[sync-pre-commit-lock] • https://repo1.local/test rev1 -> rev2" in out 216 | assert "[sync-pre-commit-lock] ├ 1st-hook" in out 217 | assert "[sync-pre-commit-lock] │ └ other 0.42 -> 3.4.5" in out 218 | assert "[sync-pre-commit-lock] └ 2nd-hook" in out 219 | assert "[sync-pre-commit-lock] ├ dep * -> 0.1.2" in out 220 | assert "[sync-pre-commit-lock] └ other >=0.42 -> 3.4.5" in out 221 | 222 | 223 | def test_poetry_printer_list_success_renamed_repository(capsys: pytest.CaptureFixture[str]) -> None: 224 | from cleo.io.inputs.input import Input 225 | from cleo.io.io import IO 226 | from cleo.io.outputs.output import Output 227 | 228 | from sync_pre_commit_lock.poetry_plugin import PoetryPrinter 229 | 230 | output = Output() 231 | 232 | def _write(message: str, new_line: bool = False): 233 | print(message) # noqa: T201 234 | 235 | output._write = _write 236 | printer = PoetryPrinter(IO(input=Input(), output=output, error_output=output)) 237 | 238 | printer.list_updated_packages( 239 | { 240 | "package": ( 241 | PreCommitRepo("https://old.repo.local/test", "rev1", [PreCommitHook("hook")]), 242 | PreCommitRepo("https://new.repo.local/test", "rev2", [PreCommitHook("hook")]), 243 | ), 244 | } 245 | ) 246 | captured = capsys.readouterr() 247 | # Remove all <..> tags, as we don't have the real parser 248 | out = re.sub(r"<[^>]*>", "", captured.out) 249 | 250 | assert "[sync-pre-commit-lock] • https://{old -> new}.repo.local/test rev1 -> rev2" in out 251 | 252 | 253 | def test_direct_command_invocation(): 254 | with pytest.raises(RuntimeError, match="self.application is None"): 255 | SyncPreCommitPoetryCommand().handle() 256 | -------------------------------------------------------------------------------- /tests/test_pre_commit_config_file.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from unittest.mock import MagicMock, mock_open 3 | 4 | import pytest 5 | import yaml 6 | from strictyaml.exceptions import YAMLValidationError 7 | 8 | from sync_pre_commit_lock.pre_commit_config import PreCommitHook, PreCommitHookConfig, PreCommitRepo 9 | 10 | 11 | def test_pre_commit_hook_config_initialization() -> None: 12 | data = {"repos": [{"repo": "repo1", "rev": "rev1"}]} 13 | path = Path("dummy_path") 14 | config = PreCommitHookConfig(yaml.dump(data), path) 15 | 16 | assert config.data == data 17 | assert config.pre_commit_config_file_path == path 18 | 19 | 20 | def test_from_yaml_file() -> None: 21 | file_data = "repos:\n- repo: repo1\n rev: rev1\n" 22 | mock_path = MagicMock(spec=Path) 23 | mock_path.open = mock_open(read_data=file_data) 24 | 25 | config = PreCommitHookConfig.from_yaml_file(mock_path) 26 | 27 | mock_path.open.assert_called_once_with("r") 28 | assert config.data == {"repos": [{"repo": "repo1", "rev": "rev1"}]} 29 | assert config.pre_commit_config_file_path == mock_path 30 | assert config.original_file_lines == file_data.splitlines(keepends=True) 31 | 32 | 33 | def test_from_yaml_file_invalid() -> None: 34 | mock_path = MagicMock(spec=Path) 35 | mock_path.open = mock_open(read_data="dummy_stream") 36 | 37 | with pytest.raises(YAMLValidationError, match="when expecting a mapping"): 38 | PreCommitHookConfig.from_yaml_file(mock_path) 39 | 40 | mock_path.open.assert_called_once_with("r") 41 | 42 | 43 | def test_repos_property() -> None: 44 | data = {"repos": [{"repo": "https://repo1.local:443/test", "rev": "rev1"}]} 45 | path = Path("dummy_path") 46 | config = PreCommitHookConfig(yaml.dump(data), path) 47 | 48 | assert config.repos[0].repo == "https://repo1.local:443/test" 49 | assert config.repos[0].rev == "rev1" 50 | assert config.repos_normalized == {PreCommitRepo("https://repo1.local/test", "rev1")} 51 | 52 | 53 | FIXTURES = Path(__file__).parent / "fixtures" / "sample_pre_commit_config" 54 | 55 | 56 | @pytest.mark.parametrize( 57 | ("path", "offset"), 58 | [ 59 | (FIXTURES / "pre-commit-config-document-separator.yaml", 4), 60 | (FIXTURES / "pre-commit-config-start-empty-lines.yaml", 0), 61 | (FIXTURES / "pre-commit-config-with-local.yaml", 2), 62 | (FIXTURES / "pre-commit-config.yaml", 1), 63 | (FIXTURES / "sample-django-stubs.yaml", 0), 64 | ], 65 | ) 66 | def test_files_offset(path: Path, offset: int) -> None: 67 | config = PreCommitHookConfig.from_yaml_file(path) 68 | assert config.document_start_offset == offset 69 | 70 | 71 | def test_update_versions() -> None: 72 | config = PreCommitHookConfig.from_yaml_file(FIXTURES / "pre-commit-config-document-separator.yaml") 73 | config.pre_commit_config_file_path = MagicMock() 74 | 75 | initial_repo = PreCommitRepo("https://github.com/psf/black", "23.2.0", [PreCommitHook("black")]) 76 | updated_repo = PreCommitRepo("https://github.com/psf/black", "23.3.0", [PreCommitHook("black")]) 77 | config.update_pre_commit_repo_versions({initial_repo: updated_repo}) 78 | assert config.pre_commit_config_file_path.open.call_args[0][0] == "w" 79 | 80 | config.update_pre_commit_repo_versions({}) 81 | assert config.pre_commit_config_file_path.open.call_count == 1 82 | 83 | with pytest.raises(RuntimeError): 84 | config.update_pre_commit_repo_versions( 85 | {PreCommitRepo("https://github.com/psf/notexist", "23.2.0"): updated_repo} 86 | ) 87 | assert config.pre_commit_config_file_path.open.call_count == 1 88 | 89 | 90 | @pytest.mark.parametrize("base", ["only-deps", "with-deps", "with-one-liner-deps", "without-new-deps"]) 91 | def test_update_additional_dependencies_versions(base: str) -> None: 92 | config = PreCommitHookConfig.from_yaml_file(FIXTURES / f"pre-commit-config-{base}.yaml") 93 | mock_file = config.pre_commit_config_file_path = MagicMock() 94 | mock_file.open = mock_open() 95 | 96 | initial_repo = config.repos[0] 97 | updated_repo = PreCommitRepo( 98 | "https://github.com/pre-commit/mirrors-mypy", 99 | "v1.5.0", 100 | [PreCommitHook("mypy", ["types-PyYAML==1.2.4", "types-requests==3.4.5"])], 101 | ) 102 | 103 | config.update_pre_commit_repo_versions({initial_repo: updated_repo}) 104 | 105 | expected = (FIXTURES / f"pre-commit-config-{base}.expected.yaml").read_text() 106 | 107 | mock_file.open().writelines.assert_called_once_with(expected.splitlines(keepends=True)) 108 | 109 | 110 | # Syntactic sugar 111 | Repo = PreCommitRepo 112 | Hook = PreCommitHook 113 | 114 | 115 | @pytest.mark.parametrize( 116 | "repo1,repo2,equal", 117 | ( 118 | (Repo("https://some.url", "0.42"), Repo("https://some.url", "0.42"), True), 119 | (Repo("https://some.url", "0.42", tuple()), Repo("https://some.url", "0.42", []), True), 120 | ( 121 | Repo("https://some.url", "0.42", [Hook("hook")]), 122 | Repo("https://some.url", "0.42", [Hook("hook")]), 123 | True, 124 | ), 125 | ( 126 | Repo("https://some.url", "0.42", [Hook("hook", ["somelib"])]), 127 | Repo("https://some.url", "0.42", [Hook("hook", ["somelib"])]), 128 | True, 129 | ), 130 | ( 131 | Repo("https://some.url", "0.42", [Hook("hook", ("somelib",))]), 132 | Repo("https://some.url", "0.42", [Hook("hook", ["somelib"])]), 133 | True, 134 | ), 135 | ( 136 | Repo( 137 | "https://some.url", 138 | "0.42", 139 | [ 140 | Hook("1st-hook", ["somelib"]), 141 | Hook("2nd-hook", ["somelib", "another-lib"]), 142 | ], 143 | ), 144 | Repo( 145 | "https://some.url", 146 | "0.42", 147 | [ 148 | Hook("1st-hook", ["somelib"]), 149 | Hook("2nd-hook", ["somelib", "another-lib"]), 150 | ], 151 | ), 152 | True, 153 | ), 154 | ( 155 | Repo("https://some.url", "0.42"), 156 | Repo("https://some.new.url", "0.42"), 157 | False, 158 | ), 159 | ( 160 | Repo("https://some.url", "0.42"), 161 | Repo("https://some.url", "0.43"), 162 | False, 163 | ), 164 | ( 165 | Repo("https://some.url", "0.42", [Hook("hook", ["somelib==0.1"])]), 166 | Repo("https://some.url", "0.42", [Hook("hook", ["somelib"])]), 167 | False, 168 | ), 169 | ( 170 | Repo( 171 | "https://some.url", 172 | "0.42", 173 | [ 174 | Hook("1st-hook", ["somelib"]), 175 | Hook("2nd-hook", ["somelib", "another-lib"]), 176 | ], 177 | ), 178 | Repo( 179 | "https://some.url", 180 | "0.42", 181 | [ 182 | Hook("1st-hook", ["somelib"]), 183 | Hook("2nd-hook", ["somelib==0.42", "another-lib"]), 184 | ], 185 | ), 186 | False, 187 | ), 188 | ), 189 | ) 190 | def test_precommit_repo_equality(repo1: PreCommitRepo, repo2: PreCommitRepo, equal: bool): 191 | assert (repo1 == repo2) is equal 192 | assert (hash(repo1) == hash(repo2)) is equal 193 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sync_pre_commit_lock.utils import normalize_git_url, url_diff 4 | 5 | 6 | # Here are the test cases 7 | @pytest.mark.parametrize( 8 | ("url", "expected"), 9 | [ 10 | ("https://github.com/username/repository/", "https://github.com/username/repository"), 11 | ("http://github.com:80/username/repository.git", "http://github.com/username/repository"), 12 | ("https://github.com:443/username/repository.git", "https://github.com/username/repository"), 13 | ("https://gitlab.com/username/repository.git", "https://gitlab.com/username/repository"), 14 | ("git://github.com/username/repository.git", "https://github.com/username/repository"), 15 | ("git://gitlab.com/username/repository.git", "https://gitlab.com/username/repository"), 16 | ("ssh://git@github.com:443/username/repository.git", "https://github.com/username/repository"), 17 | ("https://github.com/username/repository", "https://github.com/username/repository"), 18 | ("https://gitlab.com/username/repository", "https://gitlab.com/username/repository"), 19 | ("https://GITLAB.com/username/repository", "https://gitlab.com/username/repository"), 20 | 2 * ("file:///path/to/repo.git",), 21 | 2 * ("/path/to/repo.git",), 22 | ], 23 | ) 24 | def test_normalize_git_url(url: str, expected: str) -> None: 25 | assert normalize_git_url(url) == expected 26 | 27 | 28 | @pytest.mark.parametrize( 29 | "old,new,expected", 30 | [ 31 | ("https://some.place", "https://some.place", "https://some.place"), 32 | ("https://some.old.place", "https://some.new.place", "https://some.{old -> new}.place"), 33 | ("https://some.place", "https://another.place", "https://{some -> another}.place"), 34 | ("https://some.place/old", "https://a.different/place", "https://{some.place/old -> a.different/place}"), 35 | ("https://some.place/old", "https://some.place/new", "https://some.place/{old -> new}"), 36 | ], 37 | ) 38 | def test_url_diff(old: str, new: str, expected: str): 39 | assert url_diff(old, new) == expected 40 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4.2 4 | env_list = 5 | clean 6 | report 7 | py{313, 312}-pdm{224, 223, 222, 221, 220, HEAD} 8 | py{311, 310, 39}-pdm{224, 223, 222, 221, 220, 219, 218, 217, 216, 215, 214, 213, 212, 211, 210, 29, 28, 27, HEAD} 9 | py{313, 312, 311, 310, 39}-poetry{21, 20, 18, 17, 16, HEAD} 10 | 11 | [testenv] 12 | set_env = 13 | py{312,313}-pdm{220,221,222,223,224,HEAD}: COVERAGE_FILE = .coverage.{envname} 14 | py{39,310,311,312,313}-poetry{21, 20, 18, 17, 16, HEAD}: COVERAGE_FILE = .coverage.{envname} 15 | py{39,310,311}-pdm{27,28,29,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,HEAD}: COVERAGE_FILE = .coverage.{envname} 16 | commands = 17 | pytest --cov --cov-append --cov-report=term-missing {posargs:-vv} --cov-config=pyproject.toml 18 | allowlist_externals = 19 | coverage 20 | pdm 21 | pytest 22 | depends = 23 | report: py{313, 312, 311, 310, 39}-pdm{224, 223, 222, 221, 220, 219, 218, 217, 216, 215, 214, 213, 212, 211, 210, 29, 28, 27, HEAD} 24 | report: py{313, 312, 311, 310, 39}-poetry{21, 20, 18, 17, 16, HEAD} 25 | py{313, 312, 311, 310, 39}-pdm{224, 223, 222, 221, 220, 219, 218, 217, 216, 215, 214, 213, 212, 211, 210, 29, 28, 27, HEAD}: clean 26 | py{313, 312, 311, 310, 39}-poetry{21, 20, 18, 17, 16, HEAD}: clean 27 | 28 | [testenv:clean] 29 | skip_install = true 30 | commands = 31 | coverage erase 32 | pdm export --dev --group testtox -o requirements-tox.txt --no-hashes 33 | groups = 34 | testtox 35 | 36 | [testenv:report] 37 | skip_install = true 38 | deps = 39 | -r requirements-tox.txt 40 | commands = 41 | coverage combine 42 | coverage report 43 | coverage html 44 | coverage xml 45 | 46 | [testenv:py{39,310,311,312,313}-pdm{27,28,29,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,HEAD}] 47 | package = editable 48 | deps = 49 | -r requirements-tox.txt 50 | py310-pdm214: httpx<0.28 51 | py311-pdm214: httpx<0.28 52 | py39-pdm214: httpx<0.28 53 | py39-pdm27: importlib-metadata<8 54 | pdm210: pdm<2.11,>=2.10 55 | pdm211: pdm<2.12,>=2.11 56 | pdm212: pdm<2.13,>=2.12 57 | pdm213: pdm<2.14,>=2.13.2 58 | pdm214: pdm<2.15,>=2.14 59 | pdm215: pdm<2.16,>=2.15 60 | pdm216: pdm<2.17,>=2.16 61 | pdm217: pdm<2.18,>=2.17 62 | pdm218: pdm<2.19,>=2.18 63 | pdm219: pdm<2.20,>=2.19 64 | pdm220: pdm<2.21,>=2.20 65 | pdm221: pdm<2.22,>=2.21 66 | pdm222: pdm<2.23,>=2.22 67 | pdm223: pdm<2.24,>=2.23 68 | pdm224: pdm<2.25,>=2.24 69 | pdm27: pdm<2.8,>=2.7 70 | pdm28: pdm<2.9,>=2.8 71 | pdm29: pdm<2.10,>=2.9 72 | pdmHEAD: pdm@ git+https://github.com/pdm-project/pdm.git 73 | 74 | [testenv:py{313,312, 311, 310, 39}-poetry{16, 17, 18, 20, 21,HEAD}] 75 | package = editable 76 | deps = 77 | -r requirements-tox.txt 78 | poetry16: poetry<1.7,>=1.6 79 | poetry17: poetry<1.8,>=1.7 80 | poetry18: poetry<1.9,>=1.8 81 | poetry20: poetry<2.1,>=2 82 | poetry21: poetry<2.2,>=2.1 83 | poetryHEAD: poetry@ git+https://github.com/python-poetry/poetry.git 84 | 85 | [gh] 86 | python = 87 | 3.9= py39-pdm{27,28,29,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,HEAD},py39-poetry{21, 20, 18, 17, 16, HEAD}, report, clean 88 | 3.10= py310-pdm{27,28,29,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,HEAD}, py310-poetry{21, 20, 18, 17, 16, HEAD}, report, clean 89 | 3.11= py311-pdm{27,28,29,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,HEAD}, py311-poetry{21, 20, 18, 17, 16, HEAD}, report, clean 90 | 3.12= py312-pdm{220,221,222,223,224,HEAD}, py312-poetry{21, 20, 18, 17, 16, HEAD}, report, clean 91 | 3.13= py313-pdm{220,221,222,223,224,HEAD}, py313-poetry{21, 20, 18, 17, 16, HEAD}, report, clean 92 | fail_on_no_env = True 93 | --------------------------------------------------------------------------------