pre-commit-localupdate 0.5.2__tar.gz → 0.6.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.
Files changed (20) hide show
  1. {pre_commit_localupdate-0.5.2 → pre_commit_localupdate-0.6.0}/PKG-INFO +6 -4
  2. {pre_commit_localupdate-0.5.2 → pre_commit_localupdate-0.6.0}/README.md +5 -3
  3. pre_commit_localupdate-0.6.0/pre_commit_localupdate/__init__.py +1 -0
  4. {pre_commit_localupdate-0.5.2 → pre_commit_localupdate-0.6.0}/pre_commit_localupdate/__main__.py +5 -1
  5. {pre_commit_localupdate-0.5.2 → pre_commit_localupdate-0.6.0}/pre_commit_localupdate/cli.py +29 -1
  6. pre_commit_localupdate-0.6.0/pre_commit_localupdate/logs.py +64 -0
  7. {pre_commit_localupdate-0.5.2 → pre_commit_localupdate-0.6.0}/pre_commit_localupdate/packages/golang.py +31 -7
  8. {pre_commit_localupdate-0.5.2 → pre_commit_localupdate-0.6.0}/pre_commit_localupdate/packages/julia.py +4 -1
  9. {pre_commit_localupdate-0.5.2 → pre_commit_localupdate-0.6.0}/pre_commit_localupdate/packages/node.py +11 -5
  10. {pre_commit_localupdate-0.5.2 → pre_commit_localupdate-0.6.0}/pre_commit_localupdate/packages/python.py +11 -4
  11. {pre_commit_localupdate-0.5.2 → pre_commit_localupdate-0.6.0}/pre_commit_localupdate/packages/rust.py +11 -4
  12. {pre_commit_localupdate-0.5.2 → pre_commit_localupdate-0.6.0}/pre_commit_localupdate/pre_commit_config.py +29 -13
  13. {pre_commit_localupdate-0.5.2 → pre_commit_localupdate-0.6.0}/pyproject.toml +1 -1
  14. pre_commit_localupdate-0.5.2/pre_commit_localupdate/__init__.py +0 -1
  15. pre_commit_localupdate-0.5.2/pre_commit_localupdate/logs.py +0 -19
  16. {pre_commit_localupdate-0.5.2 → pre_commit_localupdate-0.6.0}/LICENSE +0 -0
  17. {pre_commit_localupdate-0.5.2 → pre_commit_localupdate-0.6.0}/pre_commit_localupdate/error.py +0 -0
  18. {pre_commit_localupdate-0.5.2 → pre_commit_localupdate-0.6.0}/pre_commit_localupdate/io.py +0 -0
  19. {pre_commit_localupdate-0.5.2 → pre_commit_localupdate-0.6.0}/pre_commit_localupdate/packages/__init__.py +0 -0
  20. {pre_commit_localupdate-0.5.2 → pre_commit_localupdate-0.6.0}/pre_commit_localupdate/packages/package.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pre-commit-localupdate
3
- Version: 0.5.2
3
+ Version: 0.6.0
4
4
  Summary: A CLI tool to automatically update additional dependencies within local hooks in pre-commit config files.
5
5
  Author: M. Farzalipour Tabriz
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -16,7 +16,7 @@ Description-Content-Type: text/markdown
16
16
 
17
17
  # pre-commit-localupdate
18
18
 
19
- A CLI tool to automatically update dependencies in `pre-commit-config.yml` files. It specifically targets `additional_dependencies` within local 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 hooks to ensure your tooling stays up-to-date. It also adds version to unversioned packages and pins exact version of loosely defined ones to the absolute latest. You can also pin a specific package to an older version by adding a `# freeze` comment.
20
20
 
21
21
  ## Installation
22
22
 
@@ -36,8 +36,9 @@ All options:
36
36
 
