pre-commit-localupdate 0.3.1__tar.gz → 0.4.1__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.
Files changed (21) hide show
  1. {pre_commit_localupdate-0.3.1 → pre_commit_localupdate-0.4.1}/PKG-INFO +18 -17
  2. {pre_commit_localupdate-0.3.1 → pre_commit_localupdate-0.4.1}/README.md +15 -15
  3. pre_commit_localupdate-0.4.1/pre_commit_localupdate/__init__.py +1 -0
  4. {pre_commit_localupdate-0.3.1 → pre_commit_localupdate-0.4.1}/pre_commit_localupdate/__main__.py +3 -1
  5. {pre_commit_localupdate-0.3.1 → pre_commit_localupdate-0.4.1}/pre_commit_localupdate/cli.py +15 -4
  6. {pre_commit_localupdate-0.3.1 → pre_commit_localupdate-0.4.1}/pre_commit_localupdate/io.py +11 -1
  7. {pre_commit_localupdate-0.3.1 → pre_commit_localupdate-0.4.1}/pre_commit_localupdate/logs.py +5 -1
  8. {pre_commit_localupdate-0.3.1 → pre_commit_localupdate-0.4.1}/pre_commit_localupdate/packages/__init__.py +2 -0
  9. pre_commit_localupdate-0.4.1/pre_commit_localupdate/packages/golang.py +126 -0
  10. {pre_commit_localupdate-0.3.1 → pre_commit_localupdate-0.4.1}/pre_commit_localupdate/packages/julia.py +26 -19
  11. {pre_commit_localupdate-0.3.1 → pre_commit_localupdate-0.4.1}/pre_commit_localupdate/packages/node.py +21 -18
  12. pre_commit_localupdate-0.4.1/pre_commit_localupdate/packages/package.py +55 -0
  13. pre_commit_localupdate-0.4.1/pre_commit_localupdate/packages/python.py +95 -0
  14. {pre_commit_localupdate-0.3.1 → pre_commit_localupdate-0.4.1}/pre_commit_localupdate/packages/rust.py +27 -28
  15. {pre_commit_localupdate-0.3.1 → pre_commit_localupdate-0.4.1}/pre_commit_localupdate/pre_commit_config.py +13 -5
  16. {pre_commit_localupdate-0.3.1 → pre_commit_localupdate-0.4.1}/pyproject.toml +3 -2
  17. pre_commit_localupdate-0.3.1/pre_commit_localupdate/__init__.py +0 -1
  18. pre_commit_localupdate-0.3.1/pre_commit_localupdate/packages/package.py +0 -41
  19. pre_commit_localupdate-0.3.1/pre_commit_localupdate/packages/python.py +0 -86
  20. {pre_commit_localupdate-0.3.1 → pre_commit_localupdate-0.4.1}/LICENSE +0 -0
  21. {pre_commit_localupdate-0.3.1 → pre_commit_localupdate-0.4.1}/pre_commit_localupdate/error.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pre-commit-localupdate
3
- Version: 0.3.1
4
- Summary: A CLI tool to automatically update additional dependencies within local Python, Julia, Rust, and Node.js hooks in pre-commit config files.
3
+ Version: 0.4.1
4
+ Summary: A CLI tool to automatically update additional dependencies within local Python, Julia, Rust, Go, and Node.js hooks in pre-commit config files.
5
5
  Author: M. Farzalipour Tabriz
6
6
  Classifier: Development Status :: 3 - Alpha
7
7
  Classifier: Environment :: Console
@@ -11,11 +11,12 @@ Project-URL: source, https://gitlab.mpcdf.mpg.de/tbz/pre-commit-localupdate.git
11
11
  Requires-Python: <3.15,>=3.11
12
12
  Requires-Dist: requests>=2.32.5
13
13
  Requires-Dist: ruamel-yaml>=0.19.1
14
+ Requires-Dist: packaging>=26.0
14
15
  Description-Content-Type: text/markdown
15
16
 
16
17
  # pre-commit-localupdate
17
18
 
18
- A CLI tool to automatically update dependencies in `pre-commit-config.yml` files. It specifically targets `additional_dependencies` within local Python, Julia, Rust, 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. You can also pin a specific package to an older version by adding a `# freeze` comment.
19
+ A CLI tool to automatically update dependencies in `pre-commit-config.yml` files. It specifically targets `additional_dependencies` within local Python, Julia, Rust, Go, 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. You can also pin a specific package to an older version by adding a `# freeze` comment.
19
20
 
20
21
  ## Installation
21
22
 
@@ -31,22 +32,22 @@ To check and update the `additional_dependencies` in your `.pre-commit-config.ya
31
32
  pre-commit-localupdate
32
33
  ```
33
34
 
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
+ All options:
35
36
 
