pre-commit-localupdate 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pre_commit_localupdate-0.1.0/LICENSE +1 -0
- pre_commit_localupdate-0.1.0/PKG-INFO +151 -0
- pre_commit_localupdate-0.1.0/README.md +136 -0
- pre_commit_localupdate-0.1.0/pre_commit_localupdate/__init__.py +0 -0
- pre_commit_localupdate-0.1.0/pre_commit_localupdate/__main__.py +34 -0
- pre_commit_localupdate-0.1.0/pre_commit_localupdate/cli.py +25 -0
- pre_commit_localupdate-0.1.0/pre_commit_localupdate/logs.py +15 -0
- pre_commit_localupdate-0.1.0/pre_commit_localupdate/node.py +98 -0
- pre_commit_localupdate-0.1.0/pre_commit_localupdate/package.py +41 -0
- pre_commit_localupdate-0.1.0/pre_commit_localupdate/pre_commit_config.py +158 -0
- pre_commit_localupdate-0.1.0/pre_commit_localupdate/python.py +86 -0
- pre_commit_localupdate-0.1.0/pyproject.toml +40 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
LICENSES/LGPL-3.0-or-later.txt
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: pre-commit-localupdate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A CLI tool to automatically update `additional_dependencies` within local Python hooks in `pre-commit-config.yml` files.
|
|
5
|
+
Author: M. Farzalipour Tabriz
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Project-URL: source, https://gitlab.mpcdf.mpg.de/tbz/pre-commit-localupdate.git
|
|
11
|
+
Requires-Python: <3.15,>=3.10
|
|
12
|
+
Requires-Dist: requests>=2.32.5
|
|
13
|
+
Requires-Dist: ruamel-yaml>=0.19.1
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# pre-commit-localupdate
|
|
17
|
+
|
|
18
|
+
A CLI tool to automatically update dependencies in `pre-commit-config.yml` files. It specifically targets `additional_dependencies` within local Python and Node.js hooks to ensure your tooling stays up-to-date. It also adds version to unversioned packages and pins exact version of loosely defined ones.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install pre-commit-localupdate
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
To check and update the `additional_dependencies` in your `.pre-commit-config.yaml` file, simply run:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pre-commit-localupdate
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
By default, the tool looks for `.pre-commit-config.yaml` in the current directory. To specify a custom file path, use the `-c` or `--config` argument:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pre-commit-localupdate --config path/to/.pre-commit-config.yaml
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Example
|
|
41
|
+
|
|
42
|
+
Given a `.pre-commit-config.yaml` with the following content:
|
|
43
|
+
|
|
44
|
+
```yaml
|
|
45
|
+
# Comments in the file header are preserved
|
|
46
|
+
---
|
|
47
|
+
repos:
|
|
48
|
+
- repo: local
|
|
49
|
+
hooks:
|
|
50
|
+
# Comment about hooks are preserved
|
|
51
|
+
- id: black
|
|
52
|
+
name: black
|
|
53
|
+
description: "Long strings are automatically folded into multilines by ruamel-yaml library!"
|
|
54
|
+
entry: black
|
|
55
|
+
language: python
|
|
56
|
+
minimum_pre_commit_version: 2.9.2
|
|
57
|
+
require_serial: true
|
|
58
|
+
types_or: [python, pyi]
|
|
59
|
+
additional_dependencies:
|
|
60
|
+
# Loose version definitions will be pinned to an exact version
|
|
61
|
+
- "black>=25.1.0"
|
|
62
|
+
|
|
63
|
+
- id: mypy
|
|
64
|
+
name: mypy
|
|
65
|
+
description: 'Type checking'
|
|
66
|
+
entry: mypy
|
|
67
|
+
language: python
|
|
68
|
+
types_or: [python, pyi]
|
|
69
|
+
args: ["--strict"]
|
|
70
|
+
require_serial: true
|
|
71
|
+
minimum_pre_commit_version: '2.9.2'
|
|
72
|
+
additional_dependencies:
|
|
73
|
+
# Double/single quoting style is preserved
|
|
74
|
+
- 'mypy==1.18.1'
|
|
75
|
+
# Version is added to packages with no version definition
|
|
76
|
+
- "types-requests"
|
|
77
|
+
|
|
78
|
+
- id: mypy2
|
|
79
|
+
name: mypy
|
|
80
|
+
description: 'Type checking'
|
|
81
|
+
entry: mypy
|
|
82
|
+
language: python
|
|
83
|
+
types_or: [python, pyi]
|
|
84
|
+
args: ["--strict"]
|
|
85
|
+
require_serial: true
|
|
86
|
+
minimum_pre_commit_version: '2.9.2'
|
|
87
|
+
# Updating packages defined in flow style is also supported
|
|
88
|
+
additional_dependencies: ['mypy==1.18.1', "types-requests"]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Running `pre-commit-localupdate` will update the file to (hypothetical latest versions):
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
# Comments in the file header are preserved
|
|
95
|
+
---
|
|
96
|
+
repos:
|
|
97
|
+
- repo: local
|
|
98
|
+
hooks:
|
|
99
|
+
# Comment about hooks are preserved
|
|
100
|
+
- id: black
|
|
101
|
+
name: black
|
|
102
|
+
description: "Long strings are automatically folded into multilines by ruamel-yaml
|
|
103
|
+
library!"
|
|
104
|
+
entry: black
|
|
105
|
+
language: python
|
|
106
|
+
minimum_pre_commit_version: 2.9.2
|
|
107
|
+
require_serial: true
|
|
108
|
+
types_or: [python, pyi]
|
|
109
|
+
additional_dependencies:
|
|
110
|
+
# Loose version definitions should be pinned
|
|
111
|
+
- "black==26.1.0"
|
|
112
|
+
|
|
113
|
+
- id: mypy
|
|
114
|
+
name: mypy
|
|
115
|
+
description: 'Type checking'
|
|
116
|
+
entry: mypy
|
|
117
|
+
language: python
|
|
118
|
+
types_or: [python, pyi]
|
|
119
|
+
args: ["--strict"]
|
|
120
|
+
require_serial: true
|
|
121
|
+
minimum_pre_commit_version: '2.9.2'
|
|
122
|
+
additional_dependencies:
|
|
123
|
+
# Double/single quoting style is preserved
|
|
124
|
+
- 'mypy==1.19.1'
|
|
125
|
+
# Version is added to packages with no version definition
|
|
126
|
+
- "types-requests==2.32.4.20260107"
|
|
127
|
+
|
|
128
|
+
- id: mypy2
|
|
129
|
+
name: mypy
|
|
130
|
+
description: 'Type checking'
|
|
131
|
+
entry: mypy
|
|
132
|
+
language: python
|
|
133
|
+
types_or: [python, pyi]
|
|
134
|
+
args: ["--strict"]
|
|
135
|
+
require_serial: true
|
|
136
|
+
minimum_pre_commit_version: '2.9.2'
|
|
137
|
+
# Updating packages defined in flow style is also supported
|
|
138
|
+
additional_dependencies: ['mypy==1.19.1', "types-requests==2.32.4.20260107"]
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Requirements
|
|
142
|
+
|
|
143
|
+
- ruamel.yaml
|
|
144
|
+
- requests
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
- Copyright 2026 M. Farzalipour Tabriz, Max Planck Institute for Physics (MPP)
|
|
149
|
+
|
|
150
|
+
All rights reserved.
|
|
151
|
+
This software may be modified and distributed under the terms of the GNU Lesser General Public License (LGPL). See the `LICENSE` file for details.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# pre-commit-localupdate
|
|
2
|
+
|
|
3
|
+
A CLI tool to automatically update dependencies in `pre-commit-config.yml` files. It specifically targets `additional_dependencies` within local Python and Node.js hooks to ensure your tooling stays up-to-date. It also adds version to unversioned packages and pins exact version of loosely defined ones.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install pre-commit-localupdate
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
To check and update the `additional_dependencies` in your `.pre-commit-config.yaml` file, simply run:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pre-commit-localupdate
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
By default, the tool looks for `.pre-commit-config.yaml` in the current directory. To specify a custom file path, use the `-c` or `--config` argument:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pre-commit-localupdate --config path/to/.pre-commit-config.yaml
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Example
|
|
26
|
+
|
|
27
|
+
Given a `.pre-commit-config.yaml` with the following content:
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
# Comments in the file header are preserved
|
|
31
|
+
---
|
|
32
|
+
repos:
|
|
33
|
+
- repo: local
|
|
34
|
+
hooks:
|
|
35
|
+
# Comment about hooks are preserved
|
|
36
|
+
- id: black
|
|
37
|
+
name: black
|
|
38
|
+
description: "Long strings are automatically folded into multilines by ruamel-yaml library!"
|
|
39
|
+
entry: black
|
|
40
|
+
language: python
|
|
41
|
+
minimum_pre_commit_version: 2.9.2
|
|
42
|
+
require_serial: true
|
|
43
|
+
types_or: [python, pyi]
|
|
44
|
+
additional_dependencies:
|
|
45
|
+
# Loose version definitions will be pinned to an exact version
|
|
46
|
+
- "black>=25.1.0"
|
|
47
|
+
|
|
48
|
+
- id: mypy
|
|
49
|
+
name: mypy
|
|
50
|
+
description: 'Type checking'
|
|
51
|
+
entry: mypy
|
|
52
|
+
language: python
|
|
53
|
+
types_or: [python, pyi]
|
|
54
|
+
args: ["--strict"]
|
|
55
|
+
require_serial: true
|
|
56
|
+
minimum_pre_commit_version: '2.9.2'
|
|
57
|
+
additional_dependencies:
|
|
58
|
+
# Double/single quoting style is preserved
|
|
59
|
+
- 'mypy==1.18.1'
|
|
60
|
+
# Version is added to packages with no version definition
|
|
61
|
+
- "types-requests"
|
|
62
|
+
|
|
63
|
+
- id: mypy2
|
|
64
|
+
name: mypy
|
|
65
|
+
description: 'Type checking'
|
|
66
|
+
entry: mypy
|
|
67
|
+
language: python
|
|
68
|
+
types_or: [python, pyi]
|
|
69
|
+
args: ["--strict"]
|
|
70
|
+
require_serial: true
|
|
71
|
+
minimum_pre_commit_version: '2.9.2'
|
|
72
|
+
# Updating packages defined in flow style is also supported
|
|
73
|
+
additional_dependencies: ['mypy==1.18.1', "types-requests"]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Running `pre-commit-localupdate` will update the file to (hypothetical latest versions):
|
|
77
|
+
|
|
78
|
+
```yaml
|
|
79
|
+
# Comments in the file header are preserved
|
|
80
|
+
---
|
|
81
|
+
repos:
|
|
82
|
+
- repo: local
|
|
83
|
+
hooks:
|
|
84
|
+
# Comment about hooks are preserved
|
|
85
|
+
- id: black
|
|
86
|
+
name: black
|
|
87
|
+
description: "Long strings are automatically folded into multilines by ruamel-yaml
|
|
88
|
+
library!"
|
|
89
|
+
entry: black
|
|
90
|
+
language: python
|
|
91
|
+
minimum_pre_commit_version: 2.9.2
|
|
92
|
+
require_serial: true
|
|
93
|
+
types_or: [python, pyi]
|
|
94
|
+
additional_dependencies:
|
|
95
|
+
# Loose version definitions should be pinned
|
|
96
|
+
- "black==26.1.0"
|
|
97
|
+
|
|
98
|
+
- id: mypy
|
|
99
|
+
name: mypy
|
|
100
|
+
description: 'Type checking'
|
|
101
|
+
entry: mypy
|
|
102
|
+
language: python
|
|
103
|
+
types_or: [python, pyi]
|
|
104
|
+
args: ["--strict"]
|
|
105
|
+
require_serial: true
|
|
106
|
+
minimum_pre_commit_version: '2.9.2'
|
|
107
|
+
additional_dependencies:
|
|
108
|
+
# Double/single quoting style is preserved
|
|
109
|
+
- 'mypy==1.19.1'
|
|
110
|
+
# Version is added to packages with no version definition
|
|
111
|
+
- "types-requests==2.32.4.20260107"
|
|
112
|
+
|
|
113
|
+
- id: mypy2
|
|
114
|
+
name: mypy
|
|
115
|
+
description: 'Type checking'
|
|
116
|
+
entry: mypy
|
|
117
|
+
language: python
|
|
118
|
+
types_or: [python, pyi]
|
|
119
|
+
args: ["--strict"]
|
|
120
|
+
require_serial: true
|
|
121
|
+
minimum_pre_commit_version: '2.9.2'
|
|
122
|
+
# Updating packages defined in flow style is also supported
|
|
123
|
+
additional_dependencies: ['mypy==1.19.1', "types-requests==2.32.4.20260107"]
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Requirements
|
|
127
|
+
|
|
128
|
+
- ruamel.yaml
|
|
129
|
+
- requests
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
- Copyright 2026 M. Farzalipour Tabriz, Max Planck Institute for Physics (MPP)
|
|
134
|
+
|
|
135
|
+
All rights reserved.
|
|
136
|
+
This software may be modified and distributed under the terms of the GNU Lesser General Public License (LGPL). See the `LICENSE` file for details.
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 M. Farzalipour Tabriz, Max Planck Institute for Physics
|
|
2
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
"""Module to update Python additional_dependencies in .pre-commit-config.yaml.
|
|
5
|
+
|
|
6
|
+
This script reads the .pre-commit-config.yaml file, identifies hooks defined
|
|
7
|
+
under 'repo: local' that use 'language: python', and updates the packages
|
|
8
|
+
listed in 'additional_dependencies' to their latest versions available on PyPI.
|
|
9
|
+
|
|
10
|
+
It preserves the original file formatting, comments, and structure, only
|
|
11
|
+
modifying the version strings within the dependency lines.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from pre_commit_localupdate.cli import parse_args
|
|
18
|
+
from pre_commit_localupdate.logs import setup_logging
|
|
19
|
+
from pre_commit_localupdate.pre_commit_config import update_additional_dependencies
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> None:
|
|
23
|
+
"""Main entry point for the script."""
|
|
24
|
+
args = parse_args()
|
|
25
|
+
setup_logging(args.debug)
|
|
26
|
+
|
|
27
|
+
if update_additional_dependencies(Path(args.config)):
|
|
28
|
+
logging.info("Done.")
|
|
29
|
+
else:
|
|
30
|
+
logging.info("File content is already up to date.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
main()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 M. Farzalipour Tabriz, Max Planck Institute for Physics
|
|
2
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse_args() -> argparse.Namespace:
|
|
8
|
+
"""Parse command line arguments."""
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
description="Update Python and Node.js additional_dependencies in .pre-commit-config.yaml"
|
|
11
|
+
)
|
|
12
|
+
parser.add_argument(
|
|
13
|
+
"--debug",
|
|
14
|
+
action="store_true",
|
|
15
|
+
help="Enable debug logging",
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"-c",
|
|
19
|
+
"--config",
|
|
20
|
+
type=str,
|
|
21
|
+
help="pre-commit config file path",
|
|
22
|
+
default=".pre-commit-config.yaml",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
return parser.parse_args()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 M. Farzalipour Tabriz, Max Planck Institute for Physics
|
|
2
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def setup_logging(debug: bool = False) -> None:
|
|
9
|
+
"""Configure logging based on the debug flag."""
|
|
10
|
+
level = logging.DEBUG if debug else logging.INFO
|
|
11
|
+
logging.basicConfig(
|
|
12
|
+
level=level,
|
|
13
|
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
14
|
+
stream=sys.stdout,
|
|
15
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 M. Farzalipour Tabriz, Max Planck Institute for Physics
|
|
2
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from pre_commit_localupdate.package import PackageBase
|
|
12
|
+
|
|
13
|
+
NPM_API_URL = "https://registry.npmjs.org/{package_name}"
|
|
14
|
+
REQUEST_TIMEOUT = 10
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class NodePackage(PackageBase):
|
|
19
|
+
"""Represents a Node.js package."""
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_specification(package, spec: str) -> "NodePackage" | None:
|
|
23
|
+
"""Parse a package specification string into a NodePackage object.
|
|
24
|
+
|
|
25
|
+
Handles formats like 'react@4.17.21', 'react@>=18.0.0', '@scope/name@^1.2.3', or just 'react'.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
spec: The package specification string.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
A NodePackage instance if parsing succeeds, otherwise None.
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
clean_spec = spec.strip().strip('"').strip("'")
|
|
35
|
+
|
|
36
|
+
if not clean_spec:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
parts = clean_spec.split("@")
|
|
40
|
+
|
|
41
|
+
name = clean_spec
|
|
42
|
+
raw_spec = None
|
|
43
|
+
|
|
44
|
+
# Look for version from the end (handles scoped packages correctly).
|
|
45
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
46
|
+
if re.match(r"^(\d+|[<>=~^])", parts[i]):
|
|
47
|
+
name = "@".join(parts[:i])
|
|
48
|
+
raw_spec = "@" + "@".join(parts[i:])
|
|
49
|
+
break
|
|
50
|
+
|
|
51
|
+
latest_version = package._get_latest_version(name)
|
|
52
|
+
|
|
53
|
+
return package(
|
|
54
|
+
name=name,
|
|
55
|
+
raw_spec=raw_spec,
|
|
56
|
+
latest_version=latest_version,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _get_latest_version(package_name: str) -> str | None:
|
|
61
|
+
"""Fetch the latest version of a package from the npm registry.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The latest version string if found, otherwise None.
|
|
65
|
+
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
url = NPM_API_URL.format(package_name=package_name)
|
|
69
|
+
response = requests.get(url, timeout=REQUEST_TIMEOUT)
|
|
70
|
+
response.raise_for_status()
|
|
71
|
+
data = response.json()
|
|
72
|
+
|
|
73
|
+
version = data.get("dist-tags", {}).get("latest")
|
|
74
|
+
|
|
75
|
+
if version and isinstance(version, str):
|
|
76
|
+
return cast("str", version)
|
|
77
|
+
|
|
78
|
+
return None
|
|
79
|
+
except requests.exceptions.RequestException as exc:
|
|
80
|
+
logging.warning(
|
|
81
|
+
"Could not fetch latest version for %s: %s", package_name, exc
|
|
82
|
+
)
|
|
83
|
+
return None
|
|
84
|
+
except (KeyError, ValueError) as exc:
|
|
85
|
+
logging.warning("Could not parse response for %s: %s", package_name, exc)
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def latest_version_specification(package) -> str | None:
|
|
89
|
+
"""Version specification of package pinned to the latest version.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The latest version specification string if found, otherwise None.
|
|
93
|
+
|
|
94
|
+
"""
|
|
95
|
+
if not package.latest_version:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
return f"{package.name}@{package.latest_version}"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 M. Farzalipour Tabriz, Max Planck Institute for Physics
|
|
2
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class PackageBase(ABC):
|
|
10
|
+
"""Abstract base class defining the interface for all package types.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
name: The name of the package (e.g., 'requests').
|
|
14
|
+
raw_spec: The original version specifier string (e.g., '==2.25.1', '^4.17.21', '>=1.0.0', '@1.2.3').
|
|
15
|
+
latest_version: Latest version number without operators (e.g., '2.25.1').
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
raw_spec: str | None
|
|
21
|
+
latest_version: str | None
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def from_specification(package, spec_string: str) -> "PackageBase" | None:
|
|
26
|
+
"""Parse a package specification string into a Package object."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def latest_version_specification(package) -> str | None:
|
|
30
|
+
"""Version specification of package pinned to the latest version."""
|
|
31
|
+
|
|
32
|
+
def version_specification(package) -> str | None:
|
|
33
|
+
"""Version specification of package as originally defined.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The version specification string.
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
if package.raw_spec:
|
|
40
|
+
return f"{package.name}{package.raw_spec}"
|
|
41
|
+
return f"{package.name}[unspecified version]"
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 M. Farzalipour Tabriz, Max Planck Institute for Physics
|
|
2
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import ruamel.yaml
|
|
9
|
+
|
|
10
|
+
from pre_commit_localupdate.node import NodePackage
|
|
11
|
+
from pre_commit_localupdate.package import PackageBase
|
|
12
|
+
from pre_commit_localupdate.python import PyPackage
|
|
13
|
+
|
|
14
|
+
LANGUAGE_PACKAGE_MAP: dict[str, type[PackageBase]] = {
|
|
15
|
+
"python": PyPackage,
|
|
16
|
+
"node": NodePackage,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def update_additional_dependencies(file_path: Path) -> bool:
|
|
21
|
+
"""Reads the configuration file, identifies outdated Python packages in
|
|
22
|
+
local hooks, and updates them directly in the file.
|
|
23
|
+
|
|
24
|
+
Preserves structure, quote styles, the document start marker (---),
|
|
25
|
+
and comments preceding it.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
file_path: Path to the YAML configuration file.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
True if the file was modified and successfully written, False otherwise.
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
logging.debug("Reading configuration file: %s", file_path)
|
|
35
|
+
|
|
36
|
+
raw_lines = []
|
|
37
|
+
header_lines = []
|
|
38
|
+
yaml_start_index = 0
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
with file_path.open("r", encoding="utf-8") as f:
|
|
42
|
+
raw_lines = f.readlines()
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
logging.exception("File not found: %s", file_path)
|
|
45
|
+
return False
|
|
46
|
+
except OSError as exc:
|
|
47
|
+
logging.exception("IOError while reading file %s: %s", file_path, exc)
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
logging.debug("Parsing document header...")
|
|
51
|
+
yaml_start_index = 0
|
|
52
|
+
for i, line in enumerate(raw_lines):
|
|
53
|
+
stripped = line.strip()
|
|
54
|
+
if stripped == "---":
|
|
55
|
+
logging.debug("YAML marker found.")
|
|
56
|
+
yaml_start_index = i + 1
|
|
57
|
+
break
|
|
58
|
+
if stripped and not stripped.startswith("#"):
|
|
59
|
+
yaml_start_index = i
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
header_lines = raw_lines[:yaml_start_index]
|
|
63
|
+
|
|
64
|
+
logging.debug("Parsing YAML content...")
|
|
65
|
+
yaml_content = "".join(raw_lines[yaml_start_index:])
|
|
66
|
+
|
|
67
|
+
yaml = ruamel.yaml.YAML()
|
|
68
|
+
yaml.preserve_quotes = True
|
|
69
|
+
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
70
|
+
yaml.default_flow_style = False
|
|
71
|
+
yaml.width = None
|
|
72
|
+
yaml.explicit_start = False
|
|
73
|
+
yaml.allow_unicode = True
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
config = yaml.load(yaml_content)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
logging.exception("Failed to parse YAML content from %s: %s", file_path, exc)
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
if config is None:
|
|
82
|
+
logging.warning("Configuration file is empty or could not be parsed.")
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
file_modified = False
|
|
86
|
+
|
|
87
|
+
if "repos" in config:
|
|
88
|
+
logging.debug("Scanning repositories for local hooks")
|
|
89
|
+
for repo in config.get("repos", []):
|
|
90
|
+
if repo.get("repo") != "local":
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
hooks = repo.get("hooks", [])
|
|
94
|
+
for hook in hooks:
|
|
95
|
+
hook_id = hook.get("id")
|
|
96
|
+
logging.debug("Processing hook with ID: %s", hook_id)
|
|
97
|
+
|
|
98
|
+
if hook.get("language") not in LANGUAGE_PACKAGE_MAP:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
PackageClass = LANGUAGE_PACKAGE_MAP[hook.get("language")]
|
|
102
|
+
|
|
103
|
+
if "additional_dependencies" not in hook:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
deps_list = list(hook["additional_dependencies"])
|
|
107
|
+
|
|
108
|
+
for i, dep_spec in enumerate(deps_list):
|
|
109
|
+
dep_str = str(dep_spec)
|
|
110
|
+
|
|
111
|
+
logging.debug("Checking dependency: %s", dep_str)
|
|
112
|
+
package: PackageBase | None = PackageClass.from_specification(
|
|
113
|
+
dep_str
|
|
114
|
+
)
|
|
115
|
+
if not package:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
if not package.latest_version_specification():
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
if (
|
|
122
|
+
package.latest_version_specification()
|
|
123
|
+
!= package.version_specification()
|
|
124
|
+
):
|
|
125
|
+
logging.info(
|
|
126
|
+
"Updating %s to %s",
|
|
127
|
+
package.version_specification(),
|
|
128
|
+
package.latest_version_specification(),
|
|
129
|
+
)
|
|
130
|
+
hook["additional_dependencies"][
|
|
131
|
+
i
|
|
132
|
+
] = package.latest_version_specification()
|
|
133
|
+
|
|
134
|
+
file_modified = True
|
|
135
|
+
else:
|
|
136
|
+
logging.debug(
|
|
137
|
+
"%s is already correctly defined and up to date (%s).",
|
|
138
|
+
package.name,
|
|
139
|
+
package.version_specification(),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if file_modified:
|
|
143
|
+
logging.debug("Writing modified content to disk")
|
|
144
|
+
try:
|
|
145
|
+
stream = io.StringIO()
|
|
146
|
+
yaml.dump(config, stream)
|
|
147
|
+
modified_body = stream.getvalue()
|
|
148
|
+
final_content = "".join(header_lines) + modified_body
|
|
149
|
+
|
|
150
|
+
with file_path.open("w", encoding="utf-8") as f:
|
|
151
|
+
f.write(final_content)
|
|
152
|
+
logging.info("Successfully updated %s", file_path)
|
|
153
|
+
return True
|
|
154
|
+
except OSError as exc:
|
|
155
|
+
logging.exception("IOError while writing to file %s: %s", file_path, exc)
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
return False
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 M. Farzalipour Tabriz, Max Planck Institute for Physics
|
|
2
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from pre_commit_localupdate.package import PackageBase
|
|
11
|
+
|
|
12
|
+
PYPI_API_URL = "https://pypi.org/pypi/{package_name}/json"
|
|
13
|
+
REQUEST_TIMEOUT = 10
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PyPackage(PackageBase):
|
|
17
|
+
"""Represents a Python package."""
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_specification(package, spec: str) -> "PyPackage" | None:
|
|
21
|
+
"""Parse a package specification string into a Package object.
|
|
22
|
+
|
|
23
|
+
Handles formats like 'ansible==13.3.0', 'ansible>=1.0', or just 'ansible'.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
spec: The package specification string.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
A Package instance if parsing succeeds, otherwise None.
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
clean_spec = spec.strip().strip('"').strip("'")
|
|
33
|
+
match = re.match(r"^([a-zA-Z0-9._-]+)([=<>!~]+.*)?$", clean_spec)
|
|
34
|
+
if not match:
|
|
35
|
+
logging.warning("Could not parse the package specification: %s", clean_spec)
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
name = match.group(1)
|
|
39
|
+
raw_spec = match.group(2)
|
|
40
|
+
latest_version = package._get_latest_version(name)
|
|
41
|
+
|
|
42
|
+
return package(
|
|
43
|
+
name=name,
|
|
44
|
+
raw_spec=raw_spec,
|
|
45
|
+
latest_version=latest_version,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _get_latest_version(package_name: str) -> str | None:
|
|
50
|
+
"""Fetch the latest version of a package from PyPI.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The latest version string if found, otherwise None.
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
logging.debug("Fetching latest version for %s", package_name)
|
|
57
|
+
try:
|
|
58
|
+
url = PYPI_API_URL.format(package_name=package_name)
|
|
59
|
+
response = requests.get(url, timeout=REQUEST_TIMEOUT)
|
|
60
|
+
response.raise_for_status()
|
|
61
|
+
data = response.json()
|
|
62
|
+
version = data.get("info", {}).get("version")
|
|
63
|
+
if version and isinstance(version, str):
|
|
64
|
+
return cast("str", version)
|
|
65
|
+
|
|
66
|
+
return None
|
|
67
|
+
except requests.exceptions.RequestException as exc:
|
|
68
|
+
logging.warning(
|
|
69
|
+
"Could not fetch latest version for %s: %s", package_name, exc
|
|
70
|
+
)
|
|
71
|
+
return None
|
|
72
|
+
except (KeyError, ValueError) as exc:
|
|
73
|
+
logging.warning("Could not parse response for %s: %s", package_name, exc)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def latest_version_specification(package) -> str | None:
|
|
77
|
+
"""Version specification of package pinned to the latest version.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The latest version specification string if found, otherwise None.
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
if not package.latest_version:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
return f"{package.name}=={package.latest_version}"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
build-backend = "pdm.backend"
|
|
3
|
+
requires = [
|
|
4
|
+
"pdm-backend",
|
|
5
|
+
]
|
|
6
|
+
|
|
7
|
+
[dependency-groups]
|
|
8
|
+
dev = [
|
|
9
|
+
"pytest>=9.0.2",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "M. Farzalipour Tabriz" },
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"requests>=2.32.5",
|
|
24
|
+
"ruamel-yaml>=0.19.1",
|
|
25
|
+
]
|
|
26
|
+
description = "A CLI tool to automatically update `additional_dependencies` within local Python hooks in `pre-commit-config.yml` files."
|
|
27
|
+
name = "pre-commit-localupdate"
|
|
28
|
+
readme = "README.md"
|
|
29
|
+
requires-python = "<3.15,>=3.10"
|
|
30
|
+
version = "0.1.0"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
pre-commit-localupdate = "pre_commit_localupdate.__main__:main"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
source = "https://gitlab.mpcdf.mpg.de/tbz/pre-commit-localupdate.git"
|
|
37
|
+
|
|
38
|
+
[tool.tomlsort]
|
|
39
|
+
sort_table_keys = true
|
|
40
|
+
trailing_comma_inline_array = true
|