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.
@@ -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.
@@ -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