36
37
  ```shell
37
- pre-commit-localupdate --config path/to/.pre-commit-config.yaml
38
- ```
39
-
40
- To check the dependencies without updating, use the `--dry-run` flag. The command will exit with a non-zero status code if any package needs updating:
41
-
42
- ```shell
43
- pre-commit-localupdate --dry-run
44
- ```
45
-
46
- To get debug output use `--debug` flag:
47
-
48
- ```shell
49
- pre-commit-localupdate --debug
38
+ usage: pre-commit-localupdate [-h] [--debug] [--dry-run] [-c CONFIG] [--timeout TIMEOUT] [--version]
39
+
40
+ Automatically update additional dependencies within local Python, Julia, Rust, Go, and Node.js hooks in a pre-commit config
41
+ file.
42
+
43
+ options:
44
+ -h, --help show this help message and exit
45
+ --debug enable debug logging (default: False)
46
+ --dry-run dry run mode. Do not update the file and exit with a non-zero code if the configuration file require an
47
+ update. (default: False)
48
+ -c, --config CONFIG pre-commit config file path (default: .pre-commit-config.yaml)
49
+ --timeout TIMEOUT connection timeout in seconds (default: 10)
50
+ --version show program's version number and exit
50
51
  ```
51
52
 
52
53
  ## Example
@@ -1,6 +1,6 @@
1
1
  # pre-commit-localupdate
2
2
 
3
- A CLI tool to automatically update dependencies in `pre-commit-config.yml` files. It specifically targets `additional_dependencies` within local Python, Julia, Rust, 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. You can also pin a specific package to an older version by adding a `# freeze` comment.
3
+ A CLI tool to automatically update dependencies in `pre-commit-config.yml` files. It specifically targets `additional_dependencies` within local Python, Julia, Rust, Go, 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. You can also pin a specific package to an older version by adding a `# freeze` comment.
4
4
 
5
5
  ## Installation
6
6
 
@@ -16,22 +16,22 @@ To check and update the `additional_dependencies` in your `.pre-commit-config.ya
16
16
  pre-commit-localupdate
17
17
  ```
18
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:
19
+ All options:
20
20
 
21
21
  ```shell
22
- pre-commit-localupdate --config path/to/.pre-commit-config.yaml
23
- ```
24
-
25
- To check the dependencies without updating, use the `--dry-run` flag. The command will exit with a non-zero status code if any package needs updating:
26
-
27
- ```shell
28
- pre-commit-localupdate --dry-run
29
- ```
30
-
31
- To get debug output use `--debug` flag:
32
-
33
- ```shell
34
- pre-commit-localupdate --debug
22
+ usage: pre-commit-localupdate [-h] [--debug] [--dry-run] [-c CONFIG] [--timeout TIMEOUT] [--version]
23
+
24
+ Automatically update additional dependencies within local Python, Julia, Rust, Go, and Node.js hooks in a pre-commit config
25
+ file.
26
+
27
+ options:
28
+ -h, --help show this help message and exit
29
+ --debug enable debug logging (default: False)
30
+ --dry-run dry run mode. Do not update the file and exit with a non-zero code if the configuration file require an
31
+ update. (default: False)
32
+ -c, --config CONFIG pre-commit config file path (default: .pre-commit-config.yaml)
33
+ --timeout TIMEOUT connection timeout in seconds (default: 10)
34
+ --version show program's version number and exit
35
35
  ```
36
36
 
37
37
  ## Example
@@ -0,0 +1 @@
1
+ __version__ = '0.4.1'
@@ -25,7 +25,9 @@ def main() -> None:
25
25
  setup_logging(debug=args.debug)
26
26
 
27
27
  try:
28
- if update_additional_dependencies(Path(args.config), dry_run=args.dry_run):
28
+ if update_additional_dependencies(
29
+ Path(args.config), args.timeout, dry_run=args.dry_run
30
+ ):
29
31
  if args.dry_run:
30
32
  sys.exit(1)
31
33
  logging.info("Done.")
@@ -7,19 +7,24 @@ from pre_commit_localupdate import __version__
7
7
 
8
8
 
9
9
  def parse_args() -> argparse.Namespace:
10
- """Parse command line arguments."""
10
+ """Parse command line arguments.
11
+
12
+ Returns:
13
+ argparse.Namespace: Parsed arguments.
14
+ """
11
15
  parser = argparse.ArgumentParser(
12
- description="Automatically update additional dependencies within local Python, Julia, Rust, and Node.js hooks in a pre-commit config file.",
16
+ description="Automatically update additional dependencies within local Python, Julia, Rust, Go, and Node.js hooks in a pre-commit config file.",
17
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
13
18
  )
14
19
  parser.add_argument(
15
20
  "--debug",
16
21
  action="store_true",
17
- help="Enable debug logging",
22
+ help="enable debug logging",
18
23
  )
