taze 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.
- taze/__init__.py +3 -0
- taze/display.py +236 -0
- taze/main.py +480 -0
- taze/models.py +174 -0
- taze/parsers.py +112 -0
- taze/pypi.py +73 -0
- taze/writers.py +57 -0
- taze-0.1.0.dist-info/METADATA +156 -0
- taze-0.1.0.dist-info/RECORD +12 -0
- taze-0.1.0.dist-info/WHEEL +4 -0
- taze-0.1.0.dist-info/entry_points.txt +2 -0
- taze-0.1.0.dist-info/licenses/LICENSE +21 -0
taze/__init__.py
ADDED
taze/display.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json as _json
|
|
4
|
+
from datetime import date
|
|
5
|
+
|
|
6
|
+
from rich import box
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.padding import Padding
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from .models import BUMP_BADGE, BUMP_COLOR, DepInfo
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _age(release_date: str | None) -> str:
|
|
18
|
+
"""Return a compact age string like ~2mo, ~3d, ~1y, or empty string."""
|
|
19
|
+
if not release_date:
|
|
20
|
+
return ""
|
|
21
|
+
try:
|
|
22
|
+
days = (date.today() - date.fromisoformat(release_date)).days
|
|
23
|
+
except ValueError:
|
|
24
|
+
return ""
|
|
25
|
+
if days < 1:
|
|
26
|
+
return "~0d"
|
|
27
|
+
if days < 30:
|
|
28
|
+
return f"~{days}d"
|
|
29
|
+
if days < 365:
|
|
30
|
+
return f"~{days // 30}mo"
|
|
31
|
+
return f"~{days // 365}y"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _age_color(release_date: str | None) -> str:
|
|
35
|
+
if not release_date:
|
|
36
|
+
return "dim"
|
|
37
|
+
try:
|
|
38
|
+
days = (date.today() - date.fromisoformat(release_date)).days
|
|
39
|
+
except ValueError:
|
|
40
|
+
return "dim"
|
|
41
|
+
if days < 28:
|
|
42
|
+
return "green"
|
|
43
|
+
if days < 180:
|
|
44
|
+
return "yellow"
|
|
45
|
+
return "red"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def render_group(
|
|
49
|
+
label: str,
|
|
50
|
+
infos: list[DepInfo],
|
|
51
|
+
*,
|
|
52
|
+
mode: str,
|
|
53
|
+
show_up_to_date: bool,
|
|
54
|
+
sort: str | None,
|
|
55
|
+
col_widths: tuple[int, int, int, int, int] = (0, 0, 0, 0, 0),
|
|
56
|
+
) -> bool:
|
|
57
|
+
"""Render one dependency group table. Returns True if anything was printed."""
|
|
58
|
+
visible = [i for i in infos if show_up_to_date or i.is_shown(mode) or i.fetch_error]
|
|
59
|
+
if not visible:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
if sort:
|
|
63
|
+
_sort_infos(visible, sort, mode)
|
|
64
|
+
|
|
65
|
+
outdated = sum(1 for i in infos if i.is_shown(mode))
|
|
66
|
+
|
|
67
|
+
header = Text()
|
|
68
|
+
header.append(f" {label}", style="bold blue")
|
|
69
|
+
if outdated:
|
|
70
|
+
header.append(f" {outdated} outdated", style="dim")
|
|
71
|
+
else:
|
|
72
|
+
header.append(" all up to date", style="dim green")
|
|
73
|
+
console.print(header)
|
|
74
|
+
|
|
75
|
+
name_width = max(max((len(i.name) for i in visible), default=0), col_widths[0])
|
|
76
|
+
spec_width = max(
|
|
77
|
+
max((len(i.current_spec) for i in visible), default=0), col_widths[1]
|
|
78
|
+
)
|
|
79
|
+
cur_age_width = max(
|
|
80
|
+
max((len(_age(i.current_release_date)) for i in visible), default=0),
|
|
81
|
+
col_widths[2],
|
|
82
|
+
)
|
|
83
|
+
lat_age_width = max(
|
|
84
|
+
max((len(_age(i.release_date)) for i in visible), default=0), col_widths[3]
|
|
85
|
+
)
|
|
86
|
+
latest_spec_width = max(
|
|
87
|
+
max((len(i.latest_spec) for i in visible), default=0), col_widths[4]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
table = Table(
|
|
91
|
+
box=box.SIMPLE,
|
|
92
|
+
show_header=False,
|
|
93
|
+
padding=(0, 2, 0, 0),
|
|
94
|
+
expand=False,
|
|
95
|
+
show_edge=False,
|
|
96
|
+
)
|
|
97
|
+
table.add_column("name", style="bold", no_wrap=True, min_width=name_width)
|
|
98
|
+
table.add_column("cur_age", style="dim", no_wrap=True, min_width=cur_age_width)
|
|
99
|
+
table.add_column("current", style="dim", no_wrap=True, min_width=spec_width)
|
|
100
|
+
table.add_column("arrow", no_wrap=True)
|
|
101
|
+
table.add_column("latest", no_wrap=True, min_width=latest_spec_width)
|
|
102
|
+
table.add_column("lat_age", style="dim", no_wrap=True, min_width=lat_age_width)
|
|
103
|
+
table.add_column("badge", no_wrap=True)
|
|
104
|
+
|
|
105
|
+
for info in visible:
|
|
106
|
+
color = BUMP_COLOR.get(info.bump, "dim")
|
|
107
|
+
badge = BUMP_BADGE.get(info.bump, "")
|
|
108
|
+
cur_age = _age(info.current_release_date)
|
|
109
|
+
lat_age = _age(info.release_date)
|
|
110
|
+
cur_age_color = _age_color(info.current_release_date)
|
|
111
|
+
lat_age_color = _age_color(info.release_date)
|
|
112
|
+
|
|
113
|
+
if info.fetch_error:
|
|
114
|
+
table.add_row(
|
|
115
|
+
info.name,
|
|
116
|
+
"",
|
|
117
|
+
info.current_spec,
|
|
118
|
+
Text("→", style="dim"),
|
|
119
|
+
Text("fetch failed", style="dim red"),
|
|
120
|
+
"",
|
|
121
|
+
"",
|
|
122
|
+
)
|
|
123
|
+
elif info.bump == "same":
|
|
124
|
+
table.add_row(
|
|
125
|
+
Text(info.name, style="dim"),
|
|
126
|
+
Text(cur_age, style="dim"),
|
|
127
|
+
Text(info.current_spec, style="dim"),
|
|
128
|
+
Text("·", style="dim"),
|
|
129
|
+
Text(info.current_spec, style="dim"),
|
|
130
|
+
"",
|
|
131
|
+
"",
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
table.add_row(
|
|
135
|
+
info.name,
|
|
136
|
+
Text(cur_age, style=cur_age_color),
|
|
137
|
+
Text(info.current_spec, style="dim"),
|
|
138
|
+
Text("→", style=color),
|
|
139
|
+
Text(info.latest_spec, style=f"bold {color}"),
|
|
140
|
+
Text(lat_age, style=lat_age_color),
|
|
141
|
+
Text.from_markup(badge),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
console.print(Padding(table, (0, 0, 0, 4)))
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _sort_infos(infos: list[DepInfo], sort: str, mode: str) -> None:
|
|
149
|
+
from .models import BUMP_ORDER
|
|
150
|
+
|
|
151
|
+
if sort == "name-asc":
|
|
152
|
+
infos.sort(key=lambda i: i.name)
|
|
153
|
+
elif sort == "name-desc":
|
|
154
|
+
infos.sort(key=lambda i: i.name, reverse=True)
|
|
155
|
+
elif sort == "diff-asc":
|
|
156
|
+
infos.sort(key=lambda i: BUMP_ORDER.get(i.bump, -1))
|
|
157
|
+
elif sort == "diff-desc":
|
|
158
|
+
infos.sort(key=lambda i: BUMP_ORDER.get(i.bump, -1), reverse=True)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def render_json(resolved: dict[str, dict[str, list[DepInfo]]]) -> None:
|
|
162
|
+
output: dict = {}
|
|
163
|
+
for file_label, groups in resolved.items():
|
|
164
|
+
output[file_label] = {}
|
|
165
|
+
for group_label, infos in groups.items():
|
|
166
|
+
output[file_label][group_label] = [
|
|
167
|
+
{
|
|
168
|
+
"name": i.name,
|
|
169
|
+
"current": i.current,
|
|
170
|
+
"current_spec": i.current_spec,
|
|
171
|
+
"latest": i.latest,
|
|
172
|
+
"latest_spec": i.latest_spec if i.latest else None,
|
|
173
|
+
"bump": i.bump,
|
|
174
|
+
"outdated": i.is_outdated,
|
|
175
|
+
"release_date": i.release_date,
|
|
176
|
+
"error": i.fetch_error,
|
|
177
|
+
}
|
|
178
|
+
for i in infos
|
|
179
|
+
]
|
|
180
|
+
print(_json.dumps(output, indent=2))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def interactive_select(outdated: list[DepInfo]) -> list[DepInfo]:
|
|
184
|
+
"""Prompt the user to choose which packages to update. Returns selected subset."""
|
|
185
|
+
if not outdated:
|
|
186
|
+
return []
|
|
187
|
+
|
|
188
|
+
console.print()
|
|
189
|
+
console.print(" [bold]Select packages to update:[/]")
|
|
190
|
+
for idx, info in enumerate(outdated, 1):
|
|
191
|
+
color = BUMP_COLOR.get(info.bump, "dim")
|
|
192
|
+
badge = BUMP_BADGE.get(info.bump, "")
|
|
193
|
+
console.print(
|
|
194
|
+
f" [dim]{idx:>2}.[/] [bold]{info.name}[/] "
|
|
195
|
+
f"[dim]{info.current_spec}[/] [dim]→[/] "
|
|
196
|
+
f"[bold {color}]{info.latest_spec}[/] {badge}"
|
|
197
|
+
)
|
|
198
|
+
console.print()
|
|
199
|
+
console.print(
|
|
200
|
+
" [dim]Enter numbers (e.g. [cyan]1,3[/]), [cyan]a[/] for all, "
|
|
201
|
+
"or press Enter to skip:[/] ",
|
|
202
|
+
end="",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
raw = input().strip()
|
|
207
|
+
except EOFError, KeyboardInterrupt:
|
|
208
|
+
console.print()
|
|
209
|
+
return []
|
|
210
|
+
|
|
211
|
+
if not raw or raw.lower() == "n":
|
|
212
|
+
return []
|
|
213
|
+
if raw.lower() in ("a", "all"):
|
|
214
|
+
return outdated
|
|
215
|
+
|
|
216
|
+
selected: list[DepInfo] = []
|
|
217
|
+
for token in raw.split(","):
|
|
218
|
+
token = token.strip()
|
|
219
|
+
if "-" in token:
|
|
220
|
+
parts = token.split("-", 1)
|
|
221
|
+
try:
|
|
222
|
+
lo, hi = int(parts[0]), int(parts[1])
|
|
223
|
+
for i in range(lo, hi + 1):
|
|
224
|
+
if 1 <= i <= len(outdated):
|
|
225
|
+
selected.append(outdated[i - 1])
|
|
226
|
+
except ValueError:
|
|
227
|
+
pass
|
|
228
|
+
else:
|
|
229
|
+
try:
|
|
230
|
+
i = int(token)
|
|
231
|
+
if 1 <= i <= len(outdated):
|
|
232
|
+
selected.append(outdated[i - 1])
|
|
233
|
+
except ValueError:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
return selected
|
taze/main.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from .display import console, interactive_select, render_group, render_json
|
|
12
|
+
from .models import MODE_SETTINGS, MODES, DepInfo, FileKind, calc_bump
|
|
13
|
+
from .parsers import (
|
|
14
|
+
build_name_filter,
|
|
15
|
+
parse_dep_string,
|
|
16
|
+
parse_pyproject,
|
|
17
|
+
parse_requirements_file,
|
|
18
|
+
)
|
|
19
|
+
from .pypi import fetch_pypi_info
|
|
20
|
+
from .writers import write_pyproject_updates, write_requirements_updates
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
name="taze",
|
|
26
|
+
help="🥬 Keep your Python deps fresh",
|
|
27
|
+
add_completion=False,
|
|
28
|
+
rich_markup_mode="rich",
|
|
29
|
+
no_args_is_help=False,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
SORT_CHOICES = ("name-asc", "name-desc", "diff-asc", "diff-desc")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ─── Resolution ───────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_deps(
|
|
39
|
+
entries: list[tuple[str, Path | None, FileKind, int | None]],
|
|
40
|
+
*,
|
|
41
|
+
include_pat: re.Pattern[str] | None,
|
|
42
|
+
exclude_pat: re.Pattern[str] | None,
|
|
43
|
+
pre: bool,
|
|
44
|
+
concurrency: int,
|
|
45
|
+
) -> list[DepInfo]:
|
|
46
|
+
infos: list[DepInfo] = []
|
|
47
|
+
for raw, src, kind, lineno in entries:
|
|
48
|
+
info = parse_dep_string(
|
|
49
|
+
raw, source_file=src, file_kind=kind, line_number=lineno
|
|
50
|
+
)
|
|
51
|
+
if info is None:
|
|
52
|
+
continue
|
|
53
|
+
if include_pat and not include_pat.match(info.name):
|
|
54
|
+
continue
|
|
55
|
+
if exclude_pat and exclude_pat.match(info.name):
|
|
56
|
+
continue
|
|
57
|
+
infos.append(info)
|
|
58
|
+
|
|
59
|
+
if not infos:
|
|
60
|
+
return infos
|
|
61
|
+
|
|
62
|
+
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
63
|
+
futures = {
|
|
64
|
+
pool.submit(fetch_pypi_info, i.name, pre=pre, current_version=i.current): i
|
|
65
|
+
for i in infos
|
|
66
|
+
}
|
|
67
|
+
for fut in as_completed(futures):
|
|
68
|
+
info = futures[fut]
|
|
69
|
+
try:
|
|
70
|
+
version, latest_date, current_date = fut.result()
|
|
71
|
+
info.latest = version
|
|
72
|
+
info.release_date = latest_date
|
|
73
|
+
info.current_release_date = current_date
|
|
74
|
+
info.fetch_error = version is None
|
|
75
|
+
except Exception:
|
|
76
|
+
info.fetch_error = True
|
|
77
|
+
info.bump = calc_bump(info.current, info.latest)
|
|
78
|
+
|
|
79
|
+
return infos
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ─── File discovery ───────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def discover_files(root: Path) -> list[Path]:
|
|
86
|
+
found: list[Path] = []
|
|
87
|
+
pyproject = root / "pyproject.toml"
|
|
88
|
+
if pyproject.exists():
|
|
89
|
+
found.append(pyproject)
|
|
90
|
+
for req in sorted(root.glob("requirements*.txt")):
|
|
91
|
+
found.append(req)
|
|
92
|
+
return found
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command()
|
|
99
|
+
def main(
|
|
100
|
+
mode: Annotated[
|
|
101
|
+
str,
|
|
102
|
+
typer.Argument(
|
|
103
|
+
help=(
|
|
104
|
+
"Update mode: "
|
|
105
|
+
"[green]patch[/] [yellow]minor[/] [red]major[/] "
|
|
106
|
+
"[dim]default | latest | stable | newest | next[/]"
|
|
107
|
+
),
|
|
108
|
+
show_default=False,
|
|
109
|
+
),
|
|
110
|
+
] = "default",
|
|
111
|
+
cwd: Annotated[
|
|
112
|
+
Path | None,
|
|
113
|
+
typer.Option("--cwd", "-C", help="Working directory", show_default=False),
|
|
114
|
+
] = None,
|
|
115
|
+
write: Annotated[
|
|
116
|
+
bool, typer.Option("--write", "-w", help="Write updates back to file")
|
|
117
|
+
] = False,
|
|
118
|
+
install: Annotated[
|
|
119
|
+
bool,
|
|
120
|
+
typer.Option(
|
|
121
|
+
"--install",
|
|
122
|
+
"-i",
|
|
123
|
+
help="Install directly after bumping (implies [cyan]-w[/])",
|
|
124
|
+
),
|
|
125
|
+
] = False,
|
|
126
|
+
update: Annotated[
|
|
127
|
+
bool,
|
|
128
|
+
typer.Option("--update", "-u", help="Alias for [cyan]--install[/]"),
|
|
129
|
+
] = False,
|
|
130
|
+
recursive: Annotated[
|
|
131
|
+
bool,
|
|
132
|
+
typer.Option(
|
|
133
|
+
"--recursive",
|
|
134
|
+
"-r",
|
|
135
|
+
help="Recursively search for pyproject.toml / requirements*.txt",
|
|
136
|
+
),
|
|
137
|
+
] = False,
|
|
138
|
+
interactive: Annotated[
|
|
139
|
+
bool,
|
|
140
|
+
typer.Option(
|
|
141
|
+
"--interactive",
|
|
142
|
+
"-I",
|
|
143
|
+
help="Interactive mode — choose which packages to update",
|
|
144
|
+
),
|
|
145
|
+
] = False,
|
|
146
|
+
include: Annotated[
|
|
147
|
+
str | None,
|
|
148
|
+
typer.Option(
|
|
149
|
+
"--include",
|
|
150
|
+
"-n",
|
|
151
|
+
help="Only check these deps (comma-separated names or [dim]/regex/[/])",
|
|
152
|
+
),
|
|
153
|
+
] = None,
|
|
154
|
+
exclude: Annotated[
|
|
155
|
+
str | None,
|
|
156
|
+
typer.Option(
|
|
157
|
+
"--exclude",
|
|
158
|
+
"-x",
|
|
159
|
+
help="Skip these deps (comma-separated names or [dim]/regex/[/])",
|
|
160
|
+
),
|
|
161
|
+
] = None,
|
|
162
|
+
all_deps: Annotated[
|
|
163
|
+
bool, typer.Option("--all", "-a", help="Show up-to-date packages too")
|
|
164
|
+
] = False,
|
|
165
|
+
group: Annotated[
|
|
166
|
+
bool,
|
|
167
|
+
typer.Option("--group", help="Group dependencies by source file on display"),
|
|
168
|
+
] = False,
|
|
169
|
+
sort: Annotated[
|
|
170
|
+
str | None,
|
|
171
|
+
typer.Option(
|
|
172
|
+
"--sort", help="Sort by: name-asc | name-desc | diff-asc | diff-desc"
|
|
173
|
+
),
|
|
174
|
+
] = None,
|
|
175
|
+
fail_on_outdated: Annotated[
|
|
176
|
+
bool,
|
|
177
|
+
typer.Option(
|
|
178
|
+
"--fail-on-outdated",
|
|
179
|
+
help="Exit with code 1 if outdated dependencies are found",
|
|
180
|
+
),
|
|
181
|
+
] = False,
|
|
182
|
+
silent: Annotated[bool, typer.Option("--silent", "-s", help="No output")] = False,
|
|
183
|
+
output_json: Annotated[
|
|
184
|
+
bool, typer.Option("--json", help="Machine-readable JSON output", hidden=True)
|
|
185
|
+
] = False,
|
|
186
|
+
version: Annotated[
|
|
187
|
+
bool,
|
|
188
|
+
typer.Option("--version", "-v", help="Show version and exit", is_eager=True),
|
|
189
|
+
] = False,
|
|
190
|
+
concurrency: Annotated[
|
|
191
|
+
int,
|
|
192
|
+
typer.Option("--concurrency", help="Number of concurrent PyPI requests"),
|
|
193
|
+
] = 10,
|
|
194
|
+
) -> None:
|
|
195
|
+
"""
|
|
196
|
+
🥬 [bold]taze[/bold] — keep your Python deps fresh
|
|
197
|
+
|
|
198
|
+
Reads [cyan]pyproject.toml[/] and/or [cyan]requirements*.txt[/], checks PyPI for
|
|
199
|
+
newer versions, and shows a grouped diff.
|
|
200
|
+
|
|
201
|
+
[dim]Examples:[/dim]
|
|
202
|
+
[cyan]taze[/] check everything (default mode)
|
|
203
|
+
[cyan]taze minor[/] only show minor and patch updates
|
|
204
|
+
[cyan]taze patch -w[/] write patch updates back to file
|
|
205
|
+
[cyan]taze newest -I[/] interactive, including pre-releases
|
|
206
|
+
[cyan]taze -r[/] scan subdirectories recursively
|
|
207
|
+
[cyan]taze -x pytest,ruff[/] skip specific packages
|
|
208
|
+
[cyan]taze -n /^boto/[/] only packages matching regex
|
|
209
|
+
|
|
210
|
+
[cyan]taze --sort diff-desc[/] biggest updates first
|
|
211
|
+
"""
|
|
212
|
+
if version:
|
|
213
|
+
console.print(f"taze/{__version__}")
|
|
214
|
+
raise typer.Exit(0)
|
|
215
|
+
|
|
216
|
+
if mode not in MODES:
|
|
217
|
+
console.print(
|
|
218
|
+
f"[red]✗[/] Unknown mode [bold]{mode!r}[/]. Available: {' | '.join(MODES)}"
|
|
219
|
+
)
|
|
220
|
+
raise typer.Exit(1)
|
|
221
|
+
|
|
222
|
+
if sort and sort not in SORT_CHOICES:
|
|
223
|
+
console.print(f"[red]✗[/] --sort must be one of: {', '.join(SORT_CHOICES)}")
|
|
224
|
+
raise typer.Exit(1)
|
|
225
|
+
|
|
226
|
+
if install or update:
|
|
227
|
+
write = True
|
|
228
|
+
if interactive:
|
|
229
|
+
write = True
|
|
230
|
+
|
|
231
|
+
_, pre = MODE_SETTINGS[mode]
|
|
232
|
+
|
|
233
|
+
root = (cwd or Path(".")).resolve()
|
|
234
|
+
include_pat = build_name_filter(include) if include else None
|
|
235
|
+
exclude_pat = build_name_filter(exclude) if exclude else None
|
|
236
|
+
|
|
237
|
+
# ── Collect files ─────────────────────────────────────────────────────────
|
|
238
|
+
if recursive:
|
|
239
|
+
target_files: list[Path] = []
|
|
240
|
+
seen: set[Path] = set()
|
|
241
|
+
for subdir in sorted(root.rglob(".")):
|
|
242
|
+
for f in discover_files(subdir):
|
|
243
|
+
if f not in seen:
|
|
244
|
+
seen.add(f)
|
|
245
|
+
target_files.append(f)
|
|
246
|
+
else:
|
|
247
|
+
target_files = discover_files(root)
|
|
248
|
+
|
|
249
|
+
if not target_files:
|
|
250
|
+
if not silent:
|
|
251
|
+
console.print(
|
|
252
|
+
f"[red]✗[/] No pyproject.toml or requirements*.txt found in {root}"
|
|
253
|
+
)
|
|
254
|
+
raise typer.Exit(1)
|
|
255
|
+
|
|
256
|
+
# ── Build entries per file ────────────────────────────────────────────────
|
|
257
|
+
# file_path → group_label → raw entries
|
|
258
|
+
raw_file_groups: dict[
|
|
259
|
+
Path, dict[str, list[tuple[str, Path | None, FileKind, int | None]]]
|
|
260
|
+
] = {}
|
|
261
|
+
|
|
262
|
+
for file_path in target_files:
|
|
263
|
+
if file_path.name == "pyproject.toml":
|
|
264
|
+
try:
|
|
265
|
+
raw_groups = parse_pyproject(file_path)
|
|
266
|
+
except Exception as e:
|
|
267
|
+
if not silent:
|
|
268
|
+
console.print(f"[red]✗[/] Failed to parse {file_path}: {e}")
|
|
269
|
+
continue
|
|
270
|
+
raw_file_groups[file_path] = {
|
|
271
|
+
label: [(s, file_path, FileKind.PYPROJECT, None) for s in deps]
|
|
272
|
+
for label, deps in raw_groups.items()
|
|
273
|
+
}
|
|
274
|
+
else:
|
|
275
|
+
try:
|
|
276
|
+
pairs = parse_requirements_file(file_path)
|
|
277
|
+
except Exception as e:
|
|
278
|
+
if not silent:
|
|
279
|
+
console.print(f"[red]✗[/] Failed to parse {file_path}: {e}")
|
|
280
|
+
continue
|
|
281
|
+
raw_file_groups[file_path] = {
|
|
282
|
+
"requirements": [
|
|
283
|
+
(s, file_path, FileKind.REQUIREMENTS, ln) for ln, s in pairs
|
|
284
|
+
]
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if not raw_file_groups:
|
|
288
|
+
raise typer.Exit(1)
|
|
289
|
+
|
|
290
|
+
total_packages = sum(
|
|
291
|
+
len(entries)
|
|
292
|
+
for groups in raw_file_groups.values()
|
|
293
|
+
for entries in groups.values()
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# ── Resolve (fetch PyPI) ──────────────────────────────────────────────────
|
|
297
|
+
resolved: dict[Path, dict[str, list[DepInfo]]] = {}
|
|
298
|
+
|
|
299
|
+
status_msg = f"[dim]Checking {total_packages} package(s) on PyPI…[/]"
|
|
300
|
+
with console.status(status_msg, spinner="dots") if not silent else _nullctx():
|
|
301
|
+
for file_path, groups in raw_file_groups.items():
|
|
302
|
+
resolved[file_path] = {}
|
|
303
|
+
for label, entries in groups.items():
|
|
304
|
+
resolved[file_path][label] = resolve_deps(
|
|
305
|
+
entries,
|
|
306
|
+
include_pat=include_pat,
|
|
307
|
+
exclude_pat=exclude_pat,
|
|
308
|
+
pre=pre,
|
|
309
|
+
concurrency=concurrency,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# ── JSON output ───────────────────────────────────────────────────────────
|
|
313
|
+
if output_json:
|
|
314
|
+
render_json({str(fp): grps for fp, grps in resolved.items()})
|
|
315
|
+
total_outdated = _count_outdated(resolved, mode)
|
|
316
|
+
raise typer.Exit(1 if (fail_on_outdated and total_outdated) else 0)
|
|
317
|
+
|
|
318
|
+
# ── Rich display ──────────────────────────────────────────────────────────
|
|
319
|
+
if not silent:
|
|
320
|
+
console.print()
|
|
321
|
+
|
|
322
|
+
total_outdated = 0
|
|
323
|
+
|
|
324
|
+
for file_path, groups in resolved.items():
|
|
325
|
+
file_outdated = _count_outdated({file_path: groups}, mode)
|
|
326
|
+
total_outdated += file_outdated
|
|
327
|
+
|
|
328
|
+
if not silent:
|
|
329
|
+
# Compute column widths across all groups in this file so every
|
|
330
|
+
# group aligns to the same grid.
|
|
331
|
+
all_infos = [i for infos in groups.values() for i in infos]
|
|
332
|
+
from .display import _age
|
|
333
|
+
|
|
334
|
+
col_widths = (
|
|
335
|
+
max((len(i.name) for i in all_infos), default=0),
|
|
336
|
+
max((len(i.current_spec) for i in all_infos), default=0),
|
|
337
|
+
max((len(_age(i.current_release_date)) for i in all_infos), default=0),
|
|
338
|
+
max((len(_age(i.release_date)) for i in all_infos), default=0),
|
|
339
|
+
max((len(i.latest_spec) for i in all_infos), default=0),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
console.print(
|
|
343
|
+
f" [bold]📦 {file_path.name}[/] [dim]{file_path.resolve()}[/]"
|
|
344
|
+
)
|
|
345
|
+
console.print()
|
|
346
|
+
|
|
347
|
+
for label, infos in groups.items():
|
|
348
|
+
if render_group(
|
|
349
|
+
label,
|
|
350
|
+
infos,
|
|
351
|
+
mode=mode,
|
|
352
|
+
show_up_to_date=all_deps,
|
|
353
|
+
sort=sort,
|
|
354
|
+
col_widths=col_widths,
|
|
355
|
+
):
|
|
356
|
+
console.print()
|
|
357
|
+
|
|
358
|
+
if file_outdated == 0:
|
|
359
|
+
console.print(" [green]✓ All dependencies are up to date![/]")
|
|
360
|
+
console.print()
|
|
361
|
+
|
|
362
|
+
if total_outdated == 0:
|
|
363
|
+
raise typer.Exit(0)
|
|
364
|
+
|
|
365
|
+
# ── Interactive selection ─────────────────────────────────────────────────
|
|
366
|
+
selected_for_update: set[str] | None = None # None = all
|
|
367
|
+
|
|
368
|
+
if interactive and not silent:
|
|
369
|
+
all_outdated = [
|
|
370
|
+
i
|
|
371
|
+
for groups in resolved.values()
|
|
372
|
+
for infos in groups.values()
|
|
373
|
+
for i in infos
|
|
374
|
+
if i.is_shown(mode)
|
|
375
|
+
]
|
|
376
|
+
chosen = interactive_select(all_outdated)
|
|
377
|
+
selected_for_update = {i.name for i in chosen}
|
|
378
|
+
console.print()
|
|
379
|
+
|
|
380
|
+
# ── Write ─────────────────────────────────────────────────────────────────
|
|
381
|
+
if write:
|
|
382
|
+
total_written = 0
|
|
383
|
+
for file_path, groups in resolved.items():
|
|
384
|
+
# Filter to selected packages if in interactive mode
|
|
385
|
+
if selected_for_update is not None:
|
|
386
|
+
filtered: dict[str, list[DepInfo]] = {
|
|
387
|
+
label: [i for i in infos if i.name in selected_for_update]
|
|
388
|
+
for label, infos in groups.items()
|
|
389
|
+
}
|
|
390
|
+
else:
|
|
391
|
+
filtered = groups
|
|
392
|
+
|
|
393
|
+
if file_path.name == "pyproject.toml":
|
|
394
|
+
updated = write_pyproject_updates(file_path, filtered)
|
|
395
|
+
else:
|
|
396
|
+
flat = [i for infos in filtered.values() for i in infos]
|
|
397
|
+
updated = write_requirements_updates(file_path, flat)
|
|
398
|
+
|
|
399
|
+
if updated and not silent:
|
|
400
|
+
console.print(
|
|
401
|
+
f" [green]✓[/] Wrote [bold]{updated}[/] update(s) to "
|
|
402
|
+
f"[cyan]{file_path.name}[/]"
|
|
403
|
+
)
|
|
404
|
+
total_written += updated
|
|
405
|
+
|
|
406
|
+
if total_written and not silent:
|
|
407
|
+
console.print()
|
|
408
|
+
elif not silent:
|
|
409
|
+
console.print(
|
|
410
|
+
f" [dim]Run [cyan]taze -w[/] to write {total_outdated} update(s)[/]"
|
|
411
|
+
)
|
|
412
|
+
console.print()
|
|
413
|
+
|
|
414
|
+
# ── Prompt to install after -w (unless -i/-u already set) ───────────────
|
|
415
|
+
if write and not install and not update and not silent and total_written > 0:
|
|
416
|
+
console.print(" [dim]Run [cyan]uv sync[/] now? [bold](y/N)[/] [/]", end="")
|
|
417
|
+
try:
|
|
418
|
+
answer = input().strip().lower()
|
|
419
|
+
except EOFError, KeyboardInterrupt:
|
|
420
|
+
answer = ""
|
|
421
|
+
console.print()
|
|
422
|
+
if answer == "y":
|
|
423
|
+
install = True
|
|
424
|
+
|
|
425
|
+
# ── uv sync / install ────────────────────────────────────────────────────
|
|
426
|
+
if install or update:
|
|
427
|
+
uv_cwd = next(
|
|
428
|
+
(fp.parent for fp in resolved if fp.name == "pyproject.toml"),
|
|
429
|
+
root,
|
|
430
|
+
)
|
|
431
|
+
if not silent:
|
|
432
|
+
console.print(" [dim]Running [cyan]uv sync[/]…[/]")
|
|
433
|
+
result = subprocess.run(
|
|
434
|
+
["uv", "sync"],
|
|
435
|
+
cwd=uv_cwd,
|
|
436
|
+
capture_output=silent,
|
|
437
|
+
)
|
|
438
|
+
if result.returncode != 0:
|
|
439
|
+
if not silent:
|
|
440
|
+
console.print("[red]✗[/] [bold]uv sync[/] failed")
|
|
441
|
+
raise typer.Exit(result.returncode)
|
|
442
|
+
if not silent:
|
|
443
|
+
console.print(" [green]✓[/] [bold]uv sync[/] complete")
|
|
444
|
+
console.print()
|
|
445
|
+
|
|
446
|
+
raise typer.Exit(1 if (fail_on_outdated and total_outdated) else 0)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _count_outdated(resolved: dict[Path, dict[str, list[DepInfo]]], mode: str) -> int:
|
|
453
|
+
return sum(
|
|
454
|
+
1
|
|
455
|
+
for groups in resolved.values()
|
|
456
|
+
for infos in groups.values()
|
|
457
|
+
for i in infos
|
|
458
|
+
if i.is_shown(mode)
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class _nullctx:
|
|
463
|
+
"""No-op context manager (replaces console.status when --silent)."""
|
|
464
|
+
|
|
465
|
+
def __enter__(self) -> _nullctx:
|
|
466
|
+
return self
|
|
467
|
+
|
|
468
|
+
def __exit__(self, *_: object) -> None:
|
|
469
|
+
pass
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ─── Entry point ──────────────────────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def run() -> None:
|
|
476
|
+
app()
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
if __name__ == "__main__":
|
|
480
|
+
run()
|
taze/models.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from packaging.requirements import Requirement
|
|
8
|
+
from packaging.version import InvalidVersion, Version
|
|
9
|
+
|
|
10
|
+
BUMP_ORDER: dict[str, int] = {"major": 3, "minor": 2, "patch": 1, "same": 0, "?": -1}
|
|
11
|
+
|
|
12
|
+
BUMP_COLOR: dict[str, str] = {
|
|
13
|
+
"major": "red",
|
|
14
|
+
"minor": "yellow",
|
|
15
|
+
"patch": "green",
|
|
16
|
+
"same": "dim",
|
|
17
|
+
"?": "dim",
|
|
18
|
+
}
|
|
19
|
+
BUMP_BADGE: dict[str, str] = {
|
|
20
|
+
"major": "[bold red]MAJOR[/]",
|
|
21
|
+
"minor": "[yellow]minor[/]",
|
|
22
|
+
"patch": "[green]patch[/]",
|
|
23
|
+
"same": "[dim]up to date[/]",
|
|
24
|
+
"?": "[dim]?[/]",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Mode → (min_bump_level, include_pre)
|
|
28
|
+
# min_bump_level: only show updates at this level or above
|
|
29
|
+
MODE_SETTINGS: dict[str, tuple[str, bool]] = {
|
|
30
|
+
"default": ("patch", False),
|
|
31
|
+
"major": ("patch", False),
|
|
32
|
+
"latest": ("patch", False),
|
|
33
|
+
"stable": ("patch", False),
|
|
34
|
+
"minor": ("patch", False), # filter applied post-fetch
|
|
35
|
+
"patch": ("patch", False), # filter applied post-fetch
|
|
36
|
+
"newest": ("patch", True),
|
|
37
|
+
"next": ("patch", True),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
MODES = list(MODE_SETTINGS)
|
|
41
|
+
|
|
42
|
+
# Which bump levels each mode actually shows
|
|
43
|
+
MODE_MIN_BUMP: dict[str, str] = {
|
|
44
|
+
"default": "patch",
|
|
45
|
+
"major": "patch",
|
|
46
|
+
"latest": "patch",
|
|
47
|
+
"stable": "patch",
|
|
48
|
+
"newest": "patch",
|
|
49
|
+
"next": "patch",
|
|
50
|
+
"minor": "patch", # no major bumps
|
|
51
|
+
"patch": "patch", # no minor or major bumps
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
MODE_SHOWS_MAJOR: dict[str, bool] = {
|
|
55
|
+
"major": True,
|
|
56
|
+
"default": True,
|
|
57
|
+
"latest": True,
|
|
58
|
+
"stable": True,
|
|
59
|
+
"newest": True,
|
|
60
|
+
"next": True,
|
|
61
|
+
"minor": False,
|
|
62
|
+
"patch": False,
|
|
63
|
+
}
|
|
64
|
+
MODE_SHOWS_MINOR: dict[str, bool] = {
|
|
65
|
+
"major": True,
|
|
66
|
+
"default": True,
|
|
67
|
+
"latest": True,
|
|
68
|
+
"stable": True,
|
|
69
|
+
"newest": True,
|
|
70
|
+
"next": True,
|
|
71
|
+
"minor": True,
|
|
72
|
+
"patch": False,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FileKind(StrEnum):
|
|
77
|
+
PYPROJECT = "pyproject"
|
|
78
|
+
REQUIREMENTS = "requirements"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def calc_bump(current: str | None, latest: str | None) -> str:
|
|
82
|
+
if not current or not latest:
|
|
83
|
+
return "?"
|
|
84
|
+
try:
|
|
85
|
+
c = Version(current)
|
|
86
|
+
la = Version(latest)
|
|
87
|
+
if la <= c:
|
|
88
|
+
return "same"
|
|
89
|
+
if la.major > c.major:
|
|
90
|
+
return "major"
|
|
91
|
+
if la.minor > c.minor:
|
|
92
|
+
return "minor"
|
|
93
|
+
return "patch"
|
|
94
|
+
except InvalidVersion:
|
|
95
|
+
return "?"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def bump_allowed(bump: str, mode: str) -> bool:
|
|
99
|
+
"""Return True if this bump level should be shown/updated in the given mode."""
|
|
100
|
+
if bump in ("same", "?"):
|
|
101
|
+
return False
|
|
102
|
+
if bump == "major" and not MODE_SHOWS_MAJOR.get(mode, True):
|
|
103
|
+
return False
|
|
104
|
+
if bump == "minor" and not MODE_SHOWS_MINOR.get(mode, True):
|
|
105
|
+
return False
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class DepInfo:
|
|
111
|
+
raw: str
|
|
112
|
+
name: str
|
|
113
|
+
current: str | None
|
|
114
|
+
operator: str | None
|
|
115
|
+
source_file: Path | None = None
|
|
116
|
+
file_kind: FileKind = FileKind.PYPROJECT
|
|
117
|
+
line_number: int | None = None
|
|
118
|
+
latest: str | None = None
|
|
119
|
+
release_date: str | None = None # ISO date of latest release
|
|
120
|
+
current_release_date: str | None = None # ISO date of the current pinned release
|
|
121
|
+
bump: str = "?"
|
|
122
|
+
fetch_error: bool = False
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def current_spec(self) -> str:
|
|
126
|
+
if self.operator and self.current:
|
|
127
|
+
return f"{self.operator}{self.current}"
|
|
128
|
+
return "(any)"
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def latest_spec(self) -> str:
|
|
132
|
+
if not self.latest:
|
|
133
|
+
return "—"
|
|
134
|
+
if self.operator:
|
|
135
|
+
if self.operator == "~=":
|
|
136
|
+
n = len(self.current.split(".")) if self.current else 2
|
|
137
|
+
parts = self.latest.split(".")[:n]
|
|
138
|
+
return f"~={'.'.join(parts)}"
|
|
139
|
+
return f"{self.operator}{self.latest}"
|
|
140
|
+
return self.latest
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def is_outdated(self) -> bool:
|
|
144
|
+
return self.bump not in ("same", "?") and not self.fetch_error
|
|
145
|
+
|
|
146
|
+
def is_shown(self, mode: str) -> bool:
|
|
147
|
+
"""True if this dep's update should be shown in the given mode."""
|
|
148
|
+
if self.fetch_error:
|
|
149
|
+
return True
|
|
150
|
+
if not self.is_outdated:
|
|
151
|
+
return False
|
|
152
|
+
return bump_allowed(self.bump, mode)
|
|
153
|
+
|
|
154
|
+
def updated_raw(self) -> str:
|
|
155
|
+
if not self.latest or not self.operator:
|
|
156
|
+
return self.raw
|
|
157
|
+
try:
|
|
158
|
+
req = Requirement(self.raw)
|
|
159
|
+
except Exception:
|
|
160
|
+
return self.raw
|
|
161
|
+
|
|
162
|
+
new_specs: list[str] = []
|
|
163
|
+
for spec in req.specifier:
|
|
164
|
+
if spec.operator in (">=", "==", ">"):
|
|
165
|
+
new_specs.append(f"{spec.operator}{self.latest}")
|
|
166
|
+
elif spec.operator == "~=":
|
|
167
|
+
n = len(spec.version.split("."))
|
|
168
|
+
parts = self.latest.split(".")[:n]
|
|
169
|
+
new_specs.append(f"~={'.'.join(parts)}")
|
|
170
|
+
else:
|
|
171
|
+
new_specs.append(str(spec))
|
|
172
|
+
|
|
173
|
+
extras = f"[{','.join(sorted(req.extras))}]" if req.extras else ""
|
|
174
|
+
return f"{req.name}{extras}{','.join(new_specs)}"
|
taze/parsers.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
8
|
+
|
|
9
|
+
from .models import DepInfo, FileKind
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_dep_string(
|
|
13
|
+
raw: str,
|
|
14
|
+
*,
|
|
15
|
+
source_file: Path | None = None,
|
|
16
|
+
file_kind: FileKind = FileKind.PYPROJECT,
|
|
17
|
+
line_number: int | None = None,
|
|
18
|
+
) -> DepInfo | None:
|
|
19
|
+
raw = raw.strip()
|
|
20
|
+
if not raw or raw.startswith(("#", "-")):
|
|
21
|
+
return None
|
|
22
|
+
raw = re.sub(r"\s+#.*$", "", raw).strip()
|
|
23
|
+
if not raw:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
req = Requirement(raw)
|
|
28
|
+
except InvalidRequirement, Exception:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
name = req.name.lower().replace("_", "-")
|
|
32
|
+
specs = list(req.specifier)
|
|
33
|
+
current: str | None = None
|
|
34
|
+
operator: str | None = None
|
|
35
|
+
|
|
36
|
+
for op in ("==", ">=", "~=", ">"):
|
|
37
|
+
for spec in specs:
|
|
38
|
+
if spec.operator == op:
|
|
39
|
+
current = spec.version
|
|
40
|
+
operator = op
|
|
41
|
+
break
|
|
42
|
+
if current:
|
|
43
|
+
break
|
|
44
|
+
|
|
45
|
+
return DepInfo(
|
|
46
|
+
raw=raw,
|
|
47
|
+
name=name,
|
|
48
|
+
current=current,
|
|
49
|
+
operator=operator,
|
|
50
|
+
source_file=source_file,
|
|
51
|
+
file_kind=file_kind,
|
|
52
|
+
line_number=line_number,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_pyproject(path: Path) -> dict[str, list[str]]:
|
|
57
|
+
"""Return group_label → raw dep strings from all recognised sections."""
|
|
58
|
+
with open(path, "rb") as f:
|
|
59
|
+
data = tomllib.load(f)
|
|
60
|
+
|
|
61
|
+
groups: dict[str, list[str]] = {}
|
|
62
|
+
|
|
63
|
+
project_deps: list = data.get("project", {}).get("dependencies", [])
|
|
64
|
+
if project_deps:
|
|
65
|
+
groups["dependencies"] = [d for d in project_deps if isinstance(d, str)]
|
|
66
|
+
|
|
67
|
+
for grp, dep_list in (
|
|
68
|
+
data.get("project", {}).get("optional-dependencies", {}).items()
|
|
69
|
+
):
|
|
70
|
+
groups[f"optional:{grp}"] = [d for d in dep_list if isinstance(d, str)]
|
|
71
|
+
|
|
72
|
+
for grp, dep_list in data.get("dependency-groups", {}).items():
|
|
73
|
+
str_deps = [d for d in dep_list if isinstance(d, str)]
|
|
74
|
+
if str_deps:
|
|
75
|
+
groups[f"group:{grp}"] = str_deps
|
|
76
|
+
|
|
77
|
+
uv_dev: list = data.get("tool", {}).get("uv", {}).get("dev-dependencies", [])
|
|
78
|
+
if uv_dev:
|
|
79
|
+
groups["dev-dependencies"] = [d for d in uv_dev if isinstance(d, str)]
|
|
80
|
+
|
|
81
|
+
return groups
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_requirements_file(path: Path) -> list[tuple[int, str]]:
|
|
85
|
+
"""Return (line_number, dep_string) pairs from a requirements file."""
|
|
86
|
+
result: list[tuple[int, str]] = []
|
|
87
|
+
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
88
|
+
stripped = line.strip()
|
|
89
|
+
if not stripped or stripped.startswith(("#", "-")):
|
|
90
|
+
continue
|
|
91
|
+
dep = re.sub(r"\s+#.*$", "", stripped).strip()
|
|
92
|
+
if dep:
|
|
93
|
+
result.append((i, dep))
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_name_filter(pattern: str) -> re.Pattern[str] | None:
|
|
98
|
+
"""
|
|
99
|
+
Build a compiled regex from a comma-separated list.
|
|
100
|
+
Entries wrapped in /slashes/ are treated as raw regex patterns;
|
|
101
|
+
plain names are matched literally (normalised to lowercase with hyphens).
|
|
102
|
+
"""
|
|
103
|
+
parts = [p.strip() for p in pattern.split(",") if p.strip()]
|
|
104
|
+
if not parts:
|
|
105
|
+
return None
|
|
106
|
+
alternatives: list[str] = []
|
|
107
|
+
for p in parts:
|
|
108
|
+
if p.startswith("/") and p.endswith("/") and len(p) > 2:
|
|
109
|
+
alternatives.append(p[1:-1])
|
|
110
|
+
else:
|
|
111
|
+
alternatives.append(re.escape(p.lower().replace("_", "-")))
|
|
112
|
+
return re.compile(r"^(?:" + "|".join(alternatives) + r")$")
|
taze/pypi.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.request
|
|
5
|
+
from urllib.error import URLError
|
|
6
|
+
|
|
7
|
+
from packaging.version import InvalidVersion, Version
|
|
8
|
+
|
|
9
|
+
_USER_AGENT = "taze/0.1.0 (https://github.com/keksi/taze)"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def fetch_pypi_info(
|
|
13
|
+
package: str,
|
|
14
|
+
*,
|
|
15
|
+
pre: bool = False,
|
|
16
|
+
current_version: str | None = None,
|
|
17
|
+
) -> tuple[str | None, str | None, str | None]:
|
|
18
|
+
"""
|
|
19
|
+
Return (latest_version, latest_release_date, current_release_date).
|
|
20
|
+
Dates are YYYY-MM-DD strings. All three are None on failure.
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
url = f"https://pypi.org/pypi/{package}/json"
|
|
24
|
+
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
|
25
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
26
|
+
data = json.loads(resp.read())
|
|
27
|
+
except URLError, OSError, ValueError:
|
|
28
|
+
return None, None, None
|
|
29
|
+
|
|
30
|
+
info_version: str = data.get("info", {}).get("version", "")
|
|
31
|
+
releases: dict = data.get("releases", {})
|
|
32
|
+
|
|
33
|
+
current_date = _upload_date(releases, current_version) if current_version else None
|
|
34
|
+
|
|
35
|
+
# Fast path: trust info.version for stable-only queries
|
|
36
|
+
if not pre and info_version:
|
|
37
|
+
try:
|
|
38
|
+
v = Version(info_version)
|
|
39
|
+
if not v.is_prerelease and not v.is_devrelease:
|
|
40
|
+
return str(v), _upload_date(releases, info_version), current_date
|
|
41
|
+
except InvalidVersion:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
# Full scan — needed for --pre modes or when info.version is a pre-release
|
|
45
|
+
best: Version | None = None
|
|
46
|
+
for v_str, files in releases.items():
|
|
47
|
+
if not files:
|
|
48
|
+
continue
|
|
49
|
+
if all(f.get("yanked") for f in files):
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
v = Version(v_str)
|
|
53
|
+
except InvalidVersion:
|
|
54
|
+
continue
|
|
55
|
+
if not pre and (v.is_prerelease or v.is_devrelease):
|
|
56
|
+
continue
|
|
57
|
+
if best is None or v > best:
|
|
58
|
+
best = v
|
|
59
|
+
|
|
60
|
+
if best is None:
|
|
61
|
+
return None, None, current_date
|
|
62
|
+
return str(best), _upload_date(releases, str(best)), current_date
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _upload_date(releases: dict, version: str | None) -> str | None:
|
|
66
|
+
if not version:
|
|
67
|
+
return None
|
|
68
|
+
files = releases.get(version) or releases.get(version.replace("-", "_")) or []
|
|
69
|
+
for f in files:
|
|
70
|
+
ts: str = f.get("upload_time", "")
|
|
71
|
+
if ts:
|
|
72
|
+
return ts[:10] # YYYY-MM-DD
|
|
73
|
+
return None
|
taze/writers.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .models import DepInfo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def write_pyproject_updates(path: Path, all_infos: dict[str, list[DepInfo]]) -> int:
|
|
10
|
+
"""Replace outdated dep strings in pyproject.toml. Returns number of changes."""
|
|
11
|
+
content = path.read_text(encoding="utf-8")
|
|
12
|
+
count = 0
|
|
13
|
+
|
|
14
|
+
for _label, infos in all_infos.items():
|
|
15
|
+
for info in infos:
|
|
16
|
+
if not info.is_outdated:
|
|
17
|
+
continue
|
|
18
|
+
new_raw = info.updated_raw()
|
|
19
|
+
if new_raw == info.raw:
|
|
20
|
+
continue
|
|
21
|
+
# Handle both single and double-quoted TOML strings
|
|
22
|
+
for q in ('"', "'"):
|
|
23
|
+
old_quoted = re.escape(f"{q}{info.raw}{q}")
|
|
24
|
+
new_content = re.sub(old_quoted, f"{q}{new_raw}{q}", content)
|
|
25
|
+
if new_content != content:
|
|
26
|
+
content = new_content
|
|
27
|
+
count += 1
|
|
28
|
+
break
|
|
29
|
+
|
|
30
|
+
path.write_text(content, encoding="utf-8")
|
|
31
|
+
return count
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write_requirements_updates(path: Path, infos: list[DepInfo]) -> int:
|
|
35
|
+
"""Update version specs in a requirements.txt file. Returns number of changes."""
|
|
36
|
+
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
37
|
+
count = 0
|
|
38
|
+
|
|
39
|
+
for info in infos:
|
|
40
|
+
if not info.is_outdated or info.line_number is None:
|
|
41
|
+
continue
|
|
42
|
+
new_raw = info.updated_raw()
|
|
43
|
+
if new_raw == info.raw:
|
|
44
|
+
continue
|
|
45
|
+
idx = info.line_number - 1
|
|
46
|
+
if idx >= len(lines):
|
|
47
|
+
continue
|
|
48
|
+
old_line = lines[idx]
|
|
49
|
+
# Preserve trailing comment and line ending
|
|
50
|
+
tail = re.search(r"(\s+#.*)$", old_line.rstrip("\n\r"))
|
|
51
|
+
comment = tail.group(1) if tail else ""
|
|
52
|
+
ending = old_line[len(old_line.rstrip("\n\r")) :]
|
|
53
|
+
lines[idx] = new_raw + comment + ending
|
|
54
|
+
count += 1
|
|
55
|
+
|
|
56
|
+
path.write_text("".join(lines), encoding="utf-8")
|
|
57
|
+
return count
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: taze
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 🥬 Keep your Python deps fresh
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.14
|
|
7
|
+
Requires-Dist: packaging>=26.2
|
|
8
|
+
Requires-Dist: rich>=15.0.0
|
|
9
|
+
Requires-Dist: typer>=0.26.7
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# taze 🥬
|
|
13
|
+
|
|
14
|
+
Keep your Python dependencies fresh.
|
|
15
|
+
|
|
16
|
+
Inspired by [taze](https://github.com/antfu-collective/taze) for Node.js — ported to the Python ecosystem with support for `pyproject.toml` and `requirements.txt`.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- Checks all dependencies against PyPI in parallel
|
|
23
|
+
- Shows the bump level (patch / minor / **MAJOR**) with colors
|
|
24
|
+
- Displays release age for both the current and latest version
|
|
25
|
+
- Supports `pyproject.toml` (PEP 508, PEP 735, uv) and `requirements*.txt`
|
|
26
|
+
- Writes updated version constraints back to the file (`-w`)
|
|
27
|
+
- Runs `uv sync` after writing (`-i`)
|
|
28
|
+
- Interactive package selection (`-I`)
|
|
29
|
+
- Recursive monorepo scanning (`-r`)
|
|
30
|
+
- Pre-release support (`newest` / `next` mode)
|
|
31
|
+
- Regex filtering for include / exclude
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
uv tool install taze
|
|
39
|
+
# or
|
|
40
|
+
pip install taze
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
taze [mode] [options]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Modes
|
|
52
|
+
|
|
53
|
+
| Mode | What it shows |
|
|
54
|
+
|-----------|----------------------------------------|
|
|
55
|
+
| `default` | All stable updates (default) |
|
|
56
|
+
| `major` | Same as default |
|
|
57
|
+
| `minor` | Minor and patch updates only |
|
|
58
|
+
| `patch` | Patch updates only |
|
|
59
|
+
| `latest` | All stable updates |
|
|
60
|
+
| `stable` | All stable updates |
|
|
61
|
+
| `newest` | All updates including pre-releases |
|
|
62
|
+
| `next` | Same as newest |
|
|
63
|
+
|
|
64
|
+
### Examples
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
# Check everything in the current directory
|
|
68
|
+
taze
|
|
69
|
+
|
|
70
|
+
# Only show minor and patch updates
|
|
71
|
+
taze minor
|
|
72
|
+
|
|
73
|
+
# Write patch updates back to pyproject.toml
|
|
74
|
+
taze patch -w
|
|
75
|
+
|
|
76
|
+
# Write all updates and run uv sync
|
|
77
|
+
taze -w -i
|
|
78
|
+
|
|
79
|
+
# Interactive — pick which packages to update
|
|
80
|
+
taze -I
|
|
81
|
+
|
|
82
|
+
# Include pre-releases
|
|
83
|
+
taze newest
|
|
84
|
+
|
|
85
|
+
# Scan all subdirectories (monorepo)
|
|
86
|
+
taze -r
|
|
87
|
+
|
|
88
|
+
# Only check specific packages
|
|
89
|
+
taze -n requests,httpx
|
|
90
|
+
|
|
91
|
+
# Skip packages matching a pattern
|
|
92
|
+
taze -x /^pytest/
|
|
93
|
+
|
|
94
|
+
# Sort by largest update first
|
|
95
|
+
taze --sort diff-desc
|
|
96
|
+
|
|
97
|
+
# Machine-readable JSON output
|
|
98
|
+
taze --json
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Options
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
-w, --write Write updates back to file
|
|
105
|
+
-i, --install Run uv sync after writing (implies -w)
|
|
106
|
+
-u, --update Alias for --install
|
|
107
|
+
-I, --interactive Choose which packages to update interactively
|
|
108
|
+
-r, --recursive Scan subdirectories for pyproject.toml / requirements*.txt
|
|
109
|
+
-a, --all Show up-to-date packages too
|
|
110
|
+
-n, --include <deps> Only check these packages (comma-separated or /regex/)
|
|
111
|
+
-x, --exclude <deps> Skip these packages (comma-separated or /regex/)
|
|
112
|
+
-C, --cwd <path> Working directory
|
|
113
|
+
-s, --silent No output
|
|
114
|
+
-v, --version Show version
|
|
115
|
+
--sort <type> Sort output: name-asc | name-desc | diff-asc | diff-desc
|
|
116
|
+
--fail-on-outdated Exit with code 1 if any outdated dependencies are found
|
|
117
|
+
--concurrency <n> Number of concurrent PyPI requests (default: 10)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Output
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
📦 pyproject.toml /home/user/myproject/pyproject.toml
|
|
126
|
+
|
|
127
|
+
dependencies 3 outdated
|
|
128
|
+
packaging ~8mo >=25.2 → >=26.2 ~3mo MAJOR
|
|
129
|
+
rich ~4mo >=14.0.0 → >=15.0.0 ~3d MAJOR
|
|
130
|
+
typer ~6mo >=0.25.7 → >=0.26.7 ~3d minor
|
|
131
|
+
|
|
132
|
+
group:dev 1 outdated
|
|
133
|
+
ruff ~8mo >=0.14.1 → >=0.15.19 ~1d minor
|
|
134
|
+
|
|
135
|
+
Run taze -w to write 4 update(s) to pyproject.toml
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The age columns show how old the **current** pinned version is and how recently the **latest** version was released — green for < 4 weeks, yellow for < 6 months, red for older.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Supported file formats
|
|
143
|
+
|
|
144
|
+
| File | Section |
|
|
145
|
+
|-------------------------|------------------------------------|
|
|
146
|
+
| `pyproject.toml` | `[project] dependencies` |
|
|
147
|
+
| `pyproject.toml` | `[project.optional-dependencies.*]`|
|
|
148
|
+
| `pyproject.toml` | `[dependency-groups.*]` (PEP 735) |
|
|
149
|
+
| `pyproject.toml` | `[tool.uv.dev-dependencies]` |
|
|
150
|
+
| `requirements*.txt` | Standard pip format |
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
taze/__init__.py,sha256=LyNZkPBaiJXRs8v_6cxSTTTM1AzZ3_ukG7mp2UYSCOY,57
|
|
2
|
+
taze/display.py,sha256=EpTj-vw-IJs18LM667M0OKgOEcovu8eoNnvjIt5Cfpk,7445
|
|
3
|
+
taze/main.py,sha256=rHuZiSx2Rx94Pu_j14DrktQakG3fLr9F6NuclYTdEKM,17354
|
|
4
|
+
taze/models.py,sha256=hzE4wWTykjU7mNUxogHPxig9Y_8e0JEDGlFoc4XYpV0,4928
|
|
5
|
+
taze/parsers.py,sha256=QhkepCuf-4HDeuP3fis3qbX54cSBqo6pvFzT5qxZRMA,3429
|
|
6
|
+
taze/pypi.py,sha256=4WkJD9-PgLKtxfwKxY0PhYBRPnavBchBG1SSXsHbYlI,2352
|
|
7
|
+
taze/writers.py,sha256=5AcXG7pEXD2kiQJwToseGIw0xSnFQfk0xyW5RDvxQvw,1930
|
|
8
|
+
taze-0.1.0.dist-info/METADATA,sha256=rFzVvURmoHeJ2Oq5SuoUTTuy9XOs4v_1BqA5vAFbJ5E,4324
|
|
9
|
+
taze-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
taze-0.1.0.dist-info/entry_points.txt,sha256=b2bmaJT3y_KBijXe3tUSFD-7DTdNgGk395Az_wrt6Dc,39
|
|
11
|
+
taze-0.1.0.dist-info/licenses/LICENSE,sha256=247Yhls8ejX4fDzpkJ4CwKXjMMlil7tOSN_N2UE88E8,1062
|
|
12
|
+
taze-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Keksi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|