├── .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 | [](https://github.com/GabDug/sync-pre-commit-lock/actions/workflows/ci.yml)
4 | [](https://results.pre-commit.ci/latest/github/GabDug/sync-pre-commit-lock/main)
5 | [](https://bestpractices.coreinfrastructure.org/projects/7529)
6 | [](https://pypi.org/project/sync-pre-commit-lock/)
7 | [](https://pypi.python.org/pypi/sync-pre-commit-lock)
8 | [](https://pypi.python.org/pypi/sync-pre-commit-lock)
9 | [](https://pdm.fming.dev)
10 | [](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 | 
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 |
--------------------------------------------------------------------------------