37
37
  ```text
38
38
  usage: pre-commit-localupdate [-h] [--debug] [--dry-run] [-c PRE-COMMIT-CONFIG] [--timeout TIMEOUT]
39
- [--indent-mapping INDENT_MAPPING] [--indent-sequence INDENT_SEQUENCE]
40
- [--indent-offset INDENT_OFFSET] [--line-width LINE_WIDTH] [--version]
39
+ [--request-delay REQUEST_DELAY] [--indent-mapping INDENT_MAPPING]
40
+ [--indent-sequence INDENT_SEQUENCE] [--indent-offset INDENT_OFFSET]
41
+ [--line-width LINE_WIDTH] [--version]
41
42
 
42
43
  Automatically update additional dependencies within local hooks in a pre-commit config file.
43
44
 
@@ -48,6 +49,7 @@ options:
48
49
  configuration file require an update. (default: False)
49
50
  -c, --config PRE-COMMIT-CONFIG pre-commit config file path (default: .pre-commit-config.yaml)
50
51
  --timeout TIMEOUT connection timeout in seconds (default: 10)
52
+ --request-delay REQUEST_DELAY minimum deplay between network requests in seconds (default: 1.0)
51
53
  --indent-mapping INDENT_MAPPING YAML indentation for mappings (default: 2)
52
54
  --indent-sequence INDENT_SEQUENCE YAML indentation for sequences (default: 4)
53
55
  --indent-offset INDENT_OFFSET YAML indentation offset (default: 2)
@@ -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 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 hooks to ensure your tooling stays up-to-date. It also adds version to unversioned packages and pins exact version of loosely defined ones to the absolute latest. You can also pin a specific package to an older version by adding a `# freeze` comment.
4
4
 
5
5
  ## Installation
6
6
 
@@ -20,8 +20,9 @@ All options:
20
20
 
