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 ADDED
@@ -0,0 +1,3 @@
1
+ from .main import __version__
2
+
3
+ __all__ = ["__version__"]
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ taze = taze.main:app
@@ -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.