peta 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.
- peta/__init__.py +1 -0
- peta/__main__.py +7 -0
- peta/__metadata__.py +6 -0
- peta/_version.py +24 -0
- peta/cli/__init__.py +1 -0
- peta/cli/app.py +214 -0
- peta/cli/commands/__init__.py +1 -0
- peta/cli/commands/compare.py +70 -0
- peta/cli/commands/deps.py +81 -0
- peta/cli/commands/files.py +28 -0
- peta/cli/commands/info.py +59 -0
- peta/cli/commands/versions.py +161 -0
- peta/cli/output/__init__.py +1 -0
- peta/cli/output/console.py +42 -0
- peta/cli/output/json.py +115 -0
- peta/cli/output/tables.py +206 -0
- peta/cli/state.py +14 -0
- peta/core/__init__.py +1 -0
- peta/core/deptree.py +215 -0
- peta/core/enrich.py +46 -0
- peta/core/local.py +77 -0
- peta/core/models.py +54 -0
- peta/core/osv.py +144 -0
- peta/core/remote.py +176 -0
- peta/core/resolve.py +81 -0
- peta/core/stats.py +143 -0
- peta/core/vulns.py +74 -0
- peta/py.typed +0 -0
- peta-0.1.0.dist-info/METADATA +132 -0
- peta-0.1.0.dist-info/RECORD +33 -0
- peta-0.1.0.dist-info/WHEEL +4 -0
- peta-0.1.0.dist-info/entry_points.txt +2 -0
- peta-0.1.0.dist-info/licenses/LICENSE +21 -0
peta/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""peta - Human-friendly Python package metadata viewer."""
|
peta/__main__.py
ADDED
peta/__metadata__.py
ADDED
peta/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
peta/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line interface."""
|
peta/cli/app.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Typer application and command registration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from importlib.metadata import Distribution, PackageNotFoundError
|
|
7
|
+
from typing import Annotated, cast
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from peta.__metadata__ import PROJECT_NAME
|
|
12
|
+
from peta.cli.commands import (
|
|
13
|
+
compare as compare_mod,
|
|
14
|
+
deps as deps_mod,
|
|
15
|
+
files as files_mod,
|
|
16
|
+
info as info_mod,
|
|
17
|
+
versions as versions_mod,
|
|
18
|
+
)
|
|
19
|
+
from peta.cli.output.console import resolve_color
|
|
20
|
+
from peta.cli.state import CliState
|
|
21
|
+
|
|
22
|
+
__all__ = ["compare", "deps", "files", "info", "main", "run", "versions"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_SUBCOMMANDS = {
|
|
26
|
+
"info",
|
|
27
|
+
"deps",
|
|
28
|
+
"files",
|
|
29
|
+
"versions",
|
|
30
|
+
"compare",
|
|
31
|
+
"--help",
|
|
32
|
+
"-h",
|
|
33
|
+
"--version",
|
|
34
|
+
"-V",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
app = typer.Typer(
|
|
38
|
+
name="peta",
|
|
39
|
+
help="Human-friendly Python package metadata viewer.",
|
|
40
|
+
no_args_is_help=True,
|
|
41
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _version_callback(value: bool) -> None:
|
|
46
|
+
if not value:
|
|
47
|
+
return
|
|
48
|
+
try:
|
|
49
|
+
distribution = Distribution.from_name(PROJECT_NAME)
|
|
50
|
+
except PackageNotFoundError as exc:
|
|
51
|
+
typer.echo("Error: peta package metadata not found.", err=True)
|
|
52
|
+
raise typer.Exit(code=1) from exc
|
|
53
|
+
typer.echo(f"peta {distribution.version}")
|
|
54
|
+
raise typer.Exit
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _color_from_ctx(ctx: typer.Context) -> bool:
|
|
58
|
+
"""Read the resolved color setting stashed on the root context.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The resolved color flag, or ``False`` if unavailable (e.g. the root
|
|
62
|
+
callback did not run, as can happen when invoking a command object
|
|
63
|
+
directly in tests).
|
|
64
|
+
"""
|
|
65
|
+
obj = cast("object", ctx.obj)
|
|
66
|
+
return obj.color if isinstance(obj, CliState) else False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.callback(invoke_without_command=True)
|
|
70
|
+
def main(
|
|
71
|
+
ctx: typer.Context,
|
|
72
|
+
_version: Annotated[
|
|
73
|
+
bool,
|
|
74
|
+
typer.Option(
|
|
75
|
+
"--version",
|
|
76
|
+
"-V",
|
|
77
|
+
callback=_version_callback,
|
|
78
|
+
is_eager=True,
|
|
79
|
+
help="Show version and exit.",
|
|
80
|
+
),
|
|
81
|
+
] = False,
|
|
82
|
+
no_color: Annotated[
|
|
83
|
+
bool, typer.Option("--no-color", help="Disable colored output.")
|
|
84
|
+
] = False,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Human-friendly Python package metadata viewer."""
|
|
87
|
+
ctx.obj = CliState(color=resolve_color(no_color=no_color))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.command()
|
|
91
|
+
def info(
|
|
92
|
+
ctx: typer.Context,
|
|
93
|
+
package: Annotated[
|
|
94
|
+
str, typer.Argument(help="Package name (optionally name==version).")
|
|
95
|
+
],
|
|
96
|
+
use_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
|
|
97
|
+
local: Annotated[
|
|
98
|
+
bool, typer.Option("--local", "-l", help="Force local lookup.")
|
|
99
|
+
] = False,
|
|
100
|
+
remote: Annotated[
|
|
101
|
+
bool, typer.Option("--remote", "-r", help="Force PyPI lookup.")
|
|
102
|
+
] = False,
|
|
103
|
+
no_osv: Annotated[
|
|
104
|
+
bool, typer.Option("--no-osv", help="Skip the OSV vulnerability lookup.")
|
|
105
|
+
] = False,
|
|
106
|
+
no_stats: Annotated[
|
|
107
|
+
bool, typer.Option("--no-stats", help="Skip download/dependent count lookups.")
|
|
108
|
+
] = False,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Show detailed package metadata."""
|
|
111
|
+
info_mod.info(
|
|
112
|
+
package,
|
|
113
|
+
use_json=use_json,
|
|
114
|
+
local=local,
|
|
115
|
+
remote=remote,
|
|
116
|
+
color=_color_from_ctx(ctx),
|
|
117
|
+
no_osv=no_osv,
|
|
118
|
+
no_stats=no_stats,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command()
|
|
123
|
+
def compare(
|
|
124
|
+
ctx: typer.Context,
|
|
125
|
+
a: Annotated[str, typer.Argument(help="First package name.")],
|
|
126
|
+
b: Annotated[str, typer.Argument(help="Second package name.")],
|
|
127
|
+
use_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
|
|
128
|
+
local: Annotated[
|
|
129
|
+
bool, typer.Option("--local", "-l", help="Force local lookup.")
|
|
130
|
+
] = False,
|
|
131
|
+
remote: Annotated[
|
|
132
|
+
bool, typer.Option("--remote", "-r", help="Force PyPI lookup.")
|
|
133
|
+
] = False,
|
|
134
|
+
no_osv: Annotated[
|
|
135
|
+
bool, typer.Option("--no-osv", help="Skip the OSV vulnerability lookup.")
|
|
136
|
+
] = False,
|
|
137
|
+
no_stats: Annotated[
|
|
138
|
+
bool, typer.Option("--no-stats", help="Skip download/dependent count lookups.")
|
|
139
|
+
] = False,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Compare two packages' metadata side by side."""
|
|
142
|
+
compare_mod.compare(
|
|
143
|
+
a,
|
|
144
|
+
b,
|
|
145
|
+
use_json=use_json,
|
|
146
|
+
local=local,
|
|
147
|
+
remote=remote,
|
|
148
|
+
color=_color_from_ctx(ctx),
|
|
149
|
+
no_osv=no_osv,
|
|
150
|
+
no_stats=no_stats,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.command()
|
|
155
|
+
def deps(
|
|
156
|
+
ctx: typer.Context,
|
|
157
|
+
package: Annotated[str, typer.Argument(help="Package name.")],
|
|
158
|
+
use_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
|
|
159
|
+
local: Annotated[
|
|
160
|
+
bool, typer.Option("--local", "-l", help="Force local lookup.")
|
|
161
|
+
] = False,
|
|
162
|
+
remote: Annotated[
|
|
163
|
+
bool, typer.Option("--remote", "-r", help="Force PyPI lookup.")
|
|
164
|
+
] = False,
|
|
165
|
+
why: Annotated[
|
|
166
|
+
str | None, typer.Option("--why", help="Show why TARGET is a dependency.")
|
|
167
|
+
] = None,
|
|
168
|
+
depth: Annotated[
|
|
169
|
+
int, typer.Option("--depth", min=1, help="Max recursion depth.")
|
|
170
|
+
] = 10,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Show a package's recursive dependency tree."""
|
|
173
|
+
deps_mod.deps(
|
|
174
|
+
package,
|
|
175
|
+
use_json=use_json,
|
|
176
|
+
local=local,
|
|
177
|
+
remote=remote,
|
|
178
|
+
color=_color_from_ctx(ctx),
|
|
179
|
+
why=why,
|
|
180
|
+
depth=depth,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.command()
|
|
185
|
+
def files(
|
|
186
|
+
ctx: typer.Context,
|
|
187
|
+
package: Annotated[str, typer.Argument(help="Package name.")],
|
|
188
|
+
use_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""List files installed by a local package."""
|
|
191
|
+
files_mod.files(package, use_json=use_json, color=_color_from_ctx(ctx))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@app.command()
|
|
195
|
+
def versions(
|
|
196
|
+
ctx: typer.Context,
|
|
197
|
+
package: Annotated[str, typer.Argument(help="Package name.")],
|
|
198
|
+
use_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
|
|
199
|
+
limit: Annotated[
|
|
200
|
+
int, typer.Option("--limit", "-n", min=1, help="Max versions to show.")
|
|
201
|
+
] = 20,
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Show published versions of a package from PyPI."""
|
|
204
|
+
versions_mod.versions(
|
|
205
|
+
package, use_json=use_json, limit=limit, color=_color_from_ctx(ctx)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def run() -> None:
|
|
210
|
+
"""Entry point; ``peta <package>`` is shorthand for ``peta info <package>``."""
|
|
211
|
+
args = sys.argv[1:]
|
|
212
|
+
if args and args[0] not in _SUBCOMMANDS and not args[0].startswith("-"):
|
|
213
|
+
sys.argv.insert(1, "info")
|
|
214
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command handlers."""
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The ``peta compare`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from peta.cli.output.json import format_compare as json_format
|
|
10
|
+
from peta.cli.output.tables import render_compare as rich_format
|
|
11
|
+
from peta.core.enrich import enrich
|
|
12
|
+
from peta.core.local import PackageNotFoundError as LocalNotFound
|
|
13
|
+
from peta.core.remote import NetworkError, PackageNotFoundError as RemoteNotFound
|
|
14
|
+
from peta.core.resolve import resolve_package
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from peta.core.models import PackageInfo
|
|
18
|
+
|
|
19
|
+
__all__ = ["compare"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Tuple constant (not an inline ``except (A, B)`` literal) so the ruff formatter
|
|
23
|
+
# cannot strip the parentheses into Python-2-only ``except A, B`` syntax.
|
|
24
|
+
_NOT_FOUND = (LocalNotFound, RemoteNotFound)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_and_enrich(
|
|
28
|
+
package: str, *, local: bool, remote: bool, no_osv: bool, no_stats: bool
|
|
29
|
+
) -> PackageInfo:
|
|
30
|
+
pkg = resolve_package(package, local=local, remote=remote)
|
|
31
|
+
return enrich(pkg, no_osv=no_osv, no_stats=no_stats)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def compare(
|
|
35
|
+
a: str,
|
|
36
|
+
b: str,
|
|
37
|
+
*,
|
|
38
|
+
use_json: bool = False,
|
|
39
|
+
local: bool = False,
|
|
40
|
+
remote: bool = False,
|
|
41
|
+
color: bool = False,
|
|
42
|
+
no_osv: bool = False,
|
|
43
|
+
no_stats: bool = False,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Compare two packages' metadata side by side.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
Exit: With code 1 if either package is not found, or code 2 on network
|
|
49
|
+
failure.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
a_pkg = _resolve_and_enrich(
|
|
53
|
+
a, local=local, remote=remote, no_osv=no_osv, no_stats=no_stats
|
|
54
|
+
)
|
|
55
|
+
b_pkg = _resolve_and_enrich(
|
|
56
|
+
b, local=local, remote=remote, no_osv=no_osv, no_stats=no_stats
|
|
57
|
+
)
|
|
58
|
+
except _NOT_FOUND as exc:
|
|
59
|
+
version = getattr(exc, "version", None)
|
|
60
|
+
target = f"{exc.name}=={version}" if version else exc.name
|
|
61
|
+
typer.echo(f"Package '{target}' not found.", err=True)
|
|
62
|
+
raise typer.Exit(code=1) from None
|
|
63
|
+
except NetworkError as exc:
|
|
64
|
+
typer.echo(str(exc), err=True)
|
|
65
|
+
raise typer.Exit(code=2) from None
|
|
66
|
+
typer.echo(
|
|
67
|
+
json_format(a_pkg, b_pkg)
|
|
68
|
+
if use_json
|
|
69
|
+
else rich_format(a_pkg, b_pkg, color=color)
|
|
70
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""The ``peta deps`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from peta.cli.output.json import format_dep_tree, format_why
|
|
8
|
+
from peta.cli.output.tables import render_dep_tree, render_why
|
|
9
|
+
from peta.core.deptree import build_tree, find_why
|
|
10
|
+
from peta.core.local import PackageNotFoundError as LocalNotFound
|
|
11
|
+
from peta.core.remote import NetworkError, PackageNotFoundError as RemoteNotFound
|
|
12
|
+
|
|
13
|
+
__all__ = ["deps"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Tuple constant (not an inline ``except (A, B)`` literal) so the ruff formatter
|
|
17
|
+
# cannot strip the parentheses into Python-2-only ``except A, B`` syntax.
|
|
18
|
+
_NOT_FOUND = (LocalNotFound, RemoteNotFound)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _print_why(
|
|
22
|
+
package: str,
|
|
23
|
+
target: str,
|
|
24
|
+
paths: list[list[str]],
|
|
25
|
+
*,
|
|
26
|
+
use_json: bool,
|
|
27
|
+
color: bool,
|
|
28
|
+
depth: int,
|
|
29
|
+
) -> None:
|
|
30
|
+
if not paths:
|
|
31
|
+
msg = (
|
|
32
|
+
f"'{target}' was not found in the dependency tree of "
|
|
33
|
+
f"'{package}' (depth {depth})."
|
|
34
|
+
)
|
|
35
|
+
typer.echo(msg, err=True)
|
|
36
|
+
raise typer.Exit(code=1)
|
|
37
|
+
typer.echo(
|
|
38
|
+
format_why(target, paths)
|
|
39
|
+
if use_json
|
|
40
|
+
else render_why(target, paths, color=color)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def deps(
|
|
45
|
+
package: str,
|
|
46
|
+
*,
|
|
47
|
+
use_json: bool = False,
|
|
48
|
+
local: bool = False,
|
|
49
|
+
remote: bool = False,
|
|
50
|
+
color: bool = False,
|
|
51
|
+
why: str | None = None,
|
|
52
|
+
depth: int = 10,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Show a package's recursive dependency tree, or ``--why`` a target is pulled in.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
Exit: With code 1 if the package (or, with ``--why``, the target) is
|
|
58
|
+
not found, or code 2 on network failure.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
tree = build_tree(package, local=local, remote=remote, max_depth=depth)
|
|
62
|
+
except _NOT_FOUND:
|
|
63
|
+
typer.echo(f"Package '{package}' not found.", err=True)
|
|
64
|
+
raise typer.Exit(code=1) from None
|
|
65
|
+
except NetworkError as exc:
|
|
66
|
+
typer.echo(str(exc), err=True)
|
|
67
|
+
raise typer.Exit(code=2) from None
|
|
68
|
+
|
|
69
|
+
if why is not None:
|
|
70
|
+
_print_why(
|
|
71
|
+
package,
|
|
72
|
+
why,
|
|
73
|
+
find_why(tree, why),
|
|
74
|
+
use_json=use_json,
|
|
75
|
+
color=color,
|
|
76
|
+
depth=depth,
|
|
77
|
+
)
|
|
78
|
+
return
|
|
79
|
+
typer.echo(
|
|
80
|
+
format_dep_tree(tree) if use_json else render_dep_tree(tree, color=color)
|
|
81
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The ``peta files`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from peta.cli.output.json import format_files as json_format
|
|
8
|
+
from peta.cli.output.tables import render_files as rich_format
|
|
9
|
+
from peta.core.local import (
|
|
10
|
+
PackageNotFoundError as LocalNotFound,
|
|
11
|
+
get_package as local_get_package,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = ["files"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def files(package: str, *, use_json: bool = False, color: bool = False) -> None:
|
|
18
|
+
"""List files installed by a local package.
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
Exit: With code 1 if the package is not installed locally.
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
pkg = local_get_package(package)
|
|
25
|
+
except LocalNotFound:
|
|
26
|
+
typer.echo(f"Package '{package}' not found locally.", err=True)
|
|
27
|
+
raise typer.Exit(code=1) from None
|
|
28
|
+
typer.echo(json_format(pkg) if use_json else rich_format(pkg, color=color))
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The ``peta info`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from peta.cli.output.json import format_info as json_format
|
|
10
|
+
from peta.cli.output.tables import render_info as rich_format
|
|
11
|
+
from peta.core.enrich import enrich
|
|
12
|
+
from peta.core.local import PackageNotFoundError as LocalNotFound
|
|
13
|
+
from peta.core.remote import NetworkError, PackageNotFoundError as RemoteNotFound
|
|
14
|
+
from peta.core.resolve import resolve_package
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from peta.core.models import PackageInfo
|
|
18
|
+
|
|
19
|
+
__all__ = ["info"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Tuple constant (not an inline ``except (A, B)`` literal) so the ruff formatter
|
|
23
|
+
# cannot strip the parentheses into Python-2-only ``except A, B`` syntax.
|
|
24
|
+
_NOT_FOUND = (LocalNotFound, RemoteNotFound)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_and_enrich(
|
|
28
|
+
package: str, *, local: bool, remote: bool, no_osv: bool, no_stats: bool
|
|
29
|
+
) -> PackageInfo:
|
|
30
|
+
pkg = resolve_package(package, local=local, remote=remote)
|
|
31
|
+
return enrich(pkg, no_osv=no_osv, no_stats=no_stats)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def info(
|
|
35
|
+
package: str,
|
|
36
|
+
*,
|
|
37
|
+
use_json: bool = False,
|
|
38
|
+
local: bool = False,
|
|
39
|
+
remote: bool = False,
|
|
40
|
+
color: bool = False,
|
|
41
|
+
no_osv: bool = False,
|
|
42
|
+
no_stats: bool = False,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Show detailed package metadata.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
Exit: With code 1 if the package is not found, or code 2 on network failure.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
pkg = _resolve_and_enrich(
|
|
51
|
+
package, local=local, remote=remote, no_osv=no_osv, no_stats=no_stats
|
|
52
|
+
)
|
|
53
|
+
except _NOT_FOUND:
|
|
54
|
+
typer.echo(f"Package '{package}' not found.", err=True)
|
|
55
|
+
raise typer.Exit(code=1) from None
|
|
56
|
+
except NetworkError as exc:
|
|
57
|
+
typer.echo(str(exc), err=True)
|
|
58
|
+
raise typer.Exit(code=2) from None
|
|
59
|
+
typer.echo(json_format(pkg) if use_json else rich_format(pkg, color=color))
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""The ``peta versions`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import operator
|
|
6
|
+
from typing import TYPE_CHECKING, cast
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import typer
|
|
10
|
+
from packaging.version import InvalidVersion, Version
|
|
11
|
+
|
|
12
|
+
from peta.cli.output.json import format_versions as json_format
|
|
13
|
+
from peta.cli.output.tables import render_versions as rich_format
|
|
14
|
+
from peta.core.remote import DEFAULT_TIMEOUT, PYPI_BASE_URL, NetworkError
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from peta.core.remote import PyPIReleaseFile
|
|
18
|
+
|
|
19
|
+
__all__ = ["get_versions", "versions"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _sorted_version_keys(releases: dict[str, list[PyPIReleaseFile]]) -> list[str]:
|
|
23
|
+
"""Return release keys newest-first, tolerating non-PEP-440 keys.
|
|
24
|
+
|
|
25
|
+
``packaging.version.Version`` raises ``InvalidVersion`` on legacy keys that
|
|
26
|
+
PyPI still serves, so parse defensively: PEP 440 versions sort newest-first,
|
|
27
|
+
and any unparsable keys are kept (sorted after) rather than letting one bad
|
|
28
|
+
key abort the whole listing with a traceback.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Release keys, PEP 440 versions newest-first, then non-PEP-440 keys.
|
|
32
|
+
"""
|
|
33
|
+
valid: list[tuple[Version, str]] = []
|
|
34
|
+
invalid: list[str] = []
|
|
35
|
+
for ver in releases:
|
|
36
|
+
try:
|
|
37
|
+
valid.append((Version(ver), ver))
|
|
38
|
+
except InvalidVersion:
|
|
39
|
+
invalid.append(ver)
|
|
40
|
+
valid.sort(key=operator.itemgetter(0), reverse=True)
|
|
41
|
+
return [ver for _, ver in valid] + sorted(invalid, reverse=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _extract_releases(body: object) -> dict[str, list[PyPIReleaseFile]]:
|
|
45
|
+
"""Validate and pull the ``releases`` mapping out of a decoded PyPI body.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The release mapping, with any non-list release values dropped.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
NetworkError: If the body, or its ``releases`` field, is not a dict.
|
|
52
|
+
"""
|
|
53
|
+
if not isinstance(body, dict):
|
|
54
|
+
msg = "malformed response from PyPI"
|
|
55
|
+
raise NetworkError(msg)
|
|
56
|
+
mapping = cast("dict[str, object]", body)
|
|
57
|
+
releases = mapping.get("releases", {})
|
|
58
|
+
if not isinstance(releases, dict):
|
|
59
|
+
msg = "malformed response from PyPI"
|
|
60
|
+
raise NetworkError(msg)
|
|
61
|
+
releases_map = cast("dict[str, object]", releases)
|
|
62
|
+
return {
|
|
63
|
+
ver: cleaned
|
|
64
|
+
for ver, raw in releases_map.items()
|
|
65
|
+
if (cleaned := _clean_files(raw)) is not None
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _clean_files(raw: object) -> list[PyPIReleaseFile] | None:
|
|
70
|
+
"""Coerce one release's file list, dropping non-dict members.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The list of file dicts, or ``None`` if ``raw`` is not a list at all.
|
|
74
|
+
"""
|
|
75
|
+
if not isinstance(raw, list):
|
|
76
|
+
return None
|
|
77
|
+
items = cast("list[object]", raw)
|
|
78
|
+
return cast("list[PyPIReleaseFile]", [f for f in items if isinstance(f, dict)])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _decode_body(response: httpx.Response) -> object:
|
|
82
|
+
"""Decode a JSON response body, mapping a decode failure to NetworkError.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The decoded body as an untyped object.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
NetworkError: If the body is not valid JSON.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
return cast("object", response.json())
|
|
92
|
+
except ValueError as exc: # includes json.JSONDecodeError
|
|
93
|
+
msg = "malformed response from PyPI"
|
|
94
|
+
raise NetworkError(msg) from exc
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_versions(name: str) -> list[dict[str, str]]:
|
|
98
|
+
"""Fetch all published versions for a package from PyPI.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
A list of ``{"version", "upload_time"}`` dicts, newest first; empty
|
|
102
|
+
if the package is not found (HTTP 404).
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
NetworkError: If the request fails, returns a non-success status, or
|
|
106
|
+
the decoded body is malformed (not a dict, or ``releases`` is not
|
|
107
|
+
a dict).
|
|
108
|
+
"""
|
|
109
|
+
url = f"{PYPI_BASE_URL}/{name}/json"
|
|
110
|
+
try:
|
|
111
|
+
response = httpx.get(url, timeout=DEFAULT_TIMEOUT)
|
|
112
|
+
except httpx.RequestError as exc:
|
|
113
|
+
raise NetworkError(str(exc)) from exc
|
|
114
|
+
|
|
115
|
+
if response.status_code == 404: # noqa: PLR2004
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
_ = response.raise_for_status()
|
|
120
|
+
except httpx.HTTPStatusError as exc:
|
|
121
|
+
msg = f"PyPI returned HTTP {exc.response.status_code}"
|
|
122
|
+
raise NetworkError(msg) from exc
|
|
123
|
+
|
|
124
|
+
# The PyPI JSON API is untyped and its shape is not guaranteed, so
|
|
125
|
+
# validate the decoded body before casting into our typed view.
|
|
126
|
+
releases = _extract_releases(_decode_body(response))
|
|
127
|
+
result: list[dict[str, str]] = []
|
|
128
|
+
for ver in _sorted_version_keys(releases):
|
|
129
|
+
files = releases[ver]
|
|
130
|
+
raw_time: object = files[0].get("upload_time", "") if files else ""
|
|
131
|
+
upload_time = raw_time[:10] if isinstance(raw_time, str) else ""
|
|
132
|
+
result.append({"version": ver, "upload_time": upload_time})
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Patch target used by tests.
|
|
137
|
+
remote_get_versions = get_versions
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def versions(
|
|
141
|
+
package: str, *, use_json: bool = False, limit: int = 20, color: bool = False
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Show published versions of a package from PyPI.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
Exit: With code 2 on network failure, or code 1 if the package is absent.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
vers = remote_get_versions(package)
|
|
150
|
+
except NetworkError as exc:
|
|
151
|
+
typer.echo(str(exc), err=True)
|
|
152
|
+
raise typer.Exit(code=2) from None
|
|
153
|
+
if not vers:
|
|
154
|
+
typer.echo(f"Package '{package}' not found on PyPI.", err=True)
|
|
155
|
+
raise typer.Exit(code=1) from None
|
|
156
|
+
shown = vers[:limit]
|
|
157
|
+
typer.echo(
|
|
158
|
+
json_format(package, shown)
|
|
159
|
+
if use_json
|
|
160
|
+
else rich_format(package, shown, color=color)
|
|
161
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Output formatters."""
|