[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/pythonnative/pythonnative/main/src/pythonnative/project/deps.py [Back]  [Original]

"""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)

Web Proxy Viewer  |  New URL  |  Original Page