FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

GitHub Viewer

"""Target-aware resolution of ``[requirements].packages``. A PythonNative app runs on an embedded CPython, so its third-party packages have to be resolved for the *device*, not for the machine running ``pn``. Pure-Python wheels (``py3-none-any``) work everywhere, but anything with a compiled extension needs a wheel built for the exact platform triple the app will run on: - iOS (PEP 730): ``ios_13_0_arm64_iphoneos`` for devices and ``ios_13_0_arm64_iphonesimulator`` / ``ios_13_0_x86_64_iphonesimulator`` for the Simulator, published on PyPI and on BeeWare's index. - Android (PEP 738): ``android_24_arm64_v8a`` / ``android_24_x86_64``, published on PyPI and on Chaquopy's index. This module models those targets ([`Target`][pythonnative.project.deps.Target]), asks pip to resolve the project's requirements against each of them without installing anything ([`resolve`][pythonnative.project.deps.resolve]), and installs the resolved set into a per-target directory for the iOS build ([`install`][pythonnative.project.deps.install]). ``pip`` does the actual work through its ``--platform`` / ``--python-version`` / ``--only-binary=:all:`` cross-resolution mode, so the host interpreter's version and platform never leak into the app. On Android the authoritative install is done by Chaquopy inside the Gradle build (with the same ``--only-binary`` rule); resolving here is a fast preview that catches missing wheels before a multi-minute build. All pip invocations go through the builder's [`CommandRunner`][pythonnative.project.builder.CommandRunner] so the logic is unit testable with a recording fake. """ from __future__ import annotations import json import re import sys from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Sequence, Tuple from urllib.parse import urlparse from .config import AppConfig if TYPE_CHECKING: from .builder import CommandRunner BEEWARE_INDEX_URL = "https://pypi.anaconda.org/beeware/simple" """BeeWare's wheel index: iOS binary wheels for the CPython versions Python-Apple-support ships.""" CHAQUOPY_INDEX_URL = "https://chaquo.com/pypi-13.1/" """Chaquopy's wheel index: Android binary wheels built for its embedded CPython.""" MIN_IOS_WHEEL_VERSION = (12, 0) """Oldest iOS version a PEP 730 wheel can be tagged with (CPython's own floor).""" MIN_ANDROID_WHEEL_API = 16 """Oldest Android API level a PEP 738 wheel can be tagged with.""" IOS_SDKS = ("iphoneos", "iphonesimulator") """The two iOS SDKs a build can target; each gets its own ``app_packages`` slice.""" ANDROID_ABI_TAGS: Dict[str, str] = {"arm64-v8a": "arm64_v8a", "x86_64": "x86_64"} """Gradle ABI name -> wheel platform tag component.""" # ====================================================================== # Targets # ====================================================================== @dataclass(frozen=True) class Target: """One device platform to resolve packages for. Attributes: platform: ``"ios"`` or ``"android"``. python_version: CPython ``major.minor`` the app embeds. arch: CPU architecture (``"arm64"`` or ``"x86_64"``). sdk: iOS SDK (``"iphoneos"`` / ``"iphonesimulator"``); empty on Android. os_version: Minimum OS version the app declares: the iOS deployment target (``"13.0"``) or the Android API level as a string (``"24"``). """ platform: str python_version: str arch: str sdk: str = "" os_version: str = "13.0" @property def label(self) -> str: """Short human-readable name, e.g. ``"iOS Simulator (arm64)"``.""" if self.platform == "ios": kind = "iOS device" if self.sdk == "iphoneos" else "iOS Simulator" return f"{kind} ({self.arch}, iOS {self.os_version}+)" return f"Android {self.abi} (API {self.os_version}+)" @property def abi(self) -> str: """The Gradle ABI name for an Android target (``"arm64-v8a"``).""" for abi, tag in ANDROID_ABI_TAGS.items(): if tag == self.arch or abi == self.arch: return abi return self.arch @property def slice_name(self) -> str: """The staged packages folder name for an iOS target (``app_packages.iphoneos``).""" return f"app_packages.{self.sdk}" @property def platform_tags(self) -> List[str]: """Every wheel platform tag this target accepts, newest first. A wheel is tagged with the *minimum* OS version it supports, so a target on iOS 13.0 also accepts wheels tagged for 12.x. Mirrors ``packaging.tags.ios_platforms`` / ``android_platforms`` so the list stays in step with what pip itself would compute on the device. """ if self.platform == "ios": return list(_ios_platform_tags(self.os_version, f"{self.arch}_{self.sdk}")) return list(_android_platform_tags(int(self.os_version), ANDROID_ABI_TAGS.get(self.arch, self.arch))) @property def index_urls(self) -> List[str]: """Extra indexes searched after PyPI: the platform's binary wheel repository.""" return [BEEWARE_INDEX_URL] if self.platform == "ios" else [CHAQUOPY_INDEX_URL] @property def abi_tag(self) -> str: """The CPython ABI tag (``cp313``).""" return "cp" + self.python_version.replace(".", "") def _ios_platform_tags(version: str, multiarch: str) -> Iterable[str]: major, minor = _parse_version(version) for maj in range(major, MIN_IOS_WHEEL_VERSION[0] - 1, -1): top = minor if maj == major else 9 bottom = MIN_IOS_WHEEL_VERSION[1] if maj == MIN_IOS_WHEEL_VERSION[0] else 0 for mnr in range(top, bottom - 1, -1): yield f"ios_{maj}_{mnr}_{multiarch}" def _android_platform_tags(api_level: int, abi: str) -> Iterable[str]: for level in range(api_level, MIN_ANDROID_WHEEL_API - 1, -1): yield f"android_{level}_{abi}" def _parse_version(version: str) -> Tuple[int, int]: parts = version.split(".") major = int(parts[0]) minor = int(parts[1]) if len(parts) > 1 else 0 return major, minor def host_simulator_arch() -> str: """The iOS Simulator architecture matching this Mac (``arm64`` or ``x86_64``).""" import platform as platform_module return "arm64" if platform_module.machine() == "arm64" else "x86_64" def ios_targets( config: AppConfig, *, sdks: Sequence[str] = IOS_SDKS, simulator_arch: Optional[str] = None ) -> List[Target]: """Build the iOS targets for ``config``. Args: config: The validated app configuration. sdks: Which SDKs to include (default both). simulator_arch: Architecture for the Simulator target; defaults to the host Mac's, which is also what the builder pins ``ARCHS`` to. Returns: One [`Target`][pythonnative.project.deps.Target] per SDK. """ targets: List[Target] = [] for sdk in sdks: if sdk not in IOS_SDKS: raise ValueError(f"Unknown iOS SDK {sdk!r} (expected one of {IOS_SDKS}).") arch = "arm64" if sdk == "iphoneos" else (simulator_arch or host_simulator_arch()) targets.append( Target( platform="ios", python_version=config.python_version, arch=arch, sdk=sdk, os_version=config.ios.deployment_target, ) ) return targets def android_targets(config: AppConfig) -> List[Target]: """Build one Android target per ``[android].abi_filters`` entry.""" return [ Target( platform="android", python_version=config.python_version, arch=ANDROID_ABI_TAGS.get(abi, abi), os_version=str(config.android.min_sdk), ) for abi in config.android.abi_filters ] def targets_for( config: AppConfig, platform: Optional[str] = None, *, all_simulator_archs: bool = False ) -> List[Target]: """Targets for a platform, optionally including both Mac architectures for portable locks.""" targets: List[Target] = [] if platform in (None, "ios"): if all_simulator_archs: targets.extend(ios_targets(config, simulator_arch="arm64")) targets.extend(ios_targets(config, sdks=("iphonesimulator",), simulator_arch="x86_64")) else: targets.extend(ios_targets(config)) if platform in (None, "android"): targets.extend(android_targets(config)) return targets # ====================================================================== # pip invocations # ====================================================================== def pip_base_args(config: AppConfig, target: Target, *, python: Optional[str] = None) -> List[str]: """The pip arguments that select ``target`` for cross-platform resolution. Args: config: The app configuration (for ``extra_index_urls``). target: The device target. python: Interpreter to run pip with; defaults to the current one. Returns: ``[python, "-m", "pip", "install", ]`` with no requirements appended. """ args = [ python or sys.executable, "-m", "pip", "install", "--disable-pip-version-check", # Cross-resolution: pip refuses to build from source for a foreign # platform, which is also exactly what an app store build needs. "--only-binary=:all:", "--implementation", "cp", "--python-version", target.python_version, "--abi", target.abi_tag, ] for tag in target.platform_tags: args.extend(["--platform", tag]) for url in [*target.index_urls, *config.extra_index_urls]: args.extend(["--extra-index-url", url]) return args def report_args(config: AppConfig, target: Target, *, python: Optional[str] = None) -> List[str]: """Pip arguments that resolve ``config.requirements`` for ``target`` without installing. The JSON report (``--report -``) lists every wheel pip *would* install, which is what [`resolve`][pythonnative.project.deps.resolve] parses. """ return [ *pip_base_args(config, target, python=python), "--dry-run", "--ignore-installed", "--quiet", "--report", "-", *config.requirements, ] def install_args(config: AppConfig, target: Target, dest: Path, *, python: Optional[str] = None) -> List[str]: """Pip arguments that install ``config.requirements`` for ``target`` into ``dest``.""" return [ *pip_base_args(config, target, python=python), "--target", str(dest), "--upgrade", *config.requirements, ] # ====================================================================== # Results # ====================================================================== @dataclass class ResolvedPackage: """One wheel pip selected for a target. Attributes: name: Distribution name as reported by pip. version: Selected version. filename: The wheel filename (its tags say what it is built for). url: Where the wheel comes from. """ name: str version: str filename: str url: str = "" sha256: str = "" latest: str = "" """The version an unconstrained desktop resolution picks, when it is newer than ``version``. Set by [`mark_downgrades`][pythonnative.project.deps.mark_downgrades]. A non-empty value means pip walked back to an older release because the newer ones have no wheel for this target. """ @property def downgraded(self) -> bool: """Whether this target got an older release than the desktop would.""" return bool(self.latest) @property def is_pure(self) -> bool: """Whether this is a platform-independent (``none-any``) wheel.""" return self.filename.endswith("-none-any.whl") @property def platform_tag(self) -> str: """The wheel's platform tag (``"any"`` for pure-Python wheels).""" stem = self.filename[:-4] if self.filename.endswith(".whl") else self.filename return stem.rsplit("-", 1)[-1] @property def index_host(self) -> str: """Host name of the index the wheel is served from (``"files.pythonhosted.org"`` for PyPI).""" return urlparse(self.url).netloc if self.url else "" @property def source_label(self) -> str: """Friendly source name: ``PyPI``, ``BeeWare``, ``Chaquopy``, or the index host.""" host = self.index_host if host.endswith("pythonhosted.org") or host.endswith("pypi.org"): return "PyPI" if host == "pypi.anaconda.org" and "/beeware/" in self.url: return "BeeWare" if host == "chaquo.com": return "Chaquopy" return host or "local" @dataclass class Resolution: """The outcome of resolving the requirements for one target. Attributes: target: The target that was resolved. packages: Wheels pip selected (empty when ``error`` is set or there are no requirements). error: pip's failure explanation when resolution failed, else ``None``. missing: Requirement names pip could not satisfy, parsed from ``error`` (best effort). """ target: Target packages: List[ResolvedPackage] = field(default_factory=list) error: Optional[str] = None missing: List[str] = field(default_factory=list) @property def ok(self) -> bool: """Whether every requirement resolved to a wheel.""" return self.error is None @property def binary_packages(self) -> List[ResolvedPackage]: """The platform-specific wheels in ``packages``.""" return [pkg for pkg in self.packages if not pkg.is_pure] @property def downgraded_packages(self) -> List[ResolvedPackage]: """Packages that resolved to an older release than the desktop would get.""" return [pkg for pkg in self.packages if pkg.downgraded] def to_dict(self) -> Dict[str, Any]: """Serialize for ``pn deps --json`` and the compatibility matrix job.""" return { "platform": self.target.platform, "label": self.target.label, "python_version": self.target.python_version, "arch": self.target.arch, "sdk": self.target.sdk, "ok": self.ok, "error": self.error, "missing": list(self.missing), "packages": [ { "name": pkg.name, "version": pkg.version, "filename": pkg.filename, "pure": pkg.is_pure, "platform_tag": pkg.platform_tag, "source": pkg.source_label, "url": pkg.url, "latest": pkg.latest or None, } for pkg in self.packages ], } class DependencyError(Exception): """Raised when requirements can't be resolved or installed for a target. The message is user-facing: it names the target, the unsatisfiable requirement(s), and what to do about it. """ # ====================================================================== # Resolving # ====================================================================== _NO_MATCH_RE = re.compile(r"No matching distribution found for (?P[^\s]+)") _NO_VERSION_RE = re.compile(r"Could not find a version that satisfies the requirement (?P[^\s]+)") def parse_report(report_json: str, target: Target) -> Resolution: """Turn a pip ``--report`` JSON document into a [`Resolution`][pythonnative.project.deps.Resolution].""" data = json.loads(report_json or "{}") packages: List[ResolvedPackage] = [] for item in data.get("install", []): metadata = item.get("metadata", {}) download = item.get("download_info", {}) or {} url = str(download.get("url", "")) filename = url.rsplit("/", 1)[-1] if url else "" packages.append( ResolvedPackage( name=str(metadata.get("name", "?")), version=str(metadata.get("version", "?")), filename=filename, url=url, sha256=str(download.get("archive_info", {}).get("hashes", {}).get("sha256", "")), ) ) packages.sort(key=lambda pkg: pkg.name.lower()) return Resolution(target=target, packages=packages) def parse_failure(stderr: str) -> Tuple[str, List[str]]: """Extract a one-paragraph explanation and the unsatisfied requirement names from pip's stderr.""" text = (stderr or "").strip() missing: List[str] = [] for match in _NO_MATCH_RE.finditer(text): req = match.group("req").strip(".,") if req not in missing: missing.append(req) if not missing: for match in _NO_VERSION_RE.finditer(text): req = match.group("req").strip(".,") if req not in missing: missing.append(req) # pip prints the useful sentence(s) as ERROR: lines; everything else is noise. lines = [line for line in text.splitlines() if line.startswith("ERROR:")] summary = "\n".join(lines) if lines else (text.splitlines()[-1] if text else "pip failed without output") return summary, missing def resolve(config: AppConfig, target: Target, *, runner: CommandRunner, python: Optional[str] = None) -> Resolution: """Resolve ``config.requirements`` for ``target`` without installing. Args: config: The validated app configuration. target: The device target. runner: A [`CommandRunner`][pythonnative.project.builder.CommandRunner]. python: Interpreter to run pip with (defaults to the current one). Returns: A [`Resolution`][pythonnative.project.deps.Resolution]; check ``.ok`` rather than expecting an exception, so callers can report every target at once. """ if not config.requirements: return Resolution(target=target) result = runner.run(report_args(config, target, python=python), capture=True) if not result.ok: summary, missing = parse_failure(result.stderr) return Resolution(target=target, error=summary, missing=missing) try: return parse_report(result.stdout, target) except (ValueError, TypeError) as exc: return Resolution(target=target, error=f"Could not parse pip's report: {exc}") def resolve_all( config: AppConfig, targets: Sequence[Target], *, runner: CommandRunner, python: Optional[str] = None, detect_downgrades: bool = True, ) -> List[Resolution]: """[`resolve`][pythonnative.project.deps.resolve] every target in order. Args: config: The validated app configuration. targets: Targets to resolve for. runner: A [`CommandRunner`][pythonnative.project.builder.CommandRunner]. python: Interpreter to run pip with (defaults to the current one). detect_downgrades: Also resolve once without any platform constraint and mark packages that got an older release on a target than that unconstrained resolution picked (see [`mark_downgrades`][pythonnative.project.deps.mark_downgrades]). One extra pip call; skipped when there are no requirements. """ resolutions = [resolve(config, target, runner=runner, python=python) for target in targets] if detect_downgrades and config.requirements and any(res.ok and res.packages for res in resolutions): reference = resolve_reference(config, runner=runner, python=python) if reference is not None: mark_downgrades(resolutions, reference) return resolutions def reference_args(config: AppConfig, *, python: Optional[str] = None) -> List[str]: """Pip arguments for an unconstrained dry-run resolution of ``config.requirements``. No platform or ABI flags and source distributions allowed, so pip picks the newest release it would give a desktop. Only the versions matter; nothing is downloaded beyond metadata. """ return [ python or sys.executable, "-m", "pip", "install", "--disable-pip-version-check", "--dry-run", "--ignore-installed", "--quiet", "--report", "-", *[arg for url in config.extra_index_urls for arg in ("--extra-index-url", url)], *config.requirements, ] def resolve_reference( config: AppConfig, *, runner: CommandRunner, python: Optional[str] = None ) -> Optional[Dict[str, str]]: """Resolve the requirements with no target constraint; return ``{name: version}``. Returns ``None`` when pip can't resolve them at all (the per-target errors already say why) or the report can't be parsed. """ if not config.requirements: return {} result = runner.run(reference_args(config, python=python), capture=True) if not result.ok: return None try: data = json.loads(result.stdout or "{}") except ValueError: return None versions: Dict[str, str] = {} for item in data.get("install", []): metadata = item.get("metadata", {}) versions[_canonical(str(metadata.get("name", "")))] = str(metadata.get("version", "")) return versions def mark_downgrades(resolutions: Sequence[Resolution], reference: Dict[str, str]) -> None: """Set ``latest`` on every resolved package that is older than in ``reference``. pip's cross-resolution silently walks back through a package's release history until it finds a version with a usable wheel for the target. That is the right call for a build (it works), but it is a surprise for the developer who gets a two-year-old API on the device and the current one in their tests. Marking the gap lets ``pn deps`` say so. Args: resolutions: Per-target resolutions to annotate in place. reference: ``{canonical name: version}`` from an unconstrained resolution (see [`resolve_reference`][pythonnative.project.deps.resolve_reference]). """ for res in resolutions: for pkg in res.packages: latest = reference.get(_canonical(pkg.name)) if latest and _version_key(latest) > _version_key(pkg.version): pkg.latest = latest def _canonical(name: str) -> str: return re.sub(r"[-_.]+", "-", name).lower() def _version_key(version: str) -> Tuple[int, ...]: """Order versions by their numeric release segment, enough to detect a walk-back. Pre/post/dev suffixes are ignored on purpose: a ``2.5.2`` versus ``2.5.2.post1`` gap is not the kind of downgrade worth a warning. """ match = re.match(r"^\d+(?:\.\d+)*", version) return tuple(int(part) for part in match.group(0).split(".")) if match else () def install( config: AppConfig, target: Target, dest: Path, *, runner: CommandRunner, python: Optional[str] = None, ) -> None: """Install ``config.requirements`` for ``target`` into ``dest``. Args: config: The validated app configuration. target: The device target (an iOS one; Android installs happen in Gradle). dest: The ``app_packages.`` directory to populate. runner: A [`CommandRunner`][pythonnative.project.builder.CommandRunner]. python: Interpreter to run pip with (defaults to the current one). Raises: DependencyError: When pip can't satisfy the requirements for the target, with the unsatisfiable requirement(s) named. """ if not config.requirements: return dest.mkdir(parents=True, exist_ok=True) from .lockfile import requirements locked = requirements(config, [target], direct=True) command = install_args(config, target, dest, python=python) if locked is not None: locked_path = dest.parent / f"requirements.{target.slice_name}.lock" locked_path.write_text(locked, encoding="utf-8") command = command[: -len(config.requirements)] + ["--no-deps", "-r", str(locked_path)] result = runner.run(command, capture=True) if result.ok: return summary, missing = parse_failure(result.stderr) raise DependencyError(explain_failure(target, summary, missing)) def explain_failure(target: Target, summary: str, missing: Sequence[str]) -> str: """Compose the user-facing message for a failed resolution.""" names = ", ".join(missing) if missing else "one or more requirements" lines = [f"Could not resolve {names} for {target.label} (Python {target.python_version})."] if summary: lines.append(summary) if target.platform == "ios": lines.append( "iOS needs a wheel tagged for the device (ios_*_arm64_iphoneos) and, for 'pn run ios', the " "Simulator (ios_*_iphonesimulator). Pure-Python packages always work; for a C extension, check " f"{BEEWARE_INDEX_URL} or ask upstream for iOS wheels (PEP 730)." ) else: lines.append( "Android needs a wheel tagged android_*_arm64_v8a / android_*_x86_64 (PEP 738). Pure-Python " f"packages always work; for a C extension, check {CHAQUOPY_INDEX_URL} or PyPI's Android wheels." ) lines.append("Run 'pn deps' for a per-target report; see https://pythonnative.com/guides/pypi-packages/.") return "\n".join(lines) # ====================================================================== # Reporting # ====================================================================== def format_report(resolutions: Sequence[Resolution], *, requirements: Sequence[str]) -> str: """Render resolutions as the terminal report ``pn deps`` prints.""" if not requirements: return "No [requirements].packages declared in pythonnative.toml; nothing to resolve." out: List[str] = [] for res in resolutions: header = res.target.label if res.target.platform == "android": header += " (preview; Chaquopy resolves again inside the Gradle build)" out.append(header) if not res.ok: out.append(" [x] " + (res.error or "resolution failed").replace("\n", "\n ")) out.append("") continue width = max((len(f"{pkg.name} {pkg.version}") for pkg in res.packages), default=0) for pkg in res.packages: name = f"{pkg.name} {pkg.version}".ljust(width) kind = "pure Python" if pkg.is_pure else f"binary wheel {pkg.platform_tag} ({pkg.source_label})" marker = "[!!]" if pkg.downgraded else "[ok]" out.append(f" {marker} {name} {kind}") if pkg.downgraded: out.append( f" older than the desktop resolution ({pkg.latest}): newer releases have no wheel here" ) out.append("") failed = [res for res in resolutions if not res.ok] downgraded = sorted({pkg.name for res in resolutions for pkg in res.downgraded_packages}, key=str.lower) if downgraded: out.append( f"Older release selected for: {', '.join(downgraded)} ([!!] above). Pin a version in " "[requirements].packages if the API difference matters." ) if failed: out.append(f"{len(failed)} of {len(resolutions)} targets cannot be satisfied. See the [x] lines above.") else: out.append(f"All {len(resolutions)} targets resolved.") return "\n".join(out)

Back | FazBrowse Home | New Git URL