dyp 0.2.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.
- dyp/__init__.py +21 -0
- dyp/__main__.py +4 -0
- dyp/_http.py +95 -0
- dyp/backend.py +88 -0
- dyp/cli.py +311 -0
- dyp/download.py +89 -0
- dyp/env.py +85 -0
- dyp/index.py +276 -0
- dyp/planner.py +115 -0
- dyp/resolver.py +243 -0
- dyp/ui.py +324 -0
- dyp/wheel.py +288 -0
- dyp-0.2.0.dist-info/METADATA +90 -0
- dyp-0.2.0.dist-info/RECORD +17 -0
- dyp-0.2.0.dist-info/WHEEL +5 -0
- dyp-0.2.0.dist-info/entry_points.txt +2 -0
- dyp-0.2.0.dist-info/top_level.txt +1 -0
dyp/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""dyp: a dnf5-style package manager for Python wheels. No pip involved.
|
|
2
|
+
|
|
3
|
+
Scriptable API::
|
|
4
|
+
|
|
5
|
+
import dyp
|
|
6
|
+
env = dyp.Env.load() # or Env.load("/path/to/venv/bin/python")
|
|
7
|
+
index = dyp.Index(env) # PyPI client (cached, parallel, PEP 658)
|
|
8
|
+
pkgs = dyp.resolve(["requests"], env=env, index=index)
|
|
9
|
+
dyp.Session(pkgs, on_event=print).run() # parallel, resumable, sha256-verified
|
|
10
|
+
for p in pkgs:
|
|
11
|
+
dyp.install_wheel(p.path, env, requested=p.requested)
|
|
12
|
+
"""
|
|
13
|
+
__version__ = "0.2.0"
|
|
14
|
+
|
|
15
|
+
from .backend import DypError, Package, cache_dir # noqa: F401
|
|
16
|
+
from .download import Event, Session # noqa: F401
|
|
17
|
+
from .env import Env # noqa: F401
|
|
18
|
+
from .index import Index, ProjectNotFound # noqa: F401
|
|
19
|
+
from .planner import check_updates, resolve # noqa: F401
|
|
20
|
+
from .ui import DownloadView, Ui # noqa: F401
|
|
21
|
+
from .wheel import Dist, install_wheel, scan, uninstall # noqa: F401
|
dyp/__main__.py
ADDED
dyp/_http.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Tiny HTTP client: keep-alive connection per thread, gzip, redirects, proxy fallback."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import gzip
|
|
5
|
+
import http.client
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from urllib.parse import urljoin, urlsplit
|
|
11
|
+
|
|
12
|
+
from .backend import UA, DypError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Http:
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self.local = threading.local()
|
|
18
|
+
self.proxies = urllib.request.getproxies()
|
|
19
|
+
|
|
20
|
+
def _proxy(self, scheme, netloc):
|
|
21
|
+
"""(host, port, auth headers) of the HTTPS proxy to tunnel through, or None."""
|
|
22
|
+
url = self.proxies.get(scheme)
|
|
23
|
+
if not url or scheme != "https" or urllib.request.proxy_bypass(netloc.split(":")[0]):
|
|
24
|
+
return None
|
|
25
|
+
p = urlsplit(url if "//" in url else "//" + url)
|
|
26
|
+
hdr = {}
|
|
27
|
+
if p.username:
|
|
28
|
+
import base64
|
|
29
|
+
tok = base64.b64encode(f"{urllib.parse.unquote(p.username)}:"
|
|
30
|
+
f"{urllib.parse.unquote(p.password or '')}".encode()).decode()
|
|
31
|
+
hdr["Proxy-Authorization"] = f"Basic {tok}"
|
|
32
|
+
return p.hostname, p.port or 8080, hdr
|
|
33
|
+
|
|
34
|
+
def _conn(self, scheme, netloc):
|
|
35
|
+
conns = self.local.__dict__.setdefault("c", {})
|
|
36
|
+
c = conns.get((scheme, netloc))
|
|
37
|
+
if c is None:
|
|
38
|
+
proxy = self._proxy(scheme, netloc)
|
|
39
|
+
if proxy: # keep-alive *through* the proxy (CONNECT)
|
|
40
|
+
c = http.client.HTTPSConnection(proxy[0], proxy[1], timeout=30)
|
|
41
|
+
host, _, port = netloc.partition(":")
|
|
42
|
+
c.set_tunnel(host, int(port) if port else 443, headers=proxy[2])
|
|
43
|
+
elif scheme == "https":
|
|
44
|
+
c = http.client.HTTPSConnection(netloc, timeout=30)
|
|
45
|
+
else:
|
|
46
|
+
c = http.client.HTTPConnection(netloc, timeout=30)
|
|
47
|
+
conns[(scheme, netloc)] = c
|
|
48
|
+
return c
|
|
49
|
+
|
|
50
|
+
def _drop(self, scheme, netloc):
|
|
51
|
+
c = self.local.__dict__.get("c", {}).pop((scheme, netloc), None)
|
|
52
|
+
if c:
|
|
53
|
+
c.close()
|
|
54
|
+
|
|
55
|
+
def get(self, url, headers=None, method="GET"):
|
|
56
|
+
"""-> (status, lower-cased headers, body bytes). Follows up to 5 redirects."""
|
|
57
|
+
hdr = {"User-Agent": UA, "Accept-Encoding": "gzip", **(headers or {})}
|
|
58
|
+
for _ in range(6):
|
|
59
|
+
if urlsplit(url).scheme == "http" and self.proxies.get("http"):
|
|
60
|
+
req = urllib.request.Request(url, headers=hdr, method=method)
|
|
61
|
+
try:
|
|
62
|
+
r = urllib.request.urlopen(req, timeout=30)
|
|
63
|
+
status, h, body = r.status, {k.lower(): v for k, v in r.headers.items()}, r.read()
|
|
64
|
+
except urllib.error.HTTPError as e:
|
|
65
|
+
status, h, body = e.code, {k.lower(): v for k, v in e.headers.items()}, e.read()
|
|
66
|
+
except OSError as e:
|
|
67
|
+
raise DypError(f"network error for {url}: {e}") from e
|
|
68
|
+
else:
|
|
69
|
+
u = urlsplit(url)
|
|
70
|
+
path = (u.path or "/") + (f"?{u.query}" if u.query else "")
|
|
71
|
+
for attempt in (0, 1):
|
|
72
|
+
c = self._conn(u.scheme, u.netloc)
|
|
73
|
+
try:
|
|
74
|
+
c.request(method, path, headers=hdr)
|
|
75
|
+
r = c.getresponse()
|
|
76
|
+
body = r.read()
|
|
77
|
+
break
|
|
78
|
+
except (OSError, http.client.HTTPException) as e:
|
|
79
|
+
self._drop(u.scheme, u.netloc)
|
|
80
|
+
if attempt:
|
|
81
|
+
raise DypError(f"network error for {url}: {e}") from e
|
|
82
|
+
status, h = r.status, {k.lower(): v for k, v in r.getheaders()}
|
|
83
|
+
if status in (301, 302, 303, 307, 308) and h.get("location"):
|
|
84
|
+
url = urljoin(url, h["location"])
|
|
85
|
+
continue
|
|
86
|
+
if h.get("content-encoding") == "gzip":
|
|
87
|
+
body = gzip.decompress(body)
|
|
88
|
+
return status, h, body
|
|
89
|
+
raise DypError(f"too many redirects for {url}")
|
|
90
|
+
|
|
91
|
+
def download(self, url, dest, chunk=1 << 16):
|
|
92
|
+
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
93
|
+
with urllib.request.urlopen(req, timeout=30) as r, open(dest, "wb") as f:
|
|
94
|
+
while b := r.read(chunk):
|
|
95
|
+
f.write(b)
|
dyp/backend.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Shared types and helpers. dyp no longer shells out to pip for anything."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import unquote
|
|
9
|
+
|
|
10
|
+
UA = "dyp/0.2 (+https://pypi.org/project/dyp/)"
|
|
11
|
+
PROTECTED = {"pip", "dyp", "packaging"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DypError(Exception):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cache_root() -> Path:
|
|
19
|
+
if os.name == "nt":
|
|
20
|
+
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
|
21
|
+
else:
|
|
22
|
+
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
23
|
+
return base / "dyp"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cache_dir(sub: str = "packages") -> Path:
|
|
27
|
+
d = cache_root() / sub
|
|
28
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
return d
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def default_python() -> str | None:
|
|
33
|
+
"""Target the *active* virtualenv even if dyp itself lives elsewhere (like pip's `python -m pip`
|
|
34
|
+
would, but also for a globally installed `dyp` command)."""
|
|
35
|
+
venv = os.environ.get("VIRTUAL_ENV")
|
|
36
|
+
if venv and os.path.realpath(sys.prefix) != os.path.realpath(venv):
|
|
37
|
+
cand = Path(venv) / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
|
38
|
+
if cand.exists():
|
|
39
|
+
return str(cand)
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Package:
|
|
45
|
+
name: str
|
|
46
|
+
version: str
|
|
47
|
+
url: str
|
|
48
|
+
sha256: str | None
|
|
49
|
+
requested: bool = False
|
|
50
|
+
size: int = 0
|
|
51
|
+
repo: str = "pypi"
|
|
52
|
+
old_version: str | None = None # installed version this package replaces
|
|
53
|
+
old_size: int = 0
|
|
54
|
+
old_path: str | None = None # its .dist-info directory
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def filename(self) -> str:
|
|
58
|
+
return unquote(self.url.split("#")[0].rsplit("/", 1)[-1])
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def is_wheel(self) -> bool:
|
|
62
|
+
return self.filename.endswith(".whl")
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def arch(self) -> str:
|
|
66
|
+
if not self.is_wheel:
|
|
67
|
+
return "src"
|
|
68
|
+
plat = self.filename[:-4].split("-")[-1]
|
|
69
|
+
if plat == "any":
|
|
70
|
+
return "noarch"
|
|
71
|
+
for key, arch in (("x86_64", "x86_64"), ("amd64", "x86_64"), ("aarch64", "aarch64"),
|
|
72
|
+
("arm64", "arm64"), ("universal2", "universal2"), ("win32", "i686"),
|
|
73
|
+
("i686", "i686"), ("ppc64le", "ppc64le"), ("s390x", "s390x")):
|
|
74
|
+
if key in plat:
|
|
75
|
+
return arch
|
|
76
|
+
return plat.split(".")[0]
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def nevra(self) -> str:
|
|
80
|
+
return f"{self.name}-{self.version}.{self.arch}"
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def path(self) -> Path:
|
|
84
|
+
return cache_dir() / self.filename
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def cached(self) -> bool:
|
|
88
|
+
return self.path.exists() and (not self.size or self.path.stat().st_size == self.size)
|
dyp/cli.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""dyp command line: a dnf5-flavoured package manager for Python wheels."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from packaging.utils import canonicalize_name
|
|
9
|
+
from packaging.version import Version
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from . import planner
|
|
13
|
+
from .backend import PROTECTED, DypError, cache_dir, cache_root, default_python
|
|
14
|
+
from .download import Session, sha256_of
|
|
15
|
+
from .env import Env
|
|
16
|
+
from .index import Index
|
|
17
|
+
from .ui import DownloadView, Ui, tsz, whole
|
|
18
|
+
from .wheel import install_wheel, scan, uninstall
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _label(index: Index) -> str:
|
|
22
|
+
return " PyPI - pypi.org" if index.host == "pypi.org" else f" {index.host}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load(a, ui):
|
|
26
|
+
env = Env.load(a.python or default_python(), a.user)
|
|
27
|
+
return env, Index(env, getattr(a, "index_url", None))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------- shared flow
|
|
31
|
+
def _transaction(a, ui: Ui, env: Env, pkgs) -> int:
|
|
32
|
+
"""Show the plan, ask, download, then install natively."""
|
|
33
|
+
new_req = [p for p in pkgs if p.old_version is None and p.requested]
|
|
34
|
+
new_dep = [p for p in pkgs if p.old_version is None and not p.requested]
|
|
35
|
+
ups = [p for p in pkgs if p.old_version and Version(p.version) > Version(p.old_version)]
|
|
36
|
+
downs = [p for p in pkgs if p.old_version and Version(p.version) < Version(p.old_version)]
|
|
37
|
+
same = [p for p in pkgs if p.old_version and Version(p.version) == Version(p.old_version)]
|
|
38
|
+
ups += same
|
|
39
|
+
|
|
40
|
+
def row(p):
|
|
41
|
+
return (p.name, p.arch, p.version, p.repo, tsz(p.size))
|
|
42
|
+
|
|
43
|
+
def row_rep(p):
|
|
44
|
+
return (row(p), [(p.name, p.arch, p.old_version, "@System", tsz(p.old_size))])
|
|
45
|
+
|
|
46
|
+
sections = []
|
|
47
|
+
for title, grp, fn in (("Installing:", new_req, row), ("Installing dependencies:", new_dep, row),
|
|
48
|
+
("Upgrading:", ups, row_rep), ("Downgrading:", downs, row_rep)):
|
|
49
|
+
if grp:
|
|
50
|
+
sections.append((title, [fn(p) for p in grp]))
|
|
51
|
+
ui.table(sections)
|
|
52
|
+
ui.summary([(l, len(g)) for l, g in (("Installing", new_req + new_dep), ("Upgrading", ups),
|
|
53
|
+
("Downgrading", downs)) if g])
|
|
54
|
+
to_get = [p for p in pkgs if not p.cached]
|
|
55
|
+
inbound, need = sum(p.size for p in pkgs), sum(p.size for p in to_get)
|
|
56
|
+
ui.print(f"\nTotal size of inbound packages is {whole(inbound)}. Need to download {whole(need)}.")
|
|
57
|
+
|
|
58
|
+
if a.dry_run:
|
|
59
|
+
ui.print("Dry run, nothing was changed.")
|
|
60
|
+
return 0
|
|
61
|
+
if not ui.confirm(a.yes):
|
|
62
|
+
ui.print("Operation aborted by the user.")
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
if to_get:
|
|
66
|
+
view = DownloadView(ui, to_get)
|
|
67
|
+
Session(to_get, parallel=a.parallel, on_event=view).run()
|
|
68
|
+
view.close()
|
|
69
|
+
if view.failed:
|
|
70
|
+
for name, e in view.failed:
|
|
71
|
+
ui.print(f"Failed to download {name}: {e}")
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
def verify():
|
|
75
|
+
for p in pkgs:
|
|
76
|
+
if p.sha256 and sha256_of(p.path) != p.sha256:
|
|
77
|
+
raise DypError(f"{p.filename}: checksum mismatch (try `dyp clean`)")
|
|
78
|
+
|
|
79
|
+
installed = scan(env)
|
|
80
|
+
|
|
81
|
+
def one(p):
|
|
82
|
+
old = installed.get(p.name)
|
|
83
|
+
return lambda report: install_wheel(p.path, env, requested=p.requested, replace=old, progress=report)
|
|
84
|
+
|
|
85
|
+
ui.print("Running transaction")
|
|
86
|
+
steps = [("Verify package files", verify, inbound or len(pkgs)),
|
|
87
|
+
("Prepare transaction", lambda: None, len(pkgs))]
|
|
88
|
+
for p in pkgs:
|
|
89
|
+
verb = "Installing" if p.old_version is None else \
|
|
90
|
+
"Upgrading" if Version(p.version) >= Version(p.old_version) else "Downgrading"
|
|
91
|
+
steps.append((f"{verb} {p.nevra}", one(p), p.size, True))
|
|
92
|
+
try:
|
|
93
|
+
ui.steps(steps)
|
|
94
|
+
except DypError as e:
|
|
95
|
+
ui.print(f"Transaction failed: {e}")
|
|
96
|
+
return 1
|
|
97
|
+
ui.print("Complete!")
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _plan(a, ui, env, index, **kw):
|
|
102
|
+
"""Run the 'loading repositories' phase; returns (pkgs, error)."""
|
|
103
|
+
ui.print("Updating and loading repositories:")
|
|
104
|
+
|
|
105
|
+
def work():
|
|
106
|
+
try:
|
|
107
|
+
pkgs = planner.resolve(env=env, index=index, pre=a.pre, **kw)
|
|
108
|
+
return (pkgs, None), index.bytes
|
|
109
|
+
except DypError as e:
|
|
110
|
+
return (None, e), index.bytes
|
|
111
|
+
|
|
112
|
+
pkgs, err = ui.run_row(_label(index), work)
|
|
113
|
+
ui.print("Repositories loaded.")
|
|
114
|
+
if err:
|
|
115
|
+
ui.print("Failed to resolve the transaction:")
|
|
116
|
+
ui.print(str(err))
|
|
117
|
+
return pkgs, err
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------- commands
|
|
121
|
+
def cmd_install(a, ui: Ui) -> int:
|
|
122
|
+
env, index = _load(a, ui)
|
|
123
|
+
pkgs, err = _plan(a, ui, env, index, specs=a.packages, requirements=a.requirement,
|
|
124
|
+
upgrade=a.upgrade)
|
|
125
|
+
if err:
|
|
126
|
+
return 1
|
|
127
|
+
if not pkgs:
|
|
128
|
+
inst = scan(env)
|
|
129
|
+
for spec in a.packages:
|
|
130
|
+
from .resolver import parse_req
|
|
131
|
+
d = inst.get(canonicalize_name(parse_req(spec).name))
|
|
132
|
+
ui.print(f'Package "{d.key}-{d.version}" is already installed.' if d else f'Package "{spec}" is already installed.')
|
|
133
|
+
ui.print("Nothing to do.")
|
|
134
|
+
return 0
|
|
135
|
+
return _transaction(a, ui, env, pkgs)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_upgrade(a, ui: Ui) -> int:
|
|
139
|
+
env, index = _load(a, ui)
|
|
140
|
+
if a.packages:
|
|
141
|
+
inst = scan(env)
|
|
142
|
+
missing = [p for p in a.packages if canonicalize_name(p) not in inst]
|
|
143
|
+
if missing:
|
|
144
|
+
ui.print("Failed to resolve the transaction:")
|
|
145
|
+
for m in missing:
|
|
146
|
+
ui.print(f"No match for argument: {m}")
|
|
147
|
+
return 1
|
|
148
|
+
kw = dict(specs=a.packages, upgrade=True)
|
|
149
|
+
else:
|
|
150
|
+
kw = dict(upgrade_all=True)
|
|
151
|
+
pkgs, err = _plan(a, ui, env, index, requirements=[], **kw)
|
|
152
|
+
if err:
|
|
153
|
+
return 1
|
|
154
|
+
if not pkgs:
|
|
155
|
+
ui.print("Nothing to do.")
|
|
156
|
+
return 0
|
|
157
|
+
return _transaction(a, ui, env, pkgs)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cmd_check_update(a, ui: Ui) -> int:
|
|
161
|
+
env, index = _load(a, ui)
|
|
162
|
+
ui.print("Updating and loading repositories:")
|
|
163
|
+
found = ui.run_row(_label(index), lambda: (planner.check_updates(env, index, pre=a.pre), index.bytes))
|
|
164
|
+
ui.print("Repositories loaded.")
|
|
165
|
+
if not found:
|
|
166
|
+
ui.print("Nothing to do.")
|
|
167
|
+
return 0
|
|
168
|
+
ui.print("")
|
|
169
|
+
w = max(len(d.name) for d, _ in found)
|
|
170
|
+
for d, v in found:
|
|
171
|
+
ui.print(f"{ui.paint('32', d.name)}{' ' * (w - len(d.name))} {str(v):<14} {index.host if index.host != 'pypi.org' else 'pypi'}")
|
|
172
|
+
return 100 # like dnf: 100 = updates available
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cmd_remove(a, ui: Ui) -> int:
|
|
176
|
+
env = Env.load(a.python or default_python(), a.user)
|
|
177
|
+
inst = scan(env)
|
|
178
|
+
ui.print("Updating and loading repositories:")
|
|
179
|
+
ui.run_row(" @System", lambda: (None, 0))
|
|
180
|
+
ui.print("Repositories loaded.")
|
|
181
|
+
dists, missing = [], []
|
|
182
|
+
for q in a.packages:
|
|
183
|
+
d = inst.get(canonicalize_name(q))
|
|
184
|
+
(dists if d else missing).append(d or q)
|
|
185
|
+
if missing:
|
|
186
|
+
ui.print("Failed to resolve the transaction:")
|
|
187
|
+
for m in missing:
|
|
188
|
+
ui.print(f"No match for argument: {m}")
|
|
189
|
+
return 1
|
|
190
|
+
prot = [d.name for d in dists if d.key in PROTECTED]
|
|
191
|
+
if prot:
|
|
192
|
+
ui.print("Failed to resolve the transaction:")
|
|
193
|
+
ui.print(f"The operation would result in removing protected packages: {', '.join(prot)}")
|
|
194
|
+
return 1
|
|
195
|
+
ui.table([("Removing:", [(d.name, "noarch", d.version, "@System", tsz(d.size)) for d in dists])],
|
|
196
|
+
green="31")
|
|
197
|
+
freed = sum(d.size for d in dists)
|
|
198
|
+
ui.summary([("Removing", len(dists))])
|
|
199
|
+
ui.print(f"\nAfter this operation, {whole(freed)} will be freed (install 0 B, remove {whole(freed)}).")
|
|
200
|
+
if a.dry_run:
|
|
201
|
+
ui.print("Dry run, nothing was removed.")
|
|
202
|
+
return 0
|
|
203
|
+
if not ui.confirm(a.yes):
|
|
204
|
+
ui.print("Operation aborted by the user.")
|
|
205
|
+
return 1
|
|
206
|
+
|
|
207
|
+
def rm(d):
|
|
208
|
+
return lambda report: uninstall(d, env, lambda i, n: report(int(d.size * i / max(n, 1)), d.size))
|
|
209
|
+
|
|
210
|
+
ui.print("Running transaction")
|
|
211
|
+
steps = [("Prepare transaction", lambda: None, len(dists))]
|
|
212
|
+
steps += [(f"Removing {d.key}-{d.version}", rm(d), d.size, True) for d in dists]
|
|
213
|
+
try:
|
|
214
|
+
ui.steps(steps)
|
|
215
|
+
except DypError as e:
|
|
216
|
+
ui.print(f"Transaction failed: {e}")
|
|
217
|
+
return 1
|
|
218
|
+
ui.print("Complete!")
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def cmd_list(a, ui: Ui) -> int:
|
|
223
|
+
env = Env.load(a.python or default_python(), a.user)
|
|
224
|
+
pkgs = sorted(scan(env).values(), key=lambda d: d.key)
|
|
225
|
+
if a.filter:
|
|
226
|
+
pkgs = [d for d in pkgs if any(f.lower() in d.key for f in a.filter)]
|
|
227
|
+
ui.print("Installed packages")
|
|
228
|
+
names = [d.name for d in pkgs]
|
|
229
|
+
w = max(map(len, names), default=0)
|
|
230
|
+
for d, n in zip(pkgs, names):
|
|
231
|
+
ui.print(f"{ui.paint('32', n)}{' ' * (w - len(n))} {d.version}")
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def cmd_clean(a, ui: Ui) -> int:
|
|
236
|
+
root = cache_root()
|
|
237
|
+
files = [f for f in root.rglob("*") if f.is_file()] if root.exists() else []
|
|
238
|
+
freed = sum(f.stat().st_size for f in files)
|
|
239
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
240
|
+
ui.print(f"{len(files)} files removed, {whole(freed)} freed.")
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ------------------------------------------------------------------- parser
|
|
245
|
+
def build_parser():
|
|
246
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
247
|
+
common.add_argument("-y", "--yes", action="store_true", help="answer yes to the prompt")
|
|
248
|
+
common.add_argument("--python", help="target interpreter / venv python (default: active venv or this Python)")
|
|
249
|
+
common.add_argument("--user", action="store_true", help="user site instead of the environment")
|
|
250
|
+
common.add_argument("--dry-run", action="store_true", help="show the transaction, change nothing")
|
|
251
|
+
net = argparse.ArgumentParser(add_help=False)
|
|
252
|
+
net.add_argument("--pre", action="store_true", help="allow pre-releases")
|
|
253
|
+
net.add_argument("--index-url", help="package index (PEP 503/691), default PyPI")
|
|
254
|
+
net.add_argument("--parallel", type=int, default=3, help="simultaneous downloads (default 3)")
|
|
255
|
+
|
|
256
|
+
p = argparse.ArgumentParser(prog="dyp", description="dnf5-style package manager for Python wheels")
|
|
257
|
+
p.add_argument("--version", action="version", version=f"dyp {__version__}")
|
|
258
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
259
|
+
|
|
260
|
+
i = sub.add_parser("install", aliases=["in", "add"], parents=[common, net], help="install packages")
|
|
261
|
+
i.add_argument("packages", nargs="*", help="names or requirement specifiers")
|
|
262
|
+
i.add_argument("-r", "--requirement", action="append", default=[], help="requirements file")
|
|
263
|
+
i.add_argument("-U", "--upgrade", action="store_true", help="upgrade the named packages")
|
|
264
|
+
i.set_defaults(fn=cmd_install)
|
|
265
|
+
|
|
266
|
+
u = sub.add_parser("upgrade", aliases=["up", "update"], parents=[common, net],
|
|
267
|
+
help="upgrade packages (all of them when none are named)")
|
|
268
|
+
u.add_argument("packages", nargs="*")
|
|
269
|
+
u.set_defaults(fn=cmd_upgrade)
|
|
270
|
+
|
|
271
|
+
c = sub.add_parser("check-update", aliases=["cu"], parents=[common, net], help="list available updates")
|
|
272
|
+
c.set_defaults(fn=cmd_check_update)
|
|
273
|
+
|
|
274
|
+
r = sub.add_parser("remove", aliases=["rm", "erase"], parents=[common], help="remove packages")
|
|
275
|
+
r.add_argument("packages", nargs="+")
|
|
276
|
+
r.set_defaults(fn=cmd_remove)
|
|
277
|
+
|
|
278
|
+
l = sub.add_parser("list", aliases=["ls"], parents=[common], help="list installed packages")
|
|
279
|
+
l.add_argument("filter", nargs="*")
|
|
280
|
+
l.set_defaults(fn=cmd_list)
|
|
281
|
+
|
|
282
|
+
k = sub.add_parser("clean", help="delete the download/index cache")
|
|
283
|
+
k.set_defaults(fn=cmd_clean)
|
|
284
|
+
return p
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def main(argv=None) -> int:
|
|
288
|
+
for stream in (sys.stdout, sys.stderr): # cp1252 consoles must never crash us
|
|
289
|
+
try:
|
|
290
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
291
|
+
except (AttributeError, ValueError):
|
|
292
|
+
pass
|
|
293
|
+
a = build_parser().parse_args(argv)
|
|
294
|
+
if a.cmd in ("install", "in", "add") and not (a.packages or a.requirement):
|
|
295
|
+
print("dyp install: give at least one package or -r requirements.txt", file=sys.stderr)
|
|
296
|
+
return 2
|
|
297
|
+
ui = Ui()
|
|
298
|
+
try:
|
|
299
|
+
return a.fn(a, ui)
|
|
300
|
+
except DypError as e:
|
|
301
|
+
ui.print(f"Error: {e}")
|
|
302
|
+
return 1
|
|
303
|
+
except KeyboardInterrupt:
|
|
304
|
+
ui.print("\nOperation aborted by the user.")
|
|
305
|
+
return 130
|
|
306
|
+
finally:
|
|
307
|
+
ui.show()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__":
|
|
311
|
+
sys.exit(main())
|
dyp/download.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Parallel, resumable, checksum-verified downloads that report through events."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from .backend import UA, DypError, Package
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Event:
|
|
18
|
+
kind: str # "start" | "progress" | "done" | "error"
|
|
19
|
+
name: str # Package.filename
|
|
20
|
+
done: int = 0
|
|
21
|
+
total: int = 0
|
|
22
|
+
rate: float = 0.0
|
|
23
|
+
error: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def sha256_of(path, chunk=1 << 20) -> str:
|
|
27
|
+
h = hashlib.sha256()
|
|
28
|
+
with open(path, "rb") as f:
|
|
29
|
+
while b := f.read(chunk):
|
|
30
|
+
h.update(b)
|
|
31
|
+
return h.hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Session:
|
|
35
|
+
def __init__(self, packages, *, parallel=3, on_event: Callable[[Event], None] | None = None):
|
|
36
|
+
self.packages = list(packages)
|
|
37
|
+
self.parallel = parallel
|
|
38
|
+
self.emit = on_event or (lambda e: None)
|
|
39
|
+
self.failed: list[Package] = []
|
|
40
|
+
|
|
41
|
+
def _fetch(self, p: Package, retry=True):
|
|
42
|
+
path = p.path
|
|
43
|
+
part = path.with_name(path.name + ".part")
|
|
44
|
+
have = part.stat().st_size if part.exists() else 0
|
|
45
|
+
headers = {"User-Agent": UA}
|
|
46
|
+
if have:
|
|
47
|
+
headers["Range"] = f"bytes={have}-"
|
|
48
|
+
try:
|
|
49
|
+
try:
|
|
50
|
+
r = urllib.request.urlopen(urllib.request.Request(p.url, headers=headers), timeout=30)
|
|
51
|
+
except urllib.error.HTTPError as e:
|
|
52
|
+
if e.code == 416 and retry: # stale .part
|
|
53
|
+
part.unlink(missing_ok=True)
|
|
54
|
+
return self._fetch(p, retry=False)
|
|
55
|
+
raise
|
|
56
|
+
with r:
|
|
57
|
+
resumed = r.status == 206
|
|
58
|
+
if not resumed:
|
|
59
|
+
have = 0
|
|
60
|
+
total = p.size or have + int(r.headers.get("Content-Length", 0))
|
|
61
|
+
self.emit(Event("start", p.filename, have, total))
|
|
62
|
+
got, t0, last = have, time.monotonic(), 0.0
|
|
63
|
+
with open(part, "ab" if resumed else "wb") as f:
|
|
64
|
+
while chunk := r.read(64 * 1024):
|
|
65
|
+
f.write(chunk)
|
|
66
|
+
got += len(chunk)
|
|
67
|
+
now = time.monotonic()
|
|
68
|
+
if now - last >= 0.05:
|
|
69
|
+
last = now
|
|
70
|
+
self.emit(Event("progress", p.filename, got, total,
|
|
71
|
+
(got - have) / max(now - t0, 1e-6)))
|
|
72
|
+
if p.sha256 and sha256_of(part) != p.sha256:
|
|
73
|
+
part.unlink(missing_ok=True)
|
|
74
|
+
raise DypError("checksum mismatch")
|
|
75
|
+
os.replace(part, path)
|
|
76
|
+
self.emit(Event("done", p.filename, got, total or got))
|
|
77
|
+
except Exception as e:
|
|
78
|
+
self.failed.append(p)
|
|
79
|
+
self.emit(Event("error", p.filename, error=str(e)))
|
|
80
|
+
|
|
81
|
+
def run(self) -> list[Package]:
|
|
82
|
+
pool = ThreadPoolExecutor(self.parallel)
|
|
83
|
+
try:
|
|
84
|
+
list(pool.map(self._fetch, self.packages))
|
|
85
|
+
except KeyboardInterrupt:
|
|
86
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
87
|
+
raise
|
|
88
|
+
pool.shutdown()
|
|
89
|
+
return self.failed
|