update-dependencies 0.1.0__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.
@@ -0,0 +1,5 @@
1
+ """Dependency updates for repositories, locally and in GitHub Actions."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("update-dependencies")
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,97 @@
1
+ """Translate composite Action inputs to CLI arguments without shell evaluation."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from .cli import effective_config, main, parser
7
+
8
+ # Each supported CLI setting is represented explicitly; no raw shell arguments.
9
+ BOOLEAN_INPUTS = (
10
+ "dry-run",
11
+ "check",
12
+ "allow-major",
13
+ "python-inline-scripts",
14
+ "python-add-minimums",
15
+ "sync",
16
+ "python-update-build-backend",
17
+ "docker-pin-latest",
18
+ "no-config",
19
+ )
20
+ VALUE_INPUTS = (
21
+ "directory",
22
+ "config",
23
+ "ecosystems",
24
+ "min-age",
25
+ "stale-days",
26
+ "timeout",
27
+ "retries",
28
+ "progress",
29
+ "format",
30
+ "output",
31
+ )
32
+ LIST_INPUTS = ("include", "exclude")
33
+
34
+
35
+ def arguments(environment: dict[str, str]) -> list[str]:
36
+ result = []
37
+ for name in BOOLEAN_INPUTS:
38
+ value = environment.get("UD_" + name.upper().replace("-", "_"), "").strip().lower()
39
+ if not value:
40
+ continue
41
+ if value not in {"true", "false"}:
42
+ raise ValueError(f"Action input {name} must be true or false")
43
+ if name == "no-config":
44
+ if value == "true":
45
+ result.append("--no-config")
46
+ else:
47
+ result.append(f"--{name}" if value == "true" else f"--no-{name}")
48
+ for name in VALUE_INPUTS:
49
+ value = environment.get("UD_" + name.upper().replace("-", "_"), "")
50
+ if value:
51
+ result.extend((f"--{name}", value))
52
+ for name in LIST_INPUTS:
53
+ for value in environment.get("UD_" + name.upper(), "").splitlines():
54
+ if value.strip():
55
+ result.extend((f"--{name}", value.strip()))
56
+ if "--output" not in result:
57
+ result.extend(
58
+ (
59
+ "--output",
60
+ str(Path(environment.get("RUNNER_TEMP", ".")) / "update-dependencies-report.json"),
61
+ )
62
+ )
63
+ return result
64
+
65
+
66
+ def run() -> int:
67
+ try:
68
+ args = arguments(dict(os.environ))
69
+ except ValueError as error:
70
+ import sys
71
+
72
+ print(error, file=sys.stderr)
73
+ return 2
74
+ import sys
75
+
76
+ if sys.argv[1:] == ["--prepare"]:
77
+ try:
78
+ root, config = effective_config(parser().parse_args(args))
79
+ from .context import RunContext, use_context
80
+ from .discovery import inventory, matches
81
+ from .engine import skip_reason
82
+
83
+ files = [p for p in inventory(root) if matches(p, config.include, config.exclude)]
84
+ with use_context(RunContext(root, config, files=files)):
85
+ needs_docker = "docker" in config.ecosystems and skip_reason("docker") is None
86
+ output = Path(os.environ["GITHUB_OUTPUT"])
87
+ with output.open("a", encoding="utf-8") as stream:
88
+ stream.write(f"docker={'true' if needs_docker else 'false'}\n")
89
+ return 0
90
+ except (OSError, ValueError, TypeError) as error:
91
+ print(error, file=sys.stderr)
92
+ return 2
93
+ return main(args)
94
+
95
+
96
+ if __name__ == "__main__":
97
+ raise SystemExit(run())
@@ -0,0 +1,247 @@
1
+ """Command line interface; explicit CLI values override repository configuration."""
2
+
3
+ import argparse
4
+ import json
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .config import ECOSYSTEMS, Config, load_config
11
+ from .processes import redact
12
+ from .progress import Progress
13
+ from .reporting import github_report
14
+ from .runner import run
15
+
16
+
17
+ def parser() -> argparse.ArgumentParser:
18
+ result = argparse.ArgumentParser(
19
+ description="Update Python dependencies, GitHub Actions, Docker digests, and pre-commit hooks."
20
+ )
21
+ result.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
22
+ result.add_argument(
23
+ "--directory",
24
+ type=Path,
25
+ default=Path.cwd(),
26
+ help="Target repository (default: current directory)",
27
+ )
28
+ group = result.add_mutually_exclusive_group()
29
+ group.add_argument("--config", type=Path)
30
+ group.add_argument("--no-config", action="store_true")
31
+ group = result.add_mutually_exclusive_group()
32
+ group.add_argument(
33
+ "--ecosystems",
34
+ type=lambda s: tuple(s.split(",")),
35
+ help="Comma-separated ecosystem names",
36
+ )
37
+ group.add_argument(
38
+ "--only",
39
+ choices=ECOSYSTEMS,
40
+ help="Compatibility alias for selecting one ecosystem",
41
+ )
42
+ booleans = {
43
+ "dry-run": "Preview without changing target files",
44
+ "check": "Preview; exit 1 when updates are available",
45
+ "allow-major": "Allow major updates (default: enabled)",
46
+ "python-inline-scripts": "Update PEP 723 script metadata",
47
+ "python-add-minimums": "Add floors to bare Python requirements",
48
+ "sync": "Install target environment after applying updates",
49
+ "python-update-build-backend": "Raise the uv_build floor independently",
50
+ "docker-pin-latest": "Pin latest and untagged Docker images",
51
+ }
52
+ for name, help_text in booleans.items():
53
+ result.add_argument(
54
+ f"--{name}",
55
+ action=argparse.BooleanOptionalAction,
56
+ default=None,
57
+ help=help_text,
58
+ )
59
+ result.add_argument(
60
+ "--no-major-version-updates",
61
+ dest="allow_major",
62
+ action="store_false",
63
+ default=None,
64
+ help="Alias for --no-allow-major",
65
+ )
66
+ result.add_argument(
67
+ "--enable-docker-latest-to-sha-updates",
68
+ dest="docker_pin_latest",
69
+ action="store_true",
70
+ default=None,
71
+ help="Alias for --docker-pin-latest",
72
+ )
73
+ result.add_argument(
74
+ "--add-python-latest-version",
75
+ dest="python_add_minimums",
76
+ action="store_true",
77
+ default=None,
78
+ help="Alias for --python-add-minimums",
79
+ )
80
+ result.add_argument(
81
+ "--no-update-python-inline-script-metadata",
82
+ dest="python_inline_scripts",
83
+ action="store_false",
84
+ default=None,
85
+ help="Alias for --no-python-inline-scripts",
86
+ )
87
+ result.add_argument(
88
+ "--update-docker-tags",
89
+ action="store_true",
90
+ help="Deprecated compatibility option (has no effect)",
91
+ )
92
+ result.add_argument("--min-age", dest="min_age_hours", type=int, default=None, metavar="HOURS")
93
+ result.add_argument("--stale-days", type=int, default=None)
94
+ result.add_argument("--timeout", type=int, default=None, help="Per-command timeout in seconds")
95
+ result.add_argument(
96
+ "--retries",
97
+ type=int,
98
+ default=None,
99
+ help="Retries for transient read-only lookups",
100
+ )
101
+ result.add_argument(
102
+ "--include",
103
+ action="append",
104
+ default=None,
105
+ help="File glob; repeat to add patterns",
106
+ )
107
+ result.add_argument(
108
+ "--exclude",
109
+ action="append",
110
+ default=None,
111
+ help="File glob; repeat to add patterns",
112
+ )
113
+ result.add_argument(
114
+ "--progress",
115
+ choices=("auto", "plain", "off"),
116
+ default=None,
117
+ help="Progress on stderr: auto, plain, or off",
118
+ )
119
+ result.add_argument("--format", choices=("text", "json"), default="text")
120
+ result.add_argument("--output", type=Path, help="Write the JSON report to this path")
121
+ result.add_argument(
122
+ "--print-config",
123
+ action="store_true",
124
+ help="Print effective configuration as JSON and exit",
125
+ )
126
+ result.add_argument(
127
+ "--tui",
128
+ action="store_true",
129
+ help="Configure interactively (requires the tui extra and a terminal)",
130
+ )
131
+ return result
132
+
133
+
134
+ def interactive(config: Config, root: Path) -> Config:
135
+ from .context import RunContext, use_context
136
+
137
+ try:
138
+ import questionary # noqa: F401
139
+
140
+ from .tui import prompt_tui
141
+ except ImportError as error:
142
+ raise ValueError("Install update-dependencies[tui] to use --tui") from error
143
+ values = argparse.Namespace(
144
+ only=None,
145
+ ecosystems=config.ecosystems,
146
+ dry_run=config.dry_run,
147
+ allow_major=config.allow_major,
148
+ min_age=config.min_age_hours,
149
+ add_python_latest_version=config.python_add_minimums,
150
+ update_python_inline_script_metadata=config.python_inline_scripts,
151
+ enable_docker_latest_to_sha_updates=config.docker_pin_latest,
152
+ )
153
+ with use_context(RunContext(root, config)):
154
+ values = prompt_tui(values)
155
+ return config.overlay(
156
+ {
157
+ "ecosystems": tuple(values.ecosystems),
158
+ "dry_run": values.dry_run,
159
+ "allow_major": values.allow_major,
160
+ "min_age_hours": values.min_age,
161
+ "python_add_minimums": values.add_python_latest_version,
162
+ "python_inline_scripts": values.update_python_inline_script_metadata,
163
+ "docker_pin_latest": values.enable_docker_latest_to_sha_updates,
164
+ }
165
+ )
166
+
167
+
168
+ def effective_config(args) -> tuple[Path, Config]:
169
+ root = args.directory.expanduser().resolve(strict=True)
170
+ if not root.is_dir():
171
+ raise ValueError("--directory must refer to a directory")
172
+ config = load_config(root, args.config, args.no_config)
173
+ overrides = {key: getattr(args, key) for key in Config.__dataclass_fields__ if hasattr(args, key)}
174
+ for key in ("include", "exclude"):
175
+ if overrides.get(key) is not None:
176
+ overrides[key] = tuple(overrides[key])
177
+ if args.only:
178
+ overrides["ecosystems"] = (args.only,)
179
+ overrides["python_sync"] = args.sync
180
+ config = config.overlay(overrides)
181
+ return root, config
182
+
183
+
184
+ def main(argv: list[str] | None = None) -> int:
185
+ arguments = parser()
186
+ args = arguments.parse_args(argv)
187
+ try:
188
+ root, config = effective_config(args)
189
+ if args.update_docker_tags:
190
+ print(
191
+ "warning: --update-docker-tags is deprecated and has no effect",
192
+ file=sys.stderr,
193
+ )
194
+ if args.tui:
195
+ config = interactive(config, root)
196
+ if args.print_config:
197
+ print(json.dumps(config.to_dict(), indent=2))
198
+ return 0
199
+ output = args.output.expanduser().absolute() if args.output else None
200
+ if output and output.exists():
201
+ if output.is_symlink():
202
+ raise ValueError("--output cannot overwrite a symlink")
203
+ try:
204
+ previous = json.loads(output.read_text(encoding="utf-8"))
205
+ except (OSError, ValueError) as error:
206
+ raise ValueError("--output refuses to overwrite an existing non-report file") from error
207
+ if (
208
+ not isinstance(previous, dict)
209
+ or "changed_files" not in previous
210
+ or previous.get("schema_version") != 1
211
+ ):
212
+ raise ValueError("--output refuses to overwrite an existing non-report file")
213
+ output = output.resolve() if output else None
214
+ with Progress(config.progress):
215
+ report = run(root, config, explicit=bool(args.only or args.ecosystems))
216
+ if output:
217
+ output.parent.mkdir(parents=True, exist_ok=True)
218
+ output.write_text(report.json(), encoding="utf-8")
219
+ github_report(report, output)
220
+ if args.format == "json":
221
+ print(report.json(), end="")
222
+ else:
223
+ mode = "Would update" if report.dry_run else "Updated"
224
+ print(f"{mode} {len(report.changed_files)} file(s).")
225
+ for path in report.changed_files:
226
+ print(f" {path}")
227
+ for change in report.changes:
228
+ note = f" ({change['note']})" if change.get("note") else ""
229
+ print(f" {change['ecosystem']}: {change['name']} {change['old']} -> {change['new']}{note}")
230
+ for item in report.skipped:
231
+ print(f"Skipped {item.get('ecosystem', item.get('file', ''))}: {item['reason']}")
232
+ return 1 if config.check and report.changed_files else 0
233
+ except (
234
+ OSError,
235
+ ValueError,
236
+ TypeError,
237
+ RuntimeError,
238
+ subprocess.SubprocessError,
239
+ ) as error:
240
+ if args.format == "json":
241
+ print(json.dumps({"schema_version": 1, "error": redact(str(error))}))
242
+ else:
243
+ print(f"error: {redact(str(error))}", file=sys.stderr)
244
+ return 2
245
+ except KeyboardInterrupt:
246
+ print("Cancelled.", file=sys.stderr)
247
+ return 130
@@ -0,0 +1,106 @@
1
+ """Validated configuration shared by the CLI and GitHub Action."""
2
+
3
+ import tomllib
4
+ from dataclasses import asdict, dataclass, fields, replace
5
+ from pathlib import Path
6
+
7
+ ECOSYSTEMS = ("python", "actions", "docker", "hooks")
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Config:
12
+ schema_version: int = 1
13
+ ecosystems: tuple[str, ...] = ECOSYSTEMS
14
+ include: tuple[str, ...] = ()
15
+ exclude: tuple[str, ...] = ()
16
+ allow_major: bool = True
17
+ min_age_hours: int = 48
18
+ stale_days: int = 180
19
+ dry_run: bool = False
20
+ check: bool = False
21
+ timeout: int = 120
22
+ retries: int = 2
23
+ progress: str = "auto"
24
+ python_inline_scripts: bool = True
25
+ python_add_minimums: bool = False
26
+ python_sync: bool = False
27
+ python_update_build_backend: bool = False
28
+ docker_pin_latest: bool = False
29
+
30
+ def validate(self) -> Config:
31
+ defaults = Config()
32
+ for item in fields(self):
33
+ value = getattr(self, item.name)
34
+ expected = type(getattr(defaults, item.name))
35
+ if type(value) is not expected:
36
+ raise ValueError(f"{item.name.replace('_', '-')} must be {expected.__name__}")
37
+ if expected is tuple and any(not isinstance(v, str) for v in value):
38
+ raise ValueError(f"{item.name} must contain strings")
39
+ if self.progress not in {"auto", "plain", "off"}:
40
+ raise ValueError("progress must be auto, plain, or off")
41
+ if self.schema_version != 1:
42
+ raise ValueError("Only schema-version = 1 is supported")
43
+ if not self.ecosystems or set(self.ecosystems) - set(ECOSYSTEMS):
44
+ raise ValueError(f"ecosystems must be a nonempty selection from {ECOSYSTEMS}")
45
+ if min(self.min_age_hours, self.stale_days, self.retries) < 0 or self.timeout < 1:
46
+ raise ValueError("Ages and retries must be >= 0; timeout must be >= 1")
47
+ return self
48
+
49
+ def overlay(self, values: dict) -> Config:
50
+ return replace(self, **{k: v for k, v in values.items() if v is not None}).validate()
51
+
52
+ def to_dict(self) -> dict:
53
+ return asdict(self)
54
+
55
+
56
+ SECTIONS = {
57
+ "python": {
58
+ "inline-scripts": "python_inline_scripts",
59
+ "add-missing-minimums": "python_add_minimums",
60
+ "sync": "python_sync",
61
+ "update-build-backend": "python_update_build_backend",
62
+ },
63
+ "docker": {"pin-latest": "docker_pin_latest"},
64
+ }
65
+
66
+
67
+ def load_config(directory: Path, explicit: Path | None = None, disabled: bool = False) -> Config:
68
+ if disabled:
69
+ return Config()
70
+ path = explicit
71
+ if path is not None:
72
+ path = path if path.is_absolute() else directory / path
73
+ elif (directory / "update-dependencies.toml").is_file():
74
+ path = directory / "update-dependencies.toml"
75
+ elif (directory / "pyproject.toml").is_file():
76
+ path = directory / "pyproject.toml"
77
+ else:
78
+ return Config()
79
+ with path.open("rb") as stream:
80
+ data = tomllib.load(stream)
81
+ if path.name == "pyproject.toml":
82
+ data = data.get("tool", {}).get("update-dependencies", {})
83
+ if not isinstance(data, dict):
84
+ raise TypeError("Expected a configuration TOML table")
85
+ values = {}
86
+ top_level = {
87
+ f.name.replace("_", "-"): f.name
88
+ for f in fields(Config)
89
+ if not f.name.startswith(("python_", "docker_"))
90
+ }
91
+ for key, value in data.items():
92
+ if key in SECTIONS:
93
+ if not isinstance(value, dict):
94
+ raise ValueError(f"{key} must be a table")
95
+ for option, setting in value.items():
96
+ if option not in SECTIONS[key]:
97
+ raise ValueError(f"Unknown configuration option: {key}.{option}")
98
+ values[SECTIONS[key][option]] = setting
99
+ elif key in top_level:
100
+ name = top_level[key]
101
+ if name in {"ecosystems", "include", "exclude"} and isinstance(value, list):
102
+ value = tuple(value)
103
+ values[name] = value
104
+ else:
105
+ raise ValueError(f"Unknown configuration option: {key}")
106
+ return Config().overlay(values)
@@ -0,0 +1,50 @@
1
+ """State isolated to one invocation, including discovery and registry caches."""
2
+
3
+ from contextlib import contextmanager
4
+ from contextvars import ContextVar
5
+ from dataclasses import dataclass, field
6
+ from datetime import UTC, datetime, timedelta
7
+ from pathlib import Path
8
+
9
+ from .config import Config
10
+
11
+
12
+ @dataclass
13
+ class RunContext:
14
+ root: Path
15
+ config: Config
16
+ files: list[Path] | None = None
17
+ pyproject: Path = field(init=False)
18
+ uv_lock: Path = field(init=False)
19
+ pre_commit: Path = field(init=False)
20
+ action_files: list[Path] | None = None
21
+ dockerfiles: list[Path] | None = None
22
+ inline_scripts: list[Path] | None = None
23
+ opt_out_skips: list = field(default_factory=list)
24
+ docker_latest_skips: list = field(default_factory=list)
25
+ github_meta: dict = field(default_factory=dict)
26
+ policy_skips: list[dict] = field(default_factory=list)
27
+ uv_age_args: list[str] = field(init=False)
28
+
29
+ def __post_init__(self):
30
+ self.pyproject = self.root / "pyproject.toml"
31
+ self.uv_lock = self.root / "uv.lock"
32
+ self.pre_commit = self.root / ".pre-commit-config.yaml"
33
+ cutoff = datetime.now(UTC) - timedelta(hours=self.config.min_age_hours)
34
+ self.uv_age_args = ["--exclude-newer", cutoff.isoformat()] if self.config.min_age_hours else []
35
+
36
+
37
+ _current: ContextVar[RunContext] = ContextVar("update_dependencies_context")
38
+
39
+
40
+ def context() -> RunContext:
41
+ return _current.get()
42
+
43
+
44
+ @contextmanager
45
+ def use_context(value: RunContext):
46
+ token = _current.set(value)
47
+ try:
48
+ yield value
49
+ finally:
50
+ _current.reset(token)
@@ -0,0 +1,72 @@
1
+ """Repository inventory with git-ignore semantics and explicit path filters."""
2
+
3
+ import os
4
+ import subprocess
5
+ from fnmatch import fnmatchcase
6
+ from pathlib import Path
7
+
8
+ SKIP_DIRS = {
9
+ ".git",
10
+ ".venv",
11
+ "venv",
12
+ "node_modules",
13
+ ".tox",
14
+ "__pycache__",
15
+ ".pytest_cache",
16
+ ".ruff_cache",
17
+ "dist",
18
+ "build",
19
+ }
20
+
21
+
22
+ def walk(root: Path):
23
+ for directory, names, files in os.walk(root):
24
+ names[:] = [n for n in names if n not in SKIP_DIRS and not (Path(directory) / n).is_symlink()]
25
+ yield from ((Path(directory) / name).relative_to(root) for name in files)
26
+
27
+
28
+ def inventory(root: Path) -> list[Path]:
29
+ try:
30
+ listed = subprocess.run(
31
+ [
32
+ "git",
33
+ "-c",
34
+ "core.fsmonitor=false",
35
+ "ls-files",
36
+ "-co",
37
+ "--exclude-standard",
38
+ "-z",
39
+ ],
40
+ cwd=root,
41
+ capture_output=True,
42
+ text=True,
43
+ timeout=30,
44
+ check=False,
45
+ )
46
+ except FileNotFoundError:
47
+ listed = None
48
+ candidates = (
49
+ (Path(p) for p in listed.stdout.split("\0") if p)
50
+ if listed is not None and listed.returncode == 0
51
+ else walk(root)
52
+ )
53
+ result = set()
54
+ for relative in candidates:
55
+ if relative.is_absolute() or ".." in relative.parts or set(relative.parts) & SKIP_DIRS:
56
+ continue
57
+ path = root / relative
58
+ if not path.is_file() or any(
59
+ (root / Path(*relative.parts[:i])).is_symlink() for i in range(1, len(relative.parts) + 1)
60
+ ):
61
+ continue
62
+ result.add(relative)
63
+ return sorted(result)
64
+
65
+
66
+ def matches(path: Path, include: tuple[str, ...], exclude: tuple[str, ...]) -> bool:
67
+ name = path.as_posix()
68
+
69
+ def matched(pattern):
70
+ return fnmatchcase(name, pattern) or (pattern.startswith("**/") and fnmatchcase(name, pattern[3:]))
71
+
72
+ return (not include or any(matched(p) for p in include)) and not any(matched(p) for p in exclude)
@@ -0,0 +1 @@
1
+ """Ecosystem-specific update operations."""