python-update-checker 0.5__py3-none-any.whl

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.
puc/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # Author: Bastian Kleineidam
2
+ # Copyright: GPL-v3
3
+ """module file"""
puc/cli.py ADDED
@@ -0,0 +1,214 @@
1
+ # Author: Bastian Kleineidam
2
+ # Copyright: GPL-v3
3
+ """CLI to update pinned dependencies in pyproject.toml or requirements.txt files.
4
+ Needs uv (https://docs.astral.sh/uv/).
5
+ """
6
+
7
+ import subprocess
8
+ import argparse
9
+ import sys
10
+ import logging
11
+ import os
12
+ import tempfile
13
+ from packaging.utils import canonicalize_name
14
+ from typing import TextIO, Any
15
+
16
+ from .dependencies import (
17
+ get_latest_version,
18
+ )
19
+ from .logging import logger
20
+ from .pyprojecttoml import handle_pyproject_toml
21
+ from .requirementstxt import handle_requirements_txt
22
+ from .uvlock import handle_uv_lock
23
+
24
+
25
+ def usage(msg: str | None = None) -> None:
26
+ """Print usage info"""
27
+ if msg:
28
+ logger.error(msg)
29
+ p = get_option_parser()
30
+ logger.info(p.format_usage())
31
+ sys.exit(-1)
32
+
33
+
34
+ def handle_latest(package, optargs, constraint_file):
35
+ """Print latest version of package."""
36
+ exclude_newer = optargs.exclude_newer
37
+ try:
38
+ latest_version = get_latest_version(
39
+ package,
40
+ exclude_newer=exclude_newer,
41
+ constraint_file=constraint_file,
42
+ )
43
+ logger.info(f"{package}=={latest_version}")
44
+ except subprocess.CalledProcessError as exc:
45
+ # error getting latest version
46
+ err = f"{exc}, output={exc.output}, stderr={exc.stderr}"
47
+ logger.warning(f"error getting latest version for '{package}': {err}")
48
+
49
+
50
+ def supports_color(handle: TextIO | Any) -> bool:
51
+ """Determine if given file handle is a TTY suitable for color output."""
52
+ return hasattr(handle, "isatty") and handle.isatty()
53
+
54
+
55
+ def get_option_parser() -> argparse.ArgumentParser:
56
+ """Initialize and return the option parser.
57
+ @return: parser
58
+ @rtype: argparse.ArgumentParser
59
+ """
60
+ parser = argparse.ArgumentParser()
61
+ parser.add_argument(
62
+ "--exclude-newer",
63
+ dest="exclude_newer",
64
+ help="Limit package versions to those that were uploaded prior to the given date",
65
+ )
66
+ parser.add_argument(
67
+ "--constraints",
68
+ dest="constraints",
69
+ help="Constrain versions using the given requirements file or string",
70
+ )
71
+ parser.add_argument(
72
+ "--package",
73
+ dest="packages",
74
+ action="append",
75
+ help="Only update the given package, can be given multiple times",
76
+ )
77
+ parser.add_argument(
78
+ "--no-color",
79
+ action="store_false",
80
+ dest="color",
81
+ default=supports_color(sys.stdout),
82
+ help="Do not print colored updated versions.",
83
+ )
84
+ parser.add_argument(
85
+ "--debug",
86
+ action="store_true",
87
+ dest="debug",
88
+ default=False,
89
+ help="Print debug messages.",
90
+ )
91
+ subparsers = parser.add_subparsers(help='commands', dest='command')
92
+ parser_check = subparsers.add_parser('check', help='check for updated versions')
93
+ parser_check.add_argument(
94
+ "dep_files",
95
+ nargs="+",
96
+ help="pyproject.toml or requirements.txt file",
97
+ )
98
+
99
+ parser_update = subparsers.add_parser('update', help='update to latest versions')
100
+ parser_update.add_argument(
101
+ "dep_files",
102
+ nargs="+",
103
+ help="pyproject.toml or requirements.txt file",
104
+ )
105
+
106
+ parser_latest = subparsers.add_parser(
107
+ 'latest', help='get latest version of packages'
108
+ )
109
+ parser_latest.add_argument(
110
+ "packages",
111
+ nargs="+",
112
+ help="package names",
113
+ )
114
+ return parser
115
+
116
+
117
+ def handle_dependency_file(dep_file: str, optargs, constraint_file):
118
+ """Check a dependency file for updates."""
119
+ if not os.path.isfile(dep_file):
120
+ usage(f"file {dep_file} not found or not a regular file")
121
+ # limit to 1MB to prevent denial-of-service
122
+ if os.stat(dep_file).st_size > 1024 * 1014:
123
+ usage(f"file {dep_file} is >1 MB")
124
+ dep_file_normalized = os.path.basename(dep_file).lower()
125
+ if optargs.packages:
126
+ packages = [canonicalize_name(name) for name in optargs.packages]
127
+ else:
128
+ packages = None
129
+ if dep_file_normalized == "pyproject.toml":
130
+ # pyproject.toml format
131
+ updatable = handle_pyproject_toml(
132
+ dep_file,
133
+ packages=packages,
134
+ command=optargs.command,
135
+ exclude_newer=optargs.exclude_newer,
136
+ constraint_file=constraint_file,
137
+ color=optargs.color,
138
+ )
139
+ elif dep_file_normalized == "uv.lock":
140
+ # pyproject.toml format
141
+ updatable = handle_uv_lock(
142
+ dep_file,
143
+ packages=packages,
144
+ command=optargs.command,
145
+ exclude_newer=optargs.exclude_newer,
146
+ constraint_file=constraint_file,
147
+ color=optargs.color,
148
+ )
149
+ elif dep_file_normalized.endswith((".txt", ".in")):
150
+ # requirements.txt format
151
+ updatable = handle_requirements_txt(
152
+ dep_file,
153
+ packages=packages,
154
+ command=optargs.command,
155
+ exclude_newer=optargs.exclude_newer,
156
+ constraint_file=constraint_file,
157
+ color=optargs.color,
158
+ )
159
+ else:
160
+ usage(
161
+ f"no pyproject.toml or requirements.txt format detected for file {dep_file!r}"
162
+ )
163
+ return updatable
164
+
165
+
166
+ def main() -> int:
167
+ """Parse options and check or update dependencies."""
168
+ # parse options
169
+ try:
170
+ optargs = get_option_parser().parse_args(sys.argv[1:])
171
+ except argparse.ArgumentError as exc:
172
+ logger.exception(exc)
173
+ usage()
174
+ # handle options
175
+ if optargs.debug:
176
+ logger.setLevel(logging.DEBUG)
177
+ remove_constraint_file = False
178
+ constraints = optargs.constraints
179
+ constraint_file = None
180
+ # if constraints is a string write it in a temporary constraint file
181
+ if constraints:
182
+ if os.path.isfile(constraints):
183
+ constraint_file = constraints
184
+ else:
185
+ _fd, constraint_file = tempfile.mkstemp(
186
+ suffix=".txt", prefix="puc-constraints-"
187
+ )
188
+ with open(constraint_file, "w") as f:
189
+ f.write(constraints)
190
+ remove_constraint_file = True
191
+ # handle all given dependency files
192
+ try:
193
+ if optargs.command in ("check", "update"):
194
+ for dep_file in optargs.dep_files:
195
+ updatable = handle_dependency_file(dep_file, optargs, constraint_file)
196
+ elif optargs.command == "latest":
197
+ for package in optargs.packages:
198
+ handle_latest(package, optargs, constraint_file)
199
+ elif not optargs.command:
200
+ usage("missing command")
201
+ else:
202
+ usage(f"unknown command {optargs.command}")
203
+ except Exception as exc:
204
+ logger.error(f"error handling command {optargs.command}: {exc}")
205
+ return -1
206
+ finally:
207
+ if remove_constraint_file and constraint_file:
208
+ os.unlink(constraint_file)
209
+ # check command returns non-zero exit code when updates are available
210
+ return 1 if optargs.command == "check" and updatable > 0 else 0
211
+
212
+
213
+ # if __name__ == "__main__":
214
+ # sys.exit(main(sys.argv[1:]))
puc/dependencies.py ADDED
@@ -0,0 +1,187 @@
1
+ # Author: Bastian Kleineidam
2
+ # Copyright: GPL-v3
3
+ """Dependency helper functions."""
4
+
5
+ import subprocess
6
+ import re
7
+ from packaging.requirements import Requirement
8
+ from packaging.markers import Variable, MarkerList
9
+ from packaging.version import parse as parse_version
10
+ from .logging import logger
11
+
12
+
13
+ def get_latest_version(
14
+ package: str,
15
+ exclude_newer: None | str = None,
16
+ constraint_file: None | str = None,
17
+ python_platform: str | None = None,
18
+ python_version: str | None = None,
19
+ ) -> str:
20
+ """Get the latest version of a package.
21
+ `python_platform` defines the platform for which requirements should be resolved.
22
+ `python_version` defines the minimum Python version that must be supported by the resolved requirements. If a patch version is omitted, the minimum patch version is assumed. For example, 3.8 is mapped to 3.8.0.
23
+ """
24
+ cmd = [
25
+ "uv",
26
+ "pip",
27
+ "compile",
28
+ "-",
29
+ "--color=never",
30
+ "--quiet",
31
+ "--no-deps",
32
+ "--no-header",
33
+ "--no-annotate",
34
+ "--no-progress",
35
+ ]
36
+ if exclude_newer:
37
+ cmd.extend(("--exclude-newer", exclude_newer))
38
+ if constraint_file:
39
+ cmd.extend(("--constraints", constraint_file))
40
+ if python_platform:
41
+ cmd.extend(("--python-platform", python_platform))
42
+ if python_version:
43
+ cmd.extend(("--python-version", python_version))
44
+ logger.debug(f"running '{' '.join(cmd)}' with input {package!r}")
45
+ result = subprocess.run(
46
+ cmd, check=True, text=True, input=package, capture_output=True
47
+ )
48
+ package_spec = result.stdout.strip()
49
+ return package_spec.split("==", 1)[1]
50
+
51
+
52
+ def get_python_platform(
53
+ os_name: str | None = None, sys_platform: str | None = None
54
+ ) -> str | None:
55
+ """Translate os_name or sys_platform values into python uv --python-platform values.
56
+ The translation is very coarse and not complete, but should be suitable for common
57
+ cases.
58
+ See https://peps.python.org/pep-0508/#environment-markers and
59
+ https://docs.astral.sh/uv/reference/cli/#uv-pip-compile--python-platform
60
+ """
61
+ if os_name == "nt":
62
+ return "windows"
63
+ if os_name == "posix":
64
+ return "linux"
65
+ if sys_platform == "win32":
66
+ return "windows"
67
+ if sys_platform == "linux":
68
+ return "linux"
69
+ if sys_platform == "darwin":
70
+ return "macos"
71
+ return None
72
+
73
+
74
+ def check_requirement(
75
+ pkg_req: Requirement, projectname: str | None = None
76
+ ) -> Requirement | None:
77
+ """Check if requirement is pinned, else log and return None."""
78
+ if projectname and pkg_req.name == projectname:
79
+ logger.info(f"skip project name dependency {pkg_req}")
80
+ return None
81
+ if pkg_req.url:
82
+ logger.info(f"skip URL-pinned package dependency '{pkg_req}'")
83
+ return None
84
+ if len(pkg_req.specifier) < 1:
85
+ logger.info(f"skip non-versioned dependency '{pkg_req}'")
86
+ return None
87
+ if len(pkg_req.specifier) > 1:
88
+ logger.info(f"skip multi-versioned dependency '{pkg_req}'")
89
+ return None
90
+ for spec in pkg_req.specifier:
91
+ if spec.operator not in ('==', '==='):
92
+ logger.info(f"skip unpinned dependency '{pkg_req}'")
93
+ return None
94
+ if "*" in spec.version:
95
+ logger.info(f"skip unpinned *-patterned version dependency '{pkg_req}'")
96
+ return None
97
+ return pkg_req
98
+ return None
99
+
100
+
101
+ def get_python_platform_from_req(pkg_req: Requirement) -> str | None:
102
+ """Determine value to use for 'uv pip compile --python-platform'"""
103
+ if not pkg_req.marker:
104
+ return None
105
+ markerlist = pkg_req.marker._markers
106
+ return get_python_platform(
107
+ os_name=get_marker_value(markerlist, "os_name", opfilter=("==",)),
108
+ sys_platform=get_marker_value(markerlist, "sys_platform", opfilter=("==",)),
109
+ )
110
+
111
+
112
+ def get_min_python_version_from_req(pkg_req: Requirement) -> str | None:
113
+ """Determine value to use for 'uv pip compile --python-version'"""
114
+ if not pkg_req.marker:
115
+ return None
116
+ markerlist = pkg_req.marker._markers
117
+ return get_marker_value(markerlist, "python_version", opfilter=(">=", ""))
118
+
119
+
120
+ def parse_requirement(
121
+ line: str,
122
+ exclude_newer: None | str = None,
123
+ constraint_file: None | str = None,
124
+ ) -> None | str | Requirement:
125
+ """Parse one line of a requirements.txt file."""
126
+ line = line.strip()
127
+ if not line or line.startswith("#"):
128
+ # ignore comments
129
+ return None
130
+ if line.endswith("\\"):
131
+ line = line[:-1]
132
+ if line.startswith("--hash"):
133
+ logger.info(f"ignore requirements hash {line!r}")
134
+ return None
135
+ if re.search(r"^-r\s+", line):
136
+ # recursion
137
+ return line.split(maxsplit=1)[1].strip()
138
+ if re.search(r"^-c\s+", line):
139
+ logger.info(
140
+ f"ignore constraints reference {line!r}, use puc --constraints instead"
141
+ )
142
+ return None
143
+ if line.startswith("./"):
144
+ # ignore local file references
145
+ logger.info(f"skip local-file pinned dependency {line!r}")
146
+ return None
147
+ if line.lower().startswith(("http://", "https://")):
148
+ # ignore local file references
149
+ logger.info(f"skip URL-pinned dependency {line!r}")
150
+ return None
151
+ # remove trailing comment
152
+ line = re.sub("#.*$", "", line)
153
+ try:
154
+ pkg_req = Requirement(line)
155
+ except Exception as exc:
156
+ logger.debug(f"error parsing exception: {exc}")
157
+ logger.info(f"skip unsupported dependency {line!r}")
158
+ return None
159
+ return check_requirement(pkg_req)
160
+
161
+
162
+ def get_marker_value(
163
+ markerlist: MarkerList, varname: str, opfilter: tuple[str, ...] | None = None
164
+ ) -> str | None:
165
+ """Search variable definitions in markerlist.
166
+ `varname`: variable name to match
167
+ `opfilter`: optional operator string to match.
168
+ """
169
+ for marker in markerlist:
170
+ if isinstance(marker, tuple):
171
+ left, op, right = marker
172
+ if opfilter and op.serialize() not in opfilter:
173
+ continue
174
+ if isinstance(left, Variable):
175
+ var = left.value
176
+ value = right.value
177
+ else:
178
+ var = right.value
179
+ value = left.value
180
+ if var == varname:
181
+ return value
182
+ return None
183
+
184
+
185
+ def is_newer_version(old_version, new_version):
186
+ """Check that new_version is newer than old_version."""
187
+ return parse_version(new_version) > parse_version(old_version)
puc/logging.py ADDED
@@ -0,0 +1,76 @@
1
+ # Author: Bastian Kleineidam
2
+ # Copyright: GPL-v3
3
+ """Logging initialization."""
4
+
5
+ import logging
6
+ import sys
7
+
8
+ logger = logging.getLogger("puc")
9
+
10
+
11
+ def init_logging(stream=sys.stdout) -> None:
12
+ """Configure the global logger.
13
+ All log messages will be sent to the given stream, default is sys.stdout.
14
+ """
15
+ # do not propagate log message to higher log level handlers (in our case the root level)
16
+ logger.propagate = False
17
+ handler = logging.StreamHandler(stream=stream)
18
+ format = "%(name)s %(levelname)s: %(message)s"
19
+ handler.setFormatter(logging.Formatter(format))
20
+ logger.addHandler(handler)
21
+ # set the log level to INFO, and change to DEBUG with --verbose
22
+ logger.setLevel(logging.INFO)
23
+
24
+
25
+ init_logging()
26
+
27
+
28
+ # ANSI color codes
29
+ ansi_colors = {
30
+ 'red': '\033[31m',
31
+ 'cyan': '\033[36m',
32
+ 'green': '\033[32m',
33
+ 'reset': '\033[0m',
34
+ }
35
+
36
+
37
+ def colorize_updated_version(from_ver: str, to_ver: str) -> str:
38
+ """Colorize an updated version `to_ver` (`from_ver` is the old version).
39
+ Assumes both versions are semver strings.
40
+ Logic for coloring:
41
+ - red: major version change or any change before 1.0.0
42
+ - cyan: minor version change
43
+ - green: patch version change
44
+ """
45
+ # split into parts for comparing
46
+ parts_to_ver = to_ver.split('.')
47
+ parts_from_ver = from_ver.split('.')
48
+
49
+ # find the index of the first difference
50
+ index = len(parts_to_ver)
51
+ for i, part in enumerate(parts_to_ver):
52
+ if i >= len(parts_from_ver):
53
+ # '1' --> '1.1'
54
+ index = i
55
+ break
56
+ if part != parts_from_ver[i]:
57
+ # '1.0' --> '1.1'
58
+ index = i
59
+ break
60
+
61
+ # coloring
62
+ if index == 0 or (len(parts_to_ver) > 0 and parts_to_ver[0] == '0'):
63
+ color = 'red'
64
+ elif index == 1:
65
+ color = 'cyan'
66
+ else:
67
+ color = 'green'
68
+
69
+ # construct the final string
70
+ first_part = ".".join(parts_to_ver[:index])
71
+ second_part = ".".join(parts_to_ver[index:])
72
+ middle_dot = '.' if 0 < index < len(parts_to_ver) else ""
73
+ if second_part:
74
+ # add color
75
+ second_part = f"{ansi_colors[color]}{second_part}{ansi_colors['reset']}"
76
+ return f"{first_part}{middle_dot}{second_part}"
puc/pyprojecttoml.py ADDED
@@ -0,0 +1,179 @@
1
+ # Author: Bastian Kleineidam
2
+ # Copyright: GPL-v3
3
+ """Handle pyproject.toml files."""
4
+
5
+ import subprocess
6
+ import os
7
+ import tomllib
8
+ from packaging.utils import canonicalize_name
9
+ from packaging.requirements import Requirement
10
+
11
+ from .logging import logger, colorize_updated_version
12
+ from .dependencies import (
13
+ get_latest_version,
14
+ get_python_platform_from_req,
15
+ get_min_python_version_from_req,
16
+ check_requirement,
17
+ )
18
+
19
+
20
+ def handle_pyproject_toml(
21
+ pyproject_path: str,
22
+ command: None | str = None,
23
+ packages=None,
24
+ exclude_newer: None | str = None,
25
+ constraint_file: None | str = None,
26
+ color: bool = True,
27
+ ) -> int:
28
+ """Check or update pinned dependencies of a pyproject.toml file.
29
+ Specification: https://packaging.python.org/en/latest/specifications/pyproject-toml/
30
+ Friendly guide: https://packaging.python.org/en/latest/guides/writing-pyproject-toml/
31
+ """
32
+ logger.info(f"{command} pyproject file {pyproject_path}")
33
+ updatable = 0
34
+ project_dir = os.path.abspath(os.path.dirname(pyproject_path))
35
+ # parse pyproject.toml
36
+ with open(pyproject_path, "rb") as f:
37
+ try:
38
+ pyproject = tomllib.load(f)
39
+ except Exception as exc:
40
+ logger.error(f"error parsing {pyproject_path}: {exc}")
41
+ return updatable
42
+ project = pyproject.get("project", dict())
43
+ if not project:
44
+ logger.warning(f"no project defined in {pyproject_path}")
45
+ return updatable
46
+ projectname = project.get("name", None)
47
+ # project dependencies
48
+ if "dependencies" in project:
49
+ updatable += update_pyproject_dependencies(
50
+ project["dependencies"],
51
+ project_dir,
52
+ projectname,
53
+ command=command,
54
+ packages=packages,
55
+ exclude_newer=exclude_newer,
56
+ constraint_file=constraint_file,
57
+ color=color,
58
+ )
59
+ # update optional dependencies
60
+ for group, deps in project.get("optional-dependencies", {}).items():
61
+ updatable += update_pyproject_dependencies(
62
+ deps,
63
+ project_dir,
64
+ projectname,
65
+ group=group,
66
+ optional=True,
67
+ command=command,
68
+ packages=packages,
69
+ exclude_newer=exclude_newer,
70
+ constraint_file=constraint_file,
71
+ color=color,
72
+ )
73
+ # update dependency groups
74
+ for group, deps in pyproject.get("dependency-groups", {}).items():
75
+ updatable += update_pyproject_dependencies(
76
+ deps,
77
+ project_dir,
78
+ projectname,
79
+ group=group,
80
+ command=command,
81
+ packages=packages,
82
+ exclude_newer=exclude_newer,
83
+ constraint_file=constraint_file,
84
+ color=color,
85
+ )
86
+ if command == "update":
87
+ logger.info(f"updated {updatable} package version(s) in {pyproject_path}")
88
+ return updatable
89
+
90
+
91
+ def update_pyproject_dependencies(
92
+ dependencies: list[str | dict],
93
+ project_dir: str,
94
+ projectname: str,
95
+ group: None | str = None,
96
+ optional=False,
97
+ command: None | str = None,
98
+ packages=None,
99
+ exclude_newer: None | str = None,
100
+ constraint_file: None | str = None,
101
+ color: bool = True,
102
+ ) -> int:
103
+ """Update given dependency list of a pyproject.toml file."""
104
+ updatable = 0
105
+ for dep in dependencies:
106
+ if isinstance(dep, dict):
107
+ logger.debug(f"skip include-group dependency {dep!r} in group {group}")
108
+ continue
109
+ try:
110
+ pkg_req = Requirement(dep)
111
+ except Exception as exc:
112
+ logger.debug(f"error parsing requirement: {exc}")
113
+ logger.info(f"skip unsupported dependency {dep!r}")
114
+ continue
115
+ if check_requirement(pkg_req, projectname=projectname) is None:
116
+ continue
117
+
118
+ # respect optional package filter
119
+ if packages and canonicalize_name(pkg_req.name) not in packages:
120
+ continue
121
+ try:
122
+ latest_version = get_latest_version(
123
+ pkg_req.name,
124
+ exclude_newer=exclude_newer,
125
+ constraint_file=constraint_file,
126
+ python_platform=get_python_platform_from_req(pkg_req),
127
+ python_version=get_min_python_version_from_req(pkg_req),
128
+ )
129
+ except subprocess.CalledProcessError as exc:
130
+ # error getting latest version
131
+ err = f"{exc}, output={exc.output}, stderr={exc.stderr}"
132
+ logger.warning(f"error getting latest version for '{pkg_req}': {err}")
133
+ latest_version = None
134
+ spec = next(s for s in pkg_req.specifier)
135
+ if latest_version is not None and latest_version != spec.version:
136
+ updatable += 1
137
+ version = (
138
+ colorize_updated_version(spec.version, latest_version)
139
+ if color
140
+ else latest_version
141
+ )
142
+ if command == "check":
143
+ logger.warning(f"found update '{dep}' --> {version}")
144
+ else:
145
+ logger.info(f"updating '{dep}' --> {version}")
146
+ newdep = dep.replace(spec.version, latest_version, 1)
147
+ update_pyproject_pkg(
148
+ newdep, pkg_req.name, project_dir, group=group, optional=optional
149
+ )
150
+ return updatable
151
+
152
+
153
+ def update_pyproject_pkg(
154
+ dependency: str,
155
+ package: str,
156
+ projectdir: str,
157
+ group: None | str = None,
158
+ optional: bool = False,
159
+ ) -> None:
160
+ """Update one package in pyproject.toml."""
161
+ command = [
162
+ "uv",
163
+ "add",
164
+ "--project",
165
+ projectdir,
166
+ "--quiet",
167
+ "--frozen",
168
+ "--color=never",
169
+ f"--upgrade-package={package}",
170
+ ]
171
+ if optional and group:
172
+ command.append("--optional")
173
+ command.append(group)
174
+ elif group:
175
+ command.append("--group")
176
+ command.append(group)
177
+ command.append(f"{dependency}")
178
+ logger.debug(f"running {' '.join(command)}")
179
+ subprocess.check_call(command)
puc/requirementstxt.py ADDED
@@ -0,0 +1,116 @@
1
+ # Author: Bastian Kleineidam
2
+ # Copyright: GPL-v3
3
+ """Handle requirements.txt files."""
4
+
5
+ import subprocess
6
+ import os
7
+ import re
8
+ import io
9
+ from packaging.utils import canonicalize_name
10
+
11
+ from .logging import logger, colorize_updated_version
12
+ from .dependencies import (
13
+ get_latest_version,
14
+ get_python_platform_from_req,
15
+ get_min_python_version_from_req,
16
+ parse_requirement,
17
+ )
18
+
19
+
20
+ # maximum recursion level for requirements.txt
21
+ max_rec_level = 5
22
+
23
+
24
+ def handle_requirements_txt(
25
+ requirements_txt_path: str,
26
+ command: None | str = None,
27
+ packages=None,
28
+ exclude_newer: None | str = None,
29
+ constraint_file: None | str = None,
30
+ color: bool = True,
31
+ rec_level: int = 0,
32
+ handled_files: list[str] | None = None,
33
+ ) -> int:
34
+ """Check or update pinned dependencies of a requirements.txt file."""
35
+ msg = f"{command} requirements file {requirements_txt_path}"
36
+ if rec_level > 0:
37
+ msg += f", recursion level {rec_level}"
38
+ logger.info(msg)
39
+ if rec_level > max_rec_level:
40
+ logger.error(f"recursion level greater than maximum {max_rec_level}, ignoring")
41
+ return 0
42
+ if handled_files is None:
43
+ handled_files = [os.path.abspath(requirements_txt_path)]
44
+ else:
45
+ handled_files.append(os.path.abspath(requirements_txt_path))
46
+ output = io.StringIO()
47
+ updatable = 0
48
+ with open(requirements_txt_path) as f:
49
+ for line in f:
50
+ pkg_req = parse_requirement(
51
+ line, exclude_newer=exclude_newer, constraint_file=constraint_file
52
+ )
53
+ if pkg_req is None:
54
+ output.write(line)
55
+ elif isinstance(pkg_req, str):
56
+ output.write(line)
57
+ base_dir = os.path.dirname(requirements_txt_path)
58
+ requirements_txt_child = os.path.join(base_dir, pkg_req)
59
+ if os.path.abspath(requirements_txt_child) not in handled_files:
60
+ updatable += handle_requirements_txt(
61
+ requirements_txt_child,
62
+ command,
63
+ packages=packages,
64
+ exclude_newer=exclude_newer,
65
+ constraint_file=constraint_file,
66
+ color=color,
67
+ rec_level=rec_level + 1,
68
+ handled_files=handled_files,
69
+ )
70
+ else:
71
+ try:
72
+ latest_version = get_latest_version(
73
+ pkg_req.name,
74
+ exclude_newer=exclude_newer,
75
+ constraint_file=constraint_file,
76
+ python_platform=get_python_platform_from_req(pkg_req),
77
+ python_version=get_min_python_version_from_req(pkg_req),
78
+ )
79
+ except subprocess.CalledProcessError as exc:
80
+ # error getting latest version
81
+ err = f"{exc}, output={exc.output}, stderr={exc.stderr}"
82
+ logger.warning(
83
+ f"error getting latest version for {pkg_req.name!r}: {err}"
84
+ )
85
+ latest_version = None
86
+ spec = next(s for s in pkg_req.specifier)
87
+ if packages and canonicalize_name(pkg_req.name) not in packages:
88
+ output.write(line)
89
+ elif latest_version is not None and latest_version != spec.version:
90
+ updatable += 1
91
+ version = (
92
+ colorize_updated_version(spec.version, latest_version)
93
+ if color
94
+ else latest_version
95
+ )
96
+ if command == "check":
97
+ logger.warning(f"found update '{line.strip()}' --> {version}")
98
+ output.write(line)
99
+ else:
100
+ logger.info(f"updating '{line.strip()}' --> {version}")
101
+ output.write(
102
+ re.sub(
103
+ rf"(===?\s*){re.escape(spec.version)}",
104
+ rf"\g<1>{latest_version}",
105
+ line,
106
+ count=1,
107
+ )
108
+ )
109
+ else:
110
+ output.write(line)
111
+ if command == "update":
112
+ if updatable > 0:
113
+ with open(requirements_txt_path, "w") as f:
114
+ f.write(output.getvalue())
115
+ logger.info("updated {updatable} package version(s) in {requirements_txt_path}")
116
+ return updatable
puc/uvlock.py ADDED
@@ -0,0 +1,133 @@
1
+ # Author: Bastian Kleineidam
2
+ # Copyright: GPL-v3
3
+ """Handle uv.lock files."""
4
+
5
+ import subprocess
6
+ import os
7
+ import tomllib
8
+ from packaging.utils import canonicalize_name
9
+
10
+ from .logging import logger, colorize_updated_version
11
+ from .dependencies import (
12
+ get_latest_version,
13
+ is_newer_version,
14
+ )
15
+
16
+
17
+ def handle_uv_lock(
18
+ uvlock_path: str,
19
+ command: None | str = None,
20
+ packages=None,
21
+ exclude_newer: None | str = None,
22
+ constraint_file: None | str = None,
23
+ color: bool = True,
24
+ ) -> int:
25
+ """Check or update pinned dependencies of a uv.lock file."""
26
+ # warn about constraint_file?
27
+ logger.info(f"{command} lock file {uvlock_path}")
28
+ updatable = 0
29
+ project_dir = os.path.abspath(os.path.dirname(uvlock_path))
30
+ # parse uv.lock
31
+ with open(uvlock_path, "rb") as f:
32
+ try:
33
+ uvlock = tomllib.load(f)
34
+ except Exception as exc:
35
+ logger.error(f"error parsing {uvlock_path}: {exc}")
36
+ return updatable
37
+ uvpackages = uvlock.get("package", [])
38
+ if not uvpackages:
39
+ logger.warning(f"no packages defined in {uvlock_path}")
40
+ return updatable
41
+
42
+ for uvpackage in uvpackages:
43
+ name = uvpackage.get("name", None)
44
+ if name is None:
45
+ logger.warning(f"missing name in package {uvpackage}")
46
+ continue
47
+ version = uvpackage.get("version", None)
48
+ if version is None:
49
+ logger.warning(f"missing version in package {uvpackage}")
50
+ continue
51
+ updatable += update_uvlock_dependency(
52
+ name,
53
+ version,
54
+ project_dir,
55
+ command=command,
56
+ packages=packages,
57
+ exclude_newer=exclude_newer,
58
+ constraint_file=constraint_file,
59
+ color=color,
60
+ )
61
+ if command == "update":
62
+ logger.info(f"updated {updatable} package version(s) in {uvlock_path}")
63
+ return updatable
64
+
65
+
66
+ def update_uvlock_dependency(
67
+ package,
68
+ version,
69
+ project_dir: str,
70
+ command: None | str = None,
71
+ packages=None,
72
+ exclude_newer: None | str = None,
73
+ constraint_file: None | str = None,
74
+ color: bool = True,
75
+ ) -> int:
76
+ """Update uv lock dependency."""
77
+ # respect optional package filter
78
+ if packages and canonicalize_name(package) not in packages:
79
+ return 0
80
+
81
+ try:
82
+ latest_version = get_latest_version(
83
+ package,
84
+ exclude_newer=exclude_newer,
85
+ constraint_file=constraint_file,
86
+ )
87
+ except subprocess.CalledProcessError as exc:
88
+ # error getting latest version
89
+ err = f"{exc}, output={exc.output}, stderr={exc.stderr}"
90
+ logger.warning(f"error getting latest version for '{package}': {err}")
91
+ return 0
92
+ if latest_version == version:
93
+ return 0
94
+ if not is_newer_version(version, latest_version):
95
+ logger.warning(
96
+ f"{package} latest version {latest_version} is older than specified version {version}"
97
+ )
98
+ return 0
99
+ newversion = (
100
+ colorize_updated_version(version, latest_version) if color else latest_version
101
+ )
102
+ if command == "check":
103
+ logger.warning(f"found update '{package}=={version}' --> {newversion}")
104
+ else:
105
+ logger.info(f"updating '{package}=={version}' --> {newversion}")
106
+ update_uvlock_pkg(package, project_dir, exclude_newer=exclude_newer)
107
+ return 1
108
+
109
+
110
+ def update_uvlock_pkg(
111
+ package: str,
112
+ projectdir: str,
113
+ exclude_newer: None | str = None,
114
+ ) -> None:
115
+ """Update one package in pyproject.toml."""
116
+ command = [
117
+ "uv",
118
+ "lock",
119
+ "--project",
120
+ projectdir,
121
+ "--quiet",
122
+ "--color=never",
123
+ f"--upgrade-package={package}",
124
+ ]
125
+ if exclude_newer:
126
+ command.extend(
127
+ (
128
+ "--exclude-newer",
129
+ exclude_newer,
130
+ )
131
+ )
132
+ logger.debug(f"running {' '.join(command)}")
133
+ subprocess.check_call(command)
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-update-checker
3
+ Version: 0.5
4
+ License-Expression: GPL-3.0-only
5
+ Classifier: Environment :: Console
6
+ Classifier: Intended Audience :: Developers
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Software Development :: Build Tools
15
+ Requires-Dist: packaging>=26.1
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+
19
+ python-update-checker
20
+ ======================
21
+ Python-update-checker (puc) checks or updates pinned dependencies
22
+ in `pyproject.toml`, `requirements.txt` or `uv.lock` files.
23
+
24
+ See https://github.com/astral-sh/uv/issues/6794 for a discussion
25
+ about different pinning strategies.
26
+
27
+
28
+ Features
29
+ ---------
30
+
31
+ * updates pinned dependencies, ignores unpinned dependencies
32
+ * supports pyproject.toml, uv.lock and requirements.txt formats
33
+ * supports `[project.dependencies]`, `[project.optional-dependencies]` and `[dependency-groups]` in pyproject.toml
34
+ * supports recursive references (-r) in requirements.txt formats
35
+ * can run in check only mode, ie. it checks if updates are available
36
+ * limit updates to specific packages
37
+ * limit updates with package version constraints (ie. "django<6")
38
+ * limit updates to versions that were uploaded prior to a given date
39
+ * (limited) support for environment markers, ie. `"pywin32==311; os_name=='nt'"`
40
+ * runs on Linux, MacOS and Windows platforms
41
+
42
+
43
+ Examples
44
+ ---------
45
+
46
+ ```bash
47
+ $ # check all pinned packages for updates in pyproject.toml
48
+ $ puc check pyproject.toml
49
+ puc INFO: check pyproject file pyproject.toml
50
+ puc WARNING: found update 'ty==0.0.29' --> 0.0.32
51
+
52
+ $ # update all pinned packages in pyproject.toml
53
+ $ # limit updates to versions that are at least 7 days old
54
+ $ puc --exclude-newer="7 days" update pyproject.toml
55
+ puc INFO: update pyproject file pyproject.toml
56
+ puc INFO: updating 'ty==0.0.29' --> 0.0.31
57
+ puc INFO: Wrote 1 updated package version(s) to pyproject.toml
58
+
59
+ $ # check all pinned packages for updates in requirements.txt
60
+ $ puc check requirements.txt
61
+ puc INFO: check requirements file requirements.txt
62
+ puc WARNING: found update 'argcomplete==3.6.1' --> 3.6.3
63
+ puc WARNING: found update 'Django==5.2.0' --> 6.0.4
64
+
65
+ $ # update only the django package version in requirements.txt
66
+ $ # limit updates to django versions less than 6
67
+ $ puc --package="Django" --constraints="Django<6" update requirements.txt
68
+ INFO: update requirements file requirements.txt
69
+ INFO: updating 'Django==5.2.0' --> 5.2.13
70
+ INFO: Wrote 1 updated package version(s) to requirements.txt
71
+ ```
72
+
73
+ Script behaviour
74
+ -----------------
75
+
76
+ The exit code of `puc check` is non-zero when updates are available.
77
+
78
+ Checking a `pyproject.toml` or `uv.lock`file with `puc` should be done
79
+ from the project of the `pyproject.toml` file,
80
+ especially if your project relies on a [project directory](https://docs.astral.sh/uv/concepts/projects/layout/) (for example to define additional packages index in pyproject.toml).
81
+
82
+ After updating versions in pyproject.toml, run `uv lock --upgrade` to update
83
+ the transitive dependencies in `uv.lock`.
84
+
85
+ Pinned dependencies are packages with `==` or `===` constraints and no wildcards in the version.
86
+
87
+
88
+ Installation
89
+ -------------
90
+
91
+ 1) Install [python uv](https://docs.astral.sh/uv/getting-started/installation/)
92
+ 2) Install puc with `uv pip install python-update-checker`.
93
+
94
+
95
+
96
+ Architecture
97
+ -------------
98
+
99
+ Dependencies are
100
+
101
+ * [uv](https://docs.astral.sh/uv/):
102
+ The uv binary must be available for the script to call.
103
+ puc uses `echo "package" | uv pip compile -` to get latest package versions.
104
+ puc uses `uv add "package==<version>"` to update pyproject.toml dependencies.
105
+
106
+ * [packaging](https://packaging.pypa.io/):
107
+ Parses dependencies with the packaging.requirements.Requirement class.
108
+
109
+ puc needs Python >= 3.11 since it uses the tomllib Python module.
110
+
111
+ puc consists of a single python script. The script uses [inline script metadata](https://peps.python.org/pep-0723/) to be executed directly with `uv run --script`.
112
+ This enables simple packaging and installation.
113
+
114
+
115
+ Limitations
116
+ ------------
117
+
118
+ * No support for custom dependency formats in pyproject.toml
119
+ (eg. `[tool.poetry.dependencies]`).
120
+ * puc has limited support for environment markers.
121
+ * Constraint references (`-c`) inside requirements.txt are not supported.
122
+ Use the `--constraints` option instead.
@@ -0,0 +1,11 @@
1
+ puc/__init__.py,sha256=QCJeo6rsztgAeIuufaaId2f-x_XYLjs4UywOPp4w7ks,67
2
+ puc/cli.py,sha256=aLQ52sA59lc8xAK7gjR36nTqUYxZvkvh467i0cx8Zso,6956
3
+ puc/dependencies.py,sha256=7MY8fyGGGayztpQfPAyCKXnlPUABXJDF9mwqmeMEjZs,6450
4
+ puc/logging.py,sha256=VBojYqr7-l-aLeNqrBqTJyHuEINzdBx1CObQg5XiC-k,2222
5
+ puc/pyprojecttoml.py,sha256=TMhNEWqlRfjichuU0nVGHbBHFhYALu12DuOSyobT5oE,6179
6
+ puc/requirementstxt.py,sha256=V075kYSuD35cGJ8XGdjq_24_aSUcJi43KH9ALov4rSQ,4604
7
+ puc/uvlock.py,sha256=mKc3x2RhcmHzYe8PHfUQZBkJI0DzN4qpDCmOQzrYnAE,3948
8
+ python_update_checker-0.5.dist-info/WHEEL,sha256=iCTolw4aw2dP3yfM-EQCGTDsFCXL_ymmbYnBRVH7plA,81
9
+ python_update_checker-0.5.dist-info/entry_points.txt,sha256=mQuGfBukyjyo7ssOHSIycAlRdQkrA1ewAth6tsV_T60,38
10
+ python_update_checker-0.5.dist-info/METADATA,sha256=RgatXY5cl_Oy8z2NisqECNj5msIVOv2VlEsBKdQA5Eo,4562
11
+ python_update_checker-0.5.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.11
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ puc = puc:cli.main
3
+