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.
- update_dependencies/__init__.py +5 -0
- update_dependencies/__main__.py +3 -0
- update_dependencies/action.py +97 -0
- update_dependencies/cli.py +247 -0
- update_dependencies/config.py +106 -0
- update_dependencies/context.py +50 -0
- update_dependencies/discovery.py +72 -0
- update_dependencies/ecosystems/__init__.py +1 -0
- update_dependencies/ecosystems/actions.py +309 -0
- update_dependencies/ecosystems/docker.py +125 -0
- update_dependencies/ecosystems/hooks.py +93 -0
- update_dependencies/ecosystems/python.py +139 -0
- update_dependencies/engine.py +1152 -0
- update_dependencies/processes.py +75 -0
- update_dependencies/progress.py +68 -0
- update_dependencies/reporting.py +47 -0
- update_dependencies/runner.py +237 -0
- update_dependencies/tui.py +180 -0
- update_dependencies-0.1.0.dist-info/METADATA +99 -0
- update_dependencies-0.1.0.dist-info/RECORD +23 -0
- update_dependencies-0.1.0.dist-info/WHEEL +4 -0
- update_dependencies-0.1.0.dist-info/entry_points.txt +3 -0
- update_dependencies-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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."""
|