Coverage for src/edwh/meta.py: 21%
115 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-13 17:03 +0200
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-13 17:03 +0200
1"""
2This files contains everything to do with meta-tasks such as self-updating
3"""
5import concurrent.futures
6import shlex
7import sys
8import typing as t
10import yayarl as yarl
11from ewok import Context, task
12from invoke.runners import Result
13from packaging.version import InvalidVersion, Version
14from packaging.version import parse as parse_package_version
15from termcolor import cprint
17from .helpers import AnyDict
19PYPI_URL_BASE = yarl.URL("https://pypi.python.org/pypi/")
22def _python() -> str:
23 """
24 used to detect current Python environment, even in pipx
25 """
26 return sys.executable
29def _pip(python: str = _python()) -> str:
30 """
31 used to detect current pip environment, even in pipx
32 """
33 # uv.find_uv_bin() does not really work here, because then the right venv may not be used!
34 return f"{python} -m uv pip"
37def pip_install(c: Context, *specifiers: str, **kw: t.Any) -> t.Optional[Result]:
38 """
39 Install into the environment edwh itself runs in.
40 """
41 return c.run(f"{_pip()} install {shlex.join(specifiers)}", **kw)
44def pip_uninstall(c: Context, *specifiers: str, **kw: t.Any) -> t.Optional[Result]:
45 """
46 Remove from the environment edwh itself runs in.
47 """
48 return c.run(f"{_pip()} uninstall {shlex.join(specifiers)}", **kw)
51def _get_pypi_info(package: str) -> AnyDict:
52 """
53 Load metadata from pypi for a package
54 """
55 url = PYPI_URL_BASE / package / "json"
56 resp = url.get(timeout=10)
57 return t.cast(AnyDict, resp.json())
60def _get_latest_version_from_pypi(package: str) -> Version:
61 """
62 Get the latest Version for a package from pypi
63 """
64 data = _get_pypi_info(package)
65 if not data or not data.get("info"):
66 raise ModuleNotFoundError(f"Plugin {package} does not seem to exist.")
68 return parse_package_version(data["info"]["version"])
71def _get_available_plugins_from_pypi(package: str, extra: str | None = None) -> list[str]:
72 """
73 List all plugins available for package, optionally for a specific 'extra'.
75 e.g.
76 [mypackage]
77 dev = ['package1', 'package2']
78 another_extra = ['package1', 'package3']
80 > _get_available_plugins_from_pypi('mypackage')
81 ['package1', 'package2', 'package3']
83 > _get_available_plugins_from_pypi('mypackage, 'dev')
84 ['package1', 'package2']
86 """
87 data = _get_pypi_info(package)
88 extras = data["info"]["requires_dist"]
90 if extra:
91 extras = [_.split(";")[0] for _ in extras if _.endswith(f'; extra == "{extra}"')]
93 return list(extras)
96def _gather_package_metadata_threaded(packages: t.Iterable[str]) -> dict[str, AnyDict | None]:
97 """
98 For any package in packages, gather its metadata from pypi
99 """
100 all_data: dict[str, AnyDict | None] = {}
101 with concurrent.futures.ThreadPoolExecutor() as executor:
102 pkg_names = [_.split("==")[0] for _ in packages]
103 for result, package in zip(executor.map(_get_pypi_info, pkg_names), packages):
104 all_data[package] = result
106 return all_data
109def _determine_newest_version(releases: t.Collection[str]) -> str:
110 sorted_releases = sorted(releases, key=Version)
111 return sorted_releases[-1]
114def _determine_outdated_threaded(installed_plugins: t.Collection[str], prerelease: bool = False) -> dict[str, Version]:
115 """
116 Like _determine_outdated but parallelized with Threading
118 installed_plugins is a list (or other iterable) of ["name==version", "name @ location"] type strings
119 """
120 plugins_metadata = _gather_package_metadata_threaded([_ for _ in installed_plugins if " @ " not in _])
122 outdated = {}
123 for plugin, metadata in plugins_metadata.items():
124 if not metadata:
125 continue
127 try:
128 name, current_version_str = plugin.split("==")
129 current_version = parse_package_version(current_version_str)
131 latest_stable = metadata["info"]["version"]
132 latest_prerelease = _determine_newest_version(metadata["releases"].keys()) if prerelease else None
133 if not (latest_stable or latest_prerelease):
134 continue
136 latest_version = parse_package_version(t.cast(str, latest_prerelease if prerelease else latest_stable))
137 except Exception:
138 # no current or latest version found? skip
139 continue
141 if current_version and latest_version and latest_version > current_version:
142 outdated[name] = latest_version
144 return outdated
147def _parse_versions(installed: list[str]) -> dict[str, Version | None]:
148 """
149 Given a list of installed packages from pip freeze (_plugins), gather the parsed versions
150 """
151 versions = {}
152 for pkg in installed:
153 parts = pkg.split(" @ ")[0].split("==")
154 name = parts[0]
155 try:
156 version = parse_package_version(parts[1])
157 except (InvalidVersion, IndexError):
158 version = None
159 # finally:
160 versions[name] = version
162 return versions
165@task()
166def plugins(c: Context, verbose: bool = False, changelog: bool = False) -> None:
167 """
168 alias for plugin.list or plugin.changelog --new
169 """
170 from .local_tasks import plugin
172 if changelog:
173 return plugin.changelog(c, [], new=True)
174 else:
175 return plugin.list_plugins(c, verbose=verbose)
178def _self_update(c: Context, prerelease: bool = False, no_cache: bool = False) -> None:
179 """
180 Wrapper for self-update that can handle type hint Context
181 """
182 from .local_tasks.plugin import list_installed_plugins
184 pip_command = _pip()
186 edwh_packages = list_installed_plugins(c, pip_command)
187 if not edwh_packages or (len(edwh_packages) == 1 and edwh_packages[0] == ""):
188 cprint("No 'edwh' packages found. That can't be right", color="yellow")
190 old_plugins = _determine_outdated_threaded(edwh_packages, prerelease=prerelease)
192 if not old_plugins and not no_cache:
193 cprint("Nothing to update", "blue")
194 exit()
196 if no_cache and not old_plugins:
197 # Fresh mode should still run for currently-up-to-date packages so transitive dependencies can be refreshed.
198 target_packages: dict[str, Version] = {
199 package.split("==")[0]: parse_package_version(package.split("==")[1])
200 for package in edwh_packages
201 if "==" in package and " @ " not in package
202 }
203 else:
204 target_packages = old_plugins
206 cprint(f"Will try to update {len(target_packages)} packages.", "blue")
208 success = []
209 failure = []
210 for plugin, version in target_packages.items():
211 command = f"{pip_command} install {plugin}=={version}"
212 if no_cache:
213 # In "fresh" mode, also refresh transitive dependencies to newest compatible versions.
214 command = f"{command} --no-cache --upgrade --resolution highest"
216 result = c.run(command, warn=True)
218 if result and result.return_code == 0:
219 success.append(plugin)
220 else:
221 failure.append(plugin)
223 if success:
224 cprint(f"{len(success)}/{len(target_packages)} updated successfully.", "green")
226 if failure:
227 cprint(f"{', '.join(failure)} failed updating", "red")
230@task(
231 flags={
232 "prerelease": ["prerelease", "pre", "pre-release", "p"],
233 "no_cache": ["no-cache", "f", "fresh"],
234 }
235)
236def self_update(c: Context, prerelease: bool = False, no_cache: bool = False) -> None:
237 """Updates `edwh` and all installed plugins.
239 Args:
240 c (Context): invoke ctx
241 prerelease (bool, optional): allow non-stable releases? Defaults to False.
242 no_cache (bool, optional): download fresh? Defaults to False.
243 """
244 return _self_update(c, prerelease, no_cache)
247def is_installed(ctx: Context, command: str) -> bool:
248 """
249 Check if a bash command is known.
250 """
251 result = ctx.run(f"which {command}", hide="both", warn=True)
252 return bool(result and result.ok)