21
21
  ```text
22
22
  usage: pre-commit-localupdate [-h] [--debug] [--dry-run] [-c PRE-COMMIT-CONFIG] [--timeout TIMEOUT]
23
- [--indent-mapping INDENT_MAPPING] [--indent-sequence INDENT_SEQUENCE]
24
- [--indent-offset INDENT_OFFSET] [--line-width LINE_WIDTH] [--version]
23
+ [--request-delay REQUEST_DELAY] [--indent-mapping INDENT_MAPPING]
24
+ [--indent-sequence INDENT_SEQUENCE] [--indent-offset INDENT_OFFSET]
25
+ [--line-width LINE_WIDTH] [--version]
25
26
 
26
27
  Automatically update additional dependencies within local hooks in a pre-commit config file.
27
28
 
@@ -32,6 +33,7 @@ options:
32
33
  configuration file require an update. (default: False)
33
34
  -c, --config PRE-COMMIT-CONFIG pre-commit config file path (default: .pre-commit-config.yaml)
34
35
  --timeout TIMEOUT connection timeout in seconds (default: 10)
36
+ --request-delay REQUEST_DELAY minimum deplay between network requests in seconds (default: 1.0)
35
37
  --indent-mapping INDENT_MAPPING YAML indentation for mappings (default: 2)
36
38
  --indent-sequence INDENT_SEQUENCE YAML indentation for sequences (default: 4)
37
39
  --indent-offset INDENT_OFFSET YAML indentation offset (default: 2)
@@ -0,0 +1 @@
1
+ __version__ = '0.6.0'
@@ -31,7 +31,11 @@ def main() -> None:
31
31
  )
32
32
  try:
33
33
  if update_additional_dependencies(
34
- args.config, args.timeout, yaml_format, dry_run=args.dry_run
34
+ args.config,
35
+ args.timeout,
36
+ args.request_delay,
37
+ yaml_format,
38
+ dry_run=args.dry_run,
35
39
  ):
36
40
  if args.dry_run:
37
41
  logging.info("Additional dependencies require updating!")
@@ -7,6 +7,28 @@ from pathlib import Path
7
7
  from pre_commit_localupdate import __version__
8
8
 
9
9
 
10
+ def positive_int(value: str) -> int:
11
+ """Convert a string argument to a positive integer.
12
+
13
+ Args:
14
+ value(str): input value
15
+ Returns:
16
+ int: positive integer equivalent
17
+ Raises:
18
+ argparse.ArgumentTypeError: if ``value`` cannot be converted to an integer
19
+ or is not a positive integer
20
+ """
21
+ try:
22
+ int_value = int(value)
23
+ except ValueError as err:
24
+ msg = f"'{value}' cannot be converted to integer"
25
+ raise argparse.ArgumentTypeError(msg) from err
26
+ if int_value <= 0:
27
+ msg = f"'{value}' is not a positive integer"
28
+ raise argparse.ArgumentTypeError(msg)
29
+ return int_value
30
+
31
+
10
32
  def parse_args() -> argparse.Namespace:
11
33
  """Parse command line arguments.
12
34
 
@@ -39,10 +61,16 @@ def parse_args() -> argparse.Namespace:
39
61
  )
40
62
  parser.add_argument(
41
63
  "--timeout",
42
- type=int,
64
+ type=positive_int,
43
65
  help="connection timeout in seconds",
44
66
  default=10,
45
67
  )
68
+ parser.add_argument(
69
+ "--request-delay",
70
+ type=float,
71
+ help="minimum deplay between network requests in seconds",
72
+ default=1.0,
73
+ )
46
74
  parser.add_argument(
47
75
  "--indent-mapping",
48
76
  type=int,
@@ -0,0 +1,64 @@
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
+
6
+
7
+ class SafeFormatter(logging.Formatter):
8
+ """Custom formatter that escapes newlines and carriage returns to prevent log injection."""
9
+
10
+ def format(self, record: logging.LogRecord) -> str:
11
+ """Sanitize logs and encode non-printable characters.
12
+
13
+ Args:
14
+ record (logging.LogRecord): log record to sanitize
15
+ Returns:
16
+ str: sanitized log message
17
+ """
18
+ processed_record = super().format(record)
19
+ if processed_record.isprintable():
20
+ return processed_record
21
+
22
+ max_8bit_uint: int = 0xFF
23
+ max_16bit_uint: int = 0xFFFF
24
+ sanitized_record_parts = []
25
+ for character in processed_record:
26
+ if character.isprintable():
27
+ sanitized_record_parts.append(character)
28
+ else:
29
+ character_code = ord(character)
30
+ if character_code <= max_8bit_uint:
31
+ sanitized_record_parts.append(f"\\x{character_code:02x}")
32
+ elif character_code <= max_16bit_uint:
33
+ sanitized_record_parts.append(f"\\u{character_code:04x}")
34
+ else:
35
+ sanitized_record_parts.append(f"\\U{character_code:08x}")
36
+
37
+ return "".join(sanitized_record_parts)
38
+
39
+
40
+ def setup_logging(*, debug: bool = False) -> None:
41
+ """Configure logging based on the debug flag.
42
+
43
+ Args:
44
+ debug (bool): activate debug logs
45
+ """
46
+ logger = logging.getLogger()
47
+ for handler in logger.handlers:
48
+ if not isinstance(handler, logging.NullHandler):
49
+ logger.warning("Logger has already been initialized!")
50
+ return
51
+
52
+ ch = logging.StreamHandler()
53
+ if debug:
54
+ log_format = "%(asctime)s - %(levelname)s - %(message)s"
55
+ logger.setLevel(logging.DEBUG)
56
+ ch.setLevel(logging.DEBUG)
57
+ else:
58
+ log_format = "%(levelname)s - %(message)s"
59
+ logger.setLevel(logging.INFO)
60
+ ch.setLevel(logging.INFO)
61
+
62
+ formatter = SafeFormatter(log_format)
63
+ ch.setFormatter(formatter)
64
+ logger.addHandler(ch)
@@ -8,6 +8,8 @@ import re
8
8
 
9
9
  import requests
10
10
 
11
+ from urllib.parse import quote as url_quote
12
+
11
13
  from .package import PackageBase, QUERY_AGENT_STRING
12
14
 
13
15
  # Go Proxy API endpoints
@@ -71,6 +73,21 @@ class GoPackage(PackageBase):
71
73
 
72
74
  return package_path
73
75
 
76
+ @staticmethod
77
+ def _escape_module_path(package_path: str) -> str:
78
+ """Escape a module path per the Go module proxy protocol.
79
+
80
+ Args:
81
+ package_path (str): The module path.
82
+
83
+ Returns:
84
+ str: The URL escaped and encoded module path.
85
+ """
86
+ escaped = re.sub(
87
+ r"[A-Z]", lambda cap_char: "!" + cap_char.group(0).lower(), package_path
88
+ )
89
+ return url_quote(escaped, safe="/")
90
+
74
91
  def fetch_latest_version(self, connection_timeout: int) -> None:
75
92
  """Fetch the latest version of a Go module from proxy.golang.org.
76
93
 
@@ -80,17 +97,24 @@ class GoPackage(PackageBase):
80
97
  package_path = self._resolve_module_path(self.name)
81
98
  logging.debug("Fetching latest version for %s", package_path)
82
99
  try:
83
- url = GO_PKG_LATEST_VERSION_API_URL.format(package_path=package_path)
100
+ safe_package_path = self._escape_module_path(package_path)
101
+ url = GO_PKG_LATEST_VERSION_API_URL.format(package_path=safe_package_path)
84
102
  headers = {"User-Agent": QUERY_AGENT_STRING}
85
103
  response = requests.get(url, headers=headers, timeout=connection_timeout)
86
104
  response.raise_for_status()
87
105
 
88
- data = response.json()
89
- latest_version = data.get("Version")
90
-
91
- if isinstance(latest_version, str):
92
- self.latest_version = latest_version
93
-
106
+ if response.headers.get("Content-Type", "").startswith("application/json"):
107
+ data = response.json()
108
+ latest_version = data.get("Version")
109
+
110
+ if isinstance(latest_version, str):
111
+ self.latest_version = latest_version
112
+ else:
113
+ logging.warning(
114
+ "Unexpected content type for %s: %s",
115
+ package_path,
116
+ response.headers.get("Content-Type", "<missing>"),
117
+ )
94
118
  except requests.exceptions.RequestException as exc:
95
119
  logging.warning(
96
120
  "Could not fetch latest version for %s: %s", package_path, exc
@@ -75,6 +75,8 @@ class JuliaPackage(PackageBase):
75
75
  def fetch_latest_version(self, connection_timeout: int) -> None:
76
76
  """Fetch the latest version of a package from the Julia General Registry.
77
77
 
78
+ Ignores pre-release versions.
79
+
78
80
  Args:
79
81
  connection_timeout (int): Connection timeout in seconds.
80
82
  """
@@ -89,7 +91,8 @@ class JuliaPackage(PackageBase):
89
91
 
90
92
  data = tomllib.loads(response.text)
91
93
 
92
- versions = list(data.keys())
94
+ versions = [ver for ver in data.keys() if "-" not in ver.split("+", 1)[0]]
95
+
93
96
  if versions:
94
97
  versions.sort(key=lambda v: Version(v), reverse=True)
95
98
  self.latest_version = versions[0]
@@ -64,12 +64,18 @@ class NodePackage(PackageBase):
64
64
  headers = {"User-Agent": QUERY_AGENT_STRING}
65
65
  response = requests.get(url, headers=headers, timeout=connection_timeout)
66
66
  response.raise_for_status()
67
- data = response.json()
68
-
69
- latest_version = data.get("dist-tags", {}).get("latest")
70
- if isinstance(latest_version, str):
71
- self.latest_version = latest_version
72
67
 
68
+ if response.headers.get("Content-Type", "").startswith("application/json"):
69
+ data = response.json()
70
+ latest_version = data.get("dist-tags", {}).get("latest")
71
+ if isinstance(latest_version, str):
72
+ self.latest_version = latest_version
73
+ else:
74
+ logging.warning(
75
+ "Unexpected content type for %s: %s",
76
+ self.name,
77
+ response.headers.get("Content-Type", "<missing>"),
78
+ )
73
79
  except requests.exceptions.RequestException as exc:
74
80
  logging.warning("Could not fetch latest version for %s: %s", self.name, exc)
75
81
  except (KeyError, ValueError) as exc:
@@ -59,11 +59,18 @@ class PyPackage(PackageBase):
59
59
  headers = {"User-Agent": QUERY_AGENT_STRING}
60
60
  response = requests.get(url, headers=headers, timeout=connection_timeout)
61
61
  response.raise_for_status()
62
- data = response.json()
63
- latest_version = data.get("info", {}).get("version")
64
- if isinstance(latest_version, str):
65
- self.latest_version = latest_version
66
62
 
63
+ if response.headers.get("Content-Type", "").startswith("application/json"):
64
+ data = response.json()
65
+ latest_version = data.get("info", {}).get("version")
66
+ if isinstance(latest_version, str):
67
+ self.latest_version = latest_version
68
+ else:
69
+ logging.warning(
70
+ "Unexpected content type for %s: %s",
71
+ self.name,
72
+ response.headers.get("Content-Type", "<missing>"),
73
+ )
67
74
  except requests.exceptions.RequestException as exc:
68
75
  logging.warning("Could not fetch latest version for %s: %s", self.name, exc)
69
76
  except (KeyError, ValueError) as exc:
@@ -67,11 +67,18 @@ class RustPackage(PackageBase):
67
67
  headers = {"User-Agent": QUERY_AGENT_STRING}
68
68
  response = requests.get(url, headers=headers, timeout=connection_timeout)
69
69
  response.raise_for_status()
70
- data = response.json()
71
- latest_version = data.get("crate", {}).get("max_version")
72
- if isinstance(latest_version, str):
73
- self.latest_version = latest_version
74
70
 
71
+ if response.headers.get("Content-Type", "").startswith("application/json"):
72
+ data = response.json()
73
+ latest_version = data.get("crate", {}).get("max_version")
74
+ if isinstance(latest_version, str):
75
+ self.latest_version = latest_version
76
+ else:
77
+ logging.warning(
78
+ "Unexpected content type for %s: %s",
79
+ self.name,
80
+ response.headers.get("Content-Type", "<missing>"),
81
+ )
75
82
  except requests.exceptions.RequestException as exc:
76
83
  logging.warning("Could not fetch latest version for %s: %s", self.name, exc)
77
84
  except (KeyError, ValueError) as exc:
@@ -2,6 +2,7 @@
2
2
  # SPDX-License-Identifier: LGPL-3.0-or-later
3
3
 
4
4
  import logging
5
+ import time
5
6
  import re
6
7
  from pathlib import Path
7
8
 
@@ -13,10 +14,13 @@ from pre_commit_localupdate.error import PreCommitLocalUpdateError
13
14
  from pre_commit_localupdate.io import YAMLFormat, load_config_file, save_config_file
14
15
  from pre_commit_localupdate.packages import SUPPORTED_PACKAGES
15
16
 
17
+ FREEZE_PATTERN = re.compile(r"^#\s*freeze(\s|$)")
18
+
16
19
 
17
20
  def update_additional_dependencies(
18
21
  file_path: Path,
19
22
  connection_timeout: int,
23
+ request_delay: float,
20
24
  yaml_format: YAMLFormat,
21
25
  *,
22
26
  dry_run: bool = False,
@@ -30,6 +34,7 @@ def update_additional_dependencies(
30
34
  Args:
31
35
  file_path (Path): Path to the pre-commit configuration file.
32
36
  connection_timeout (int): Connection timeout in seconds.
37
+ request_delay (float): Minimum delay between network requests in seconds.
33
38
  yaml_format (YAMLFormat): YAML file formatting parameters
34
39
  dry_run (bool): Do not update the pre-commit configuration file.
35
40
 
@@ -66,7 +71,9 @@ def update_additional_dependencies(
66
71
  )
67
72
 
68
73
  update_required = False
69
- FREEZE_PATTERN = re.compile(r"^#\s*freeze(\s|$)")
74
+ latest_request_time: float = 0.0
75
+ # (package type, package name) -> latest version specification
76
+ queried_packages: dict[tuple[str, str], str | None] = {}
70
77
 
71
78
  if "repos" in config:
72
79
  logging.debug("Scanning repositories for local hooks")
@@ -90,7 +97,6 @@ def update_additional_dependencies(
90
97
  package_type = SUPPORTED_PACKAGES[hook.get("language")]
91
98
 
92
99
  for i, dep_str in enumerate(deps_list):
93
-
94
100
  comment = None
95
101
  if isinstance(deps_list, CommentedSeq):
96
102
  comment = deps_list.ca.items.get(i, [None, None])[0]
@@ -101,7 +107,7 @@ def update_additional_dependencies(
101
107
  logging.debug("Skipping frozen dependency: %s", dep_str)
102
108
  continue
103
109
 
104
- logging.debug("Checking dependency: %s", dep_str)
110
+ logging.debug("Processing dependency: %s", dep_str)
105
111
  clean_dep_str = dep_str.strip().strip('"').strip("'")
106
112
  package = package_type.from_specification(clean_dep_str)
107
113
  if not package:
@@ -110,22 +116,32 @@ def update_additional_dependencies(
110
116
  )
111
117
  continue
112
118
 
113
- package.fetch_latest_version(connection_timeout)
114
- if not package.latest_version_specification():
119
+ cache_key = (package_type.__name__, package.name)
120
+ if cache_key in queried_packages:
121
+ logging.debug(
122
+ "Using cached latest version for package: %s",
123
+ package.name,
124
+ )
125
+ latest_spec = queried_packages[cache_key]
126
+ else:
127
+ elapsed = time.monotonic() - latest_request_time
128
+ if elapsed < request_delay:
129
+ time.sleep(request_delay - elapsed)
130
+ latest_request_time = time.monotonic()
131
+ package.fetch_latest_version(connection_timeout)
132
+ latest_spec = package.latest_version_specification()
133
+ queried_packages[cache_key] = latest_spec
134
+
135
+ if latest_spec is None:
115
136
  continue
116
137
 
117
- if (
118
- package.latest_version_specification()
119
- != package.original_version_specification()
120
- ):
138
+ if latest_spec != package.original_version_specification():
121
139
  logging.debug(
122
140
  "%s needs updating to %s",
123
141
  package.original_version_specification(),
124
- package.latest_version_specification(),
142
+ latest_spec,
125
143
  )
126
- hook["additional_dependencies"][
127
- i
128
- ] = package.latest_version_specification()
144
+ hook["additional_dependencies"][i] = latest_spec
129
145
 
130
146
  update_required = True
131
147
  else:
@@ -29,7 +29,7 @@ dynamic = []
29
29
  name = "pre-commit-localupdate"
30
30
  readme = "README.md"
31
31
  requires-python = "<3.15,>=3.11"
32
- version = "0.5.2"
32
+ version = "0.6.0"
33
33
 
34
34
  [project.scripts]
35
35
  pre-commit-localupdate = "pre_commit_localupdate.__main__:main"
@@ -1 +0,0 @@
1
- __version__ = '0.5.2'
@@ -1,19 +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 sys
6
-
7
-
8
- def setup_logging(*, debug: bool = False) -> None:
9
- """Configure logging based on the debug flag.
10
-
11
- Args:
12
- debug (bool): activate debug logs
13
- """
14
- level = logging.DEBUG if debug else logging.INFO
15
- logging.basicConfig(
16
- level=level,
17
- format="%(asctime)s - %(levelname)s - %(message)s",
18
- stream=sys.stdout,
19
- )