19
24
  parser.add_argument(
20
25
  "--dry-run",
21
26
  action="store_true",
22
- help="Dry run mode. Do not update the file and exit with a non-zero code if the configuration file require an update.",
27
+ help="dry run mode. Do not update the file and exit with a non-zero code if the configuration file require an update.",
23
28
  )
24
29
  parser.add_argument(
25
30
  "-c",
@@ -28,6 +33,12 @@ def parse_args() -> argparse.Namespace:
28
33
  help="pre-commit config file path",
29
34
  default=".pre-commit-config.yaml",
30
35
  )
36
+ parser.add_argument(
37
+ "--timeout",
38
+ type=int,
39
+ help="connection timeout in seconds",
40
+ default=10,
41
+ )
31
42
  parser.add_argument(
32
43
  "--version",
33
44
  action="version",
@@ -8,7 +8,17 @@ from pre_commit_localupdate.error import PreCommitLocalUpdateError
8
8
 
9
9
 
10
10
  def load_config_file(file_path: Path) -> tuple[list[str], str]:
11
- """Reads pre-commit configuration file and returns its header lines and content."""
11
+ """Reads pre-commit configuration file and returns its header lines and content.
12
+
13
+ Args:
14
+ file_path (Path): Path to config file
15
+
16
+ Returns:
17
+ tuple[list[str], str]: Header lines, config file content
18
+
19
+ Raises:
20
+ PreCommitLocalUpdateError: Error in reading config file
21
+ """
12
22
  logging.debug("Reading configuration file: %s", file_path)
13
23
 
14
24
  raw_lines: list[str] = []
@@ -6,7 +6,11 @@ import sys
6
6
 
7
7
 
8
8
  def setup_logging(*, debug: bool = False) -> None:
9
- """Configure logging based on the debug flag."""
9
+ """Configure logging based on the debug flag.
10
+
11
+ Args:
12
+ debug (bool): activate debug logs
13
+ """
10
14
  level = logging.DEBUG if debug else logging.INFO
11
15
  logging.basicConfig(
12
16
  level=level,
@@ -1,6 +1,7 @@
1
1
  # SPDX-FileCopyrightText: 2026 M. Farzalipour Tabriz, Max Planck Institute for Physics
2
2
  # SPDX-License-Identifier: LGPL-3.0-or-later
3
3
 
4
+ from .golang import GoPackage
4
5
  from .julia import JuliaPackage
5
6
  from .node import NodePackage
6
7
  from .package import PackageBase
@@ -8,6 +9,7 @@ from .python import PyPackage
8
9
  from .rust import RustPackage
9
10
 
10
11
  SUPPORTED_PACKAGES: dict[str, type[PackageBase]] = {
12
+ "golang": GoPackage,
11
13
  "julia": JuliaPackage,
12
14
  "node": NodePackage,
13
15
  "python": PyPackage,
@@ -0,0 +1,126 @@
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
+
8
+ import requests
9
+
10
+ from .package import PackageBase
11
+
12
+ # Go Proxy API endpoints
13
+ GO_PKG_LATEST_VERSION_API_URL = "https://proxy.golang.org/{package_path}/@latest"
14
+
15
+
16
+ @dataclass
17
+ class GoPackage(PackageBase):
18
+ """Represents a Go module."""
19
+
20
+ @classmethod
21
+ def from_specification(
22
+ cls, spec: str, connection_timeout: int
23
+ ) -> "GoPackage" | None:
24
+ """Parse a Go module specification string into a Package object.
25
+
26
+ Handles formats like:
27
+ - 'mvdan.cc/sh' (Module path only)
28
+ - 'mvdan.cc/sh@v1.3.0' (Module path with specific version)
29
+ - 'mvdan.cc/sh/v3' (Module path with major version suffix)
30
+ - 'mvdan.cc/sh/v3@v3.10.0' (Major suffix with version)
31
+ - 'mvdan.cc/sh/v3/cmd/shfmt' (Submodule)
32
+ - 'mvdan.cc/sh/v3/cmd/shfmt@v3.10.0' (Submodule with version)
33
+
34
+ Args:
35
+ spec (str): The package specification string.
36
+ connection_timeout (int): Connection timeout in seconds.
37
+
38
+ Returns:
39
+ "GoPackage" | None: A Package instance if parsing succeeds, otherwise None.
40
+ """
41
+ clean_spec = spec.strip().strip('"').strip("'")
42
+
43
+ match = re.match(r"^([a-zA-Z0-9._/-]+)(@.+)?$", clean_spec)
44
+
45
+ if not match:
46
+ logging.warning(
47
+ "Could not parse the Go package specification: %s", clean_spec
48
+ )
49
+ return None
50
+
51
+ name = match.group(1)
52
+ raw_spec = match.group(2)
53
+
54
+ module_path = cls._resolve_module_path(name)
55
+ latest_version = cls._get_latest_version(module_path, connection_timeout)
56
+
57
+ return cls(
58
+ name=name,
59
+ raw_spec=raw_spec,
60
+ latest_version=latest_version,
61
+ )
62
+
63
+ @staticmethod
64
+ def _resolve_module_path(package_path: str) -> str:
65
+ """
66
+ Resolve a submodule path to a module path.
67
+
68
+ If the path contains a major version suffix (e.g., /v2, /v3) followed by
69
+ additional path segments, it trims the path at the suffix.
70
+
71
+ Args:
72
+ package_path (str): The full submodule path.
73
+
74
+ Returns:
75
+ str: The resolved module path.
76
+ """
77
+
78
+ match = re.search(r"/v([2-9]|[1-9][0-9]+)(?=/)", package_path)
79
+
80
+ if match:
81
+ return package_path[: match.end()]
82
+
83
+ return package_path
84
+
85
+ @staticmethod
86
+ def _get_latest_version(package_path: str, connection_timeout: int) -> str | None:
87
+ """Fetch the latest version of a Go module from proxy.golang.org.
88
+
89
+ Args:
90
+ package_path (str): The module path (e.g., mvdan.cc/sh/v3).
91
+ connection_timeout (int): Connection timeout in seconds.
92
+
93
+ Returns:
94
+ str | None: The latest version string (e.g., v3.10.0) if found, otherwise None.
95
+ """
96
+ logging.debug("Fetching latest version for %s", package_path)
97
+ try:
98
+ url = GO_PKG_LATEST_VERSION_API_URL.format(package_path=package_path)
99
+
100
+ response = requests.get(url, timeout=connection_timeout)
101
+ response.raise_for_status()
102
+
103
+ data = response.json()
104
+ latest_version = data.get("Version")
105
+
106
+ return latest_version if isinstance(latest_version, str) else None
107
+
108
+ except requests.exceptions.RequestException as exc:
109
+ logging.warning(
110
+ "Could not fetch latest version for %s: %s", package_path, exc
111
+ )
112
+ return None
113
+ except (KeyError, ValueError) as exc:
114
+ logging.warning("Could not parse response for %s: %s", package_path, exc)
115
+ return None
116
+
117
+ def latest_version_specification(self) -> str | None:
118
+ """Version specification of package pinned to the latest version.
119
+
120
+ Returns:
121
+ str | None: The latest version specification string if found, otherwise None.
122
+ """
123
+ if not self.latest_version:
124
+ return None
125
+
126
+ return f"{self.name}@{self.latest_version}"
@@ -4,30 +4,35 @@
4
4
  import logging
5
5
  import re
6
6
  import tomllib
7
+ from dataclasses import dataclass
7
8
 
8
9
  import requests
10
+ from packaging.version import InvalidVersion, Version
9
11
 
10
12
  from .package import PackageBase
11
13
 
12
14
  # Julia General Registry Source
13
15
  JULIA_PKG_API_URL = "https://raw.githubusercontent.com/JuliaRegistries/General/refs/heads/master/{package_name_first_letter}/{package_name}/Versions.toml"
14
- REQUEST_TIMEOUT = 10
15
16
 
16
17
 
18
+ @dataclass
17
19
  class JuliaPackage(PackageBase):
18
20
  """Represents a Julia package."""
19
21
 
20
22
  @classmethod
21
- def from_specification(package, spec: str) -> "JuliaPackage" | None:
23
+ def from_specification(
24
+ cls, spec: str, connection_timeout: int
25
+ ) -> "JuliaPackage" | None:
22
26
  """Parse a package specification string into a Package object.
23
27
 
24
28
  Handles formats like 'Example@0.4.1', 'Example@0.4', or just 'Example'.
25
29
 
26
30
  Args:
27
- spec: The package specification string.
31
+ spec (str): The package specification string.
32
+ connection_timeout (int): Connection timeout in seconds.
28
33
 
29
34
  Returns:
30
- A Package instance if parsing succeeds, otherwise None.
35
+ "JuliaPackage" | None: A Package instance if parsing succeeds, otherwise None.
31
36
 
32
37
  """
33
38
  clean_spec = spec.strip().strip('"').strip("'")
@@ -39,20 +44,24 @@ class JuliaPackage(PackageBase):
39
44
 
40
45
  name = match.group(1)
41
46
  raw_spec = match.group(2)
42
- latest_version = package._get_latest_version(name)
47
+ latest_version = cls._get_latest_version(name, connection_timeout)
43
48
 
44
- return package(
49
+ return cls(
45
50
  name=name,
46
51
  raw_spec=raw_spec,
47
52
  latest_version=latest_version,
48
53
  )
49
54
 
50
55
  @staticmethod
51
- def _get_latest_version(package_name: str) -> str | None:
56
+ def _get_latest_version(package_name: str, connection_timeout: int) -> str | None:
52
57
  """Fetch the latest version of a package from the Julia General Registry.
53
58
 
59
+ Args:
60
+ package_name (str): Name of the package.
61
+ connection_timeout (int): Connection timeout in seconds.
62
+
54
63
  Returns:
55
- The latest version string if found, otherwise None.
64
+ str | None: The latest version string if found, otherwise None.
56
65
 
57
66
  """
58
67
  logging.debug("Fetching latest version for %s", package_name)
@@ -60,7 +69,7 @@ class JuliaPackage(PackageBase):
60
69
  url = JULIA_PKG_API_URL.format(
61
70
  package_name=package_name, package_name_first_letter=package_name[0]
62
71
  )
63
- response = requests.get(url, timeout=REQUEST_TIMEOUT)
72
+ response = requests.get(url, timeout=connection_timeout)
64
73
  response.raise_for_status()
65
74
 
66
75
  data = tomllib.loads(response.text)
@@ -69,31 +78,29 @@ class JuliaPackage(PackageBase):
69
78
  if not versions:
70
79
  return None
71
80
 
72
- versions.sort(reverse=True)
73
- latest = versions[0]
81
+ versions.sort(key=lambda v: Version(v), reverse=True)
82
+ latest_version = versions[0]
74
83
 
75
- if latest and isinstance(latest, str):
76
- return latest
84
+ return latest_version if isinstance(latest_version, str) else None
77
85
 
78
- return None
79
86
  except requests.exceptions.RequestException as exc:
80
87
  logging.warning(
81
88
  "Could not fetch latest version for %s: %s", package_name, exc
82
89
  )
83
90
  return None
84
- except (KeyError, ValueError, tomllib.TOMLDecodeError) as exc:
91
+ except (KeyError, ValueError, tomllib.TOMLDecodeError, InvalidVersion) as exc:
85
92
  logging.warning("Could not parse response for %s: %s", package_name, exc)
86
93
  return None
87
94
 
88
- def latest_version_specification(package) -> str | None:
95
+ def latest_version_specification(self) -> str | None:
89
96
  """Version specification of package pinned to the latest version.
90
97
 
91
98
  Returns:
92
- The latest version specification string if found, otherwise None.
99
+ str | None: The latest version specification string if found, otherwise None.
93
100
  Format: PackageName@version
94
101
 
95
102
  """
96
- if not package.latest_version:
103
+ if not self.latest_version:
97
104
  return None
98
105
 
99
- return f"{package.name}@{package.latest_version}"
106
+ return f"{self.name}@{self.latest_version}"
@@ -4,14 +4,12 @@
4
4
  import logging
5
5
  import re
6
6
  from dataclasses import dataclass
7
- from typing import cast
8
7
 
9
8
  import requests
10
9
 
11
10
  from .package import PackageBase
12
11
 
13
12
  NPM_API_URL = "https://registry.npmjs.org/{package_name}"
14
- REQUEST_TIMEOUT = 10
15
13
 
16
14
 
17
15
  @dataclass
@@ -19,16 +17,19 @@ class NodePackage(PackageBase):
19
17
  """Represents a Node.js package."""
20
18
 
21
19
  @classmethod
22
- def from_specification(package, spec: str) -> "NodePackage" | None:
20
+ def from_specification(
21
+ cls, spec: str, connection_timeout: int
22
+ ) -> "NodePackage" | None:
23
23
  """Parse a package specification string into a NodePackage object.
24
24
 
25
25
  Handles formats like 'react@4.17.21', 'react@>=18.0.0', '@scope/name@^1.2.3', or just 'react'.
26
26
 
27
27
  Args:
28
- spec: The package specification string.
28
+ spec (str): The package specification string.
29
+ connection_timeout (int): Connection timeout in seconds.
29
30
 
30
31
  Returns:
31
- A NodePackage instance if parsing succeeds, otherwise None.
32
+ "NodePackage" | None: A NodePackage instance if parsing succeeds, otherwise None.
32
33
 
33
34
  """
34
35
  clean_spec = spec.strip().strip('"').strip("'")
@@ -48,34 +49,36 @@ class NodePackage(PackageBase):
48
49
  raw_spec = "@" + "@".join(parts[i:])
49
50
  break
50
51
 
51
- latest_version = package._get_latest_version(name)
52
+ latest_version = cls._get_latest_version(name, connection_timeout)
52
53
 
53
- return package(
54
+ return cls(
54
55
  name=name,
55
56
  raw_spec=raw_spec,
56
57
  latest_version=latest_version,
57
58
  )
58
59
 
59
60
  @staticmethod
60
- def _get_latest_version(package_name: str) -> str | None:
61
+ def _get_latest_version(package_name: str, connection_timeout: int) -> str | None:
61
62
  """Fetch the latest version of a package from the npm registry.
62
63
 
64
+ Args:
65
+ package_name (str): Name of the package.
66
+ connection_timeout (int): Connection timeout in seconds.
67
+
63
68
  Returns:
64
- The latest version string if found, otherwise None.
69
+ str | None: The latest version string if found, otherwise None.
65
70
 
66
71
  """
67
72
  try:
68
73
  url = NPM_API_URL.format(package_name=package_name)
69
- response = requests.get(url, timeout=REQUEST_TIMEOUT)
74
+ response = requests.get(url, timeout=connection_timeout)
70
75
  response.raise_for_status()
71
76
  data = response.json()
72
77
 
73
- version = data.get("dist-tags", {}).get("latest")
78
+ latest_version = data.get("dist-tags", {}).get("latest")
74
79
 
75
- if version and isinstance(version, str):
76
- return cast("str", version)
80
+ return latest_version if isinstance(latest_version, str) else None
77
81
 
78
- return None
79
82
  except requests.exceptions.RequestException as exc:
80
83
  logging.warning(
81
84
  "Could not fetch latest version for %s: %s", package_name, exc
@@ -85,14 +88,14 @@ class NodePackage(PackageBase):
85
88
  logging.warning("Could not parse response for %s: %s", package_name, exc)
86
89
  return None
87
90
 
88
- def latest_version_specification(package) -> str | None:
91
+ def latest_version_specification(self) -> str | None:
89
92
  """Version specification of package pinned to the latest version.
90
93
 
91
94
  Returns:
92
- The latest version specification string if found, otherwise None.
95
+ str | None: The latest version specification string if found, otherwise None.
93
96
 
94
97
  """
95
- if not package.latest_version:
98
+ if not self.latest_version:
96
99
  return None
97
100
 
98
- return f"{package.name}@{package.latest_version}"
101
+ return f"{self.name}@{self.latest_version}"
@@ -0,0 +1,55 @@
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 (str): The name of the package (e.g., 'requests').
14
+ raw_spec (str | None): The original version specifier string (e.g., '==2.25.1', '^4.17.21', '>=1.0.0', '@1.2.3').
15
+ latest_version (str | None): 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(
26
+ cls, spec_string: str, connection_timeout: int
27
+ ) -> "PackageBase" | None:
28
+ """Parse a package specification string into a Package object.
29
+
30
+ Args:
31
+ spec_string (str): The package specification string.
32
+ connection_timeout (int): Connection timeout in seconds.
33
+
34
+ Returns:
35
+ "PackageBase" | None: A Package instance if parsing succeeds, otherwise None.
36
+ """
37
+
38
+ @abstractmethod
39
+ def latest_version_specification(self) -> str | None:
40
+ """Version specification of package pinned to the latest version.
41
+
42
+ Returns:
43
+ str | None: The latest version specification string if found, otherwise None.
44
+ """
45
+
46
+ def version_specification(self) -> str | None:
47
+ """Version specification of package as originally defined.
48
+
49
+ Returns:
50
+ str | None: The version specification string.
51
+
52
+ """
53
+ if self.raw_spec:
54
+ return f"{self.name}{self.raw_spec}"
55
+ return f"{self.name}[unspecified version]"
@@ -0,0 +1,95 @@
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
+ from dataclasses import dataclass
6
+
7
+ import requests
8
+ from packaging.requirements import Requirement
9
+
10
+ from .package import PackageBase
11
+
12
+ PYPI_API_URL = "https://pypi.org/pypi/{package_name}/json"
13
+
14
+
15
+ @dataclass
16
+ class PyPackage(PackageBase):
17
+ """Represents a Python package."""
18
+
19
+ @classmethod
20
+ def from_specification(
21
+ cls, spec: str, connection_timeout: int
22
+ ) -> "PyPackage" | None:
23
+ """Parse a package specification string into a Package object.
24
+
25
+ Handles formats like 'ansible==13.3.0', 'ansible>=1.0', or just 'ansible'.
26
+
27
+ Args:
28
+ spec (str): The package specification string.
29
+ connection_timeout (int): Connection timeout in seconds.
30
+
31
+ Returns:
32
+ "PyPackage" | None: A Package instance if parsing succeeds, otherwise None.
33
+
34
+ """
35
+ clean_spec = spec.strip().strip('"').strip("'")
36
+
37
+ try:
38
+ requirement = Requirement(clean_spec)
39
+ except Exception as exc:
40
+ logging.warning(
41
+ "Could not parse the package specification: %s - %s", clean_spec, exc
42
+ )
43
+ return None
44
+
45
+ name = requirement.name
46
+ raw_spec = str(requirement.specifier) if requirement.specifier else None
47
+ latest_version = cls._get_latest_version(name, connection_timeout)
48
+
49
+ return cls(
50
+ name=name,
51
+ raw_spec=raw_spec,
52
+ latest_version=latest_version,
53
+ )
54
+
55
+ @staticmethod
56
+ def _get_latest_version(package_name: str, connection_timeout: int) -> str | None:
57
+ """Fetch the latest version of a package from PyPI.
58
+
59
+ Args:
60
+ package_name (str): Name of the package.
61
+ connection_timeout (int): Connection timeout in seconds.
62
+
63
+ Returns:
64
+ str | None: The latest version string if found, otherwise None.
65
+
66
+ """
67
+ logging.debug("Fetching latest version for %s", package_name)
68
+ try:
69
+ url = PYPI_API_URL.format(package_name=package_name)
70
+ response = requests.get(url, timeout=connection_timeout)
71
+ response.raise_for_status()
72
+ data = response.json()
73
+ latest_version = data.get("info", {}).get("version")
74
+ return latest_version if isinstance(latest_version, str) else None
75
+
76
+ except requests.exceptions.RequestException as exc:
77
+ logging.warning(
78
+ "Could not fetch latest version for %s: %s", package_name, exc
79
+ )
80
+ return None
81
+ except (KeyError, ValueError) as exc:
82
+ logging.warning("Could not parse response for %s: %s", package_name, exc)
83
+ return None
84
+
85
+ def latest_version_specification(self) -> str | None:
86
+ """Version specification of package pinned to the latest version.
87
+
88
+ Returns:
89
+ str | None: The latest version specification string if found, otherwise None.
90
+
91
+ """
92
+ if not self.latest_version:
93
+ return None
94
+
95
+ return f"{self.name}=={self.latest_version}"
@@ -3,40 +3,39 @@
3
3
 
4
4
  import logging
5
5
  import re
6
- from typing import Any, cast
6
+ from dataclasses import dataclass
7
7
 
8
8
  import requests
9
9
 
10
10
  from .package import PackageBase
11
11
 
12
12
  CRATES_API_URL = "https://crates.io/api/v1/crates/{package_name}"
13
- REQUEST_TIMEOUT = 10
14
13
 
15
14
 
15
+ @dataclass
16
16
  class RustPackage(PackageBase):
17
17
  """Represents a Rust package.
18
18
 
19
19
  Attributes:
20
- cli: Package is a CLI tool.
20
+ cli (bool): Package is a CLI tool.
21
21
  """
22
22
 
23
23
  cli: bool = False
24
24
 
25
- def __init__(self, **kwargs: Any) -> None:
26
- self.cli = kwargs.pop("cli", False)
27
- super().__init__(**kwargs)
28
-
29
25
  @classmethod
30
- def from_specification(package, spec: str) -> "RustPackage" | None:
26
+ def from_specification(
27
+ cls, spec: str, connection_timeout: int
28
+ ) -> "RustPackage" | None:
31
29
  """Parse a package specification string into a Package object.
32
30
 
33
31
  Handles formats like 'package', 'package:1.2.3', or 'cli:package:1.2.3'.
34
32
 
35
33
  Args:
36
- spec: The package specification string.
34
+ spec (str): The package specification string.
35
+ connection_timeout (int): Connection timeout in seconds.
37
36
 
38
37
  Returns:
39
- A Package instance if parsing succeeds, otherwise None.
38
+ "RustPackage" | None: A Package instance if parsing succeeds, otherwise None.
40
39
 
41
40
  """
42
41
  clean_spec = spec.strip().strip('"').strip("'")
@@ -51,34 +50,34 @@ class RustPackage(PackageBase):
51
50
 
52
51
  name = match.group("name")
53
52
  version = match.group("version")
54
- cli = True if match.group("cli") else False
53
+ cli = bool(match.group("cli"))
55
54
 
56
55
  raw_spec = f":{version}" if version else None
57
- latest_version = package._get_latest_version(name)
56
+ latest_version = cls._get_latest_version(name, connection_timeout)
58
57
 
59
- return package(
60
- name=name, raw_spec=raw_spec, latest_version=latest_version, cli=cli
61
- )
58
+ return cls(name=name, raw_spec=raw_spec, latest_version=latest_version, cli=cli)
62
59
 
63
60
  @staticmethod
64
- def _get_latest_version(package_name: str) -> str | None:
61
+ def _get_latest_version(package_name: str, connection_timeout: int) -> str | None:
65
62
  """Fetch the latest version of a package from crates.io.
66
63
 
64
+ Args:
65
+ package_name (str): Name of the package.
66
+ connection_timeout (int): Connection timeout in seconds.
67
+
67
68
  Returns:
68
- The latest version string if found, otherwise None.
69
+ str | None: The latest version string if found, otherwise None.
69
70
 
70
71
  """
71
72
  logging.debug("Fetching latest version for %s", package_name)
72
73
  try:
73
74
  url = CRATES_API_URL.format(package_name=package_name)
74
- response = requests.get(url, timeout=REQUEST_TIMEOUT)
75
+ response = requests.get(url, timeout=connection_timeout)
75
76
  response.raise_for_status()
76
77
  data = response.json()
77
- version = data.get("crate", {}).get("max_version")
78
- if version and isinstance(version, str):
79
- return cast("str", version)
78
+ latest_version = data.get("crate", {}).get("max_version")
79
+ return latest_version if isinstance(latest_version, str) else None
80
80
 
81
- return None
82
81
  except requests.exceptions.RequestException as exc:
83
82
  logging.warning(
84
83
  "Could not fetch latest version for %s: %s", package_name, exc
@@ -88,16 +87,16 @@ class RustPackage(PackageBase):
88
87
  logging.warning("Could not parse response for %s: %s", package_name, exc)
89
88
  return None
90
89
 
91
- def latest_version_specification(package) -> str | None:
90
+ def latest_version_specification(self) -> str | None:
92
91
  """Version specification of package pinned to the latest version.
93
92
 
94
93
  Returns:
95
- The latest version specification string if found, otherwise None.
94
+ str | None: The latest version specification string if found, otherwise None.
96
95
 
97
96
  """
98
- if not package.latest_version:
97
+ if not self.latest_version:
99
98
  return None
100
- if package.cli:
101
- return f"cli:{package.name}:{package.latest_version}"
99
+ if self.cli:
100
+ return f"cli:{self.name}:{self.latest_version}"
102
101
  else:
103
- return f"{package.name}:{package.latest_version}"
102
+ return f"{self.name}:{self.latest_version}"
@@ -15,7 +15,9 @@ from pre_commit_localupdate.io import load_config_file
15
15
  from pre_commit_localupdate.packages import SUPPORTED_PACKAGES
16
16
 
17
17
 
18
- def update_additional_dependencies(file_path: Path, *, dry_run: bool = False) -> bool:
18
+ def update_additional_dependencies(
19
+ file_path: Path, connection_timeout: int, *, dry_run: bool = False
20
+ ) -> bool:
19
21
  """Reads the pre-commit configuration file, identifies outdated packages
20
22
  in local hooks, and updates them directly in the file.
21
23
 
@@ -23,11 +25,15 @@ def update_additional_dependencies(file_path: Path, *, dry_run: bool = False) ->
23
25
  and comments preceding it.
24
26
 
25
27
  Args:
26
- file_path: Path to the pre-commit configuration file.
27
- dry_run: Do not update the pre-commit configuration file.
28
+ file_path (Path): Path to the pre-commit configuration file.
29
+ connection_timeout (int): Connection timeout in seconds.
30
+ dry_run (bool): Do not update the pre-commit configuration file.
28
31
 
29
32
  Returns:
30
- True if the file needed updating, False otherwise.
33
+ bool: True if the file needed updating, False otherwise.
34
+
35
+ Raises:
36
+ PreCommitLocalUpdateError: Error in parsing or updating pre-commit hooks
31
37
 
32
38
  """
33
39
  header_lines, yaml_content = load_config_file(file_path)
@@ -88,7 +94,9 @@ def update_additional_dependencies(file_path: Path, *, dry_run: bool = False) ->
88
94
  continue
89
95
 
90
96
  logging.debug("Checking dependency: %s", dep_str)
91
- package = package_type.from_specification(dep_str)
97
+ package = package_type.from_specification(
98
+ dep_str, connection_timeout
99
+ )
92
100
  if not package:
93
101
  continue
94
102
 
@@ -22,13 +22,14 @@ classifiers = [
22
22
  dependencies = [
23
23
  "requests>=2.32.5",
24
24
  "ruamel-yaml>=0.19.1",
25
+ "packaging>=26.0",
25
26
  ]
26
- description = "A CLI tool to automatically update additional dependencies within local Python, Julia, Rust, and Node.js hooks in pre-commit config files."
27
+ description = "A CLI tool to automatically update additional dependencies within local Python, Julia, Rust, Go, and Node.js hooks in pre-commit config files."
27
28
  dynamic = []
28
29
  name = "pre-commit-localupdate"
29
30
  readme = "README.md"
30
31
  requires-python = "<3.15,>=3.11"
31
- version = "0.3.1"
32
+ version = "0.4.1"
32
33
 
33
34
  [project.scripts]
34
35
  pre-commit-localupdate = "pre_commit_localupdate.__main__:main"
@@ -1 +0,0 @@
1
- __version__ = '0.3.1'
@@ -1,41 +0,0 @@
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]"
@@ -1,86 +0,0 @@
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 .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}"