diff --git a/pip/flatpak-pip-generator.py b/pip/flatpak-pip-generator.py index 2013ef0f..4703fb89 100755 --- a/pip/flatpak-pip-generator.py +++ b/pip/flatpak-pip-generator.py @@ -31,6 +31,8 @@ from typing import Any, TextIO, Callable import operator from packaging.version import Version +from packaging.tags import Tag +from typing import cast try: import requirements @@ -116,9 +118,37 @@ "(e.g. --ignore-pkg 'foo>=3.0.0' 'baz>=21.0')." ), ) +parser.add_argument( + "--prefer-wheels", + type=lambda s: [x.strip().lower() for x in s.split(",")], + default=[], + help="Comma-separated list of packages for which platform wheels should be preferred over sdists", +) +parser.add_argument( + "--wheel-arches", + type=lambda s: [x.strip().lower() for x in s.split(",") if x.strip()], + help="Comma-separated list of architectures for which platform wheels should be generated (default: x86_64,aarch64)", +) + opts = parser.parse_args() +if opts.runtime: + parts = opts.runtime.split("//", 1) + if len(parts) != 2 or not parts[0] or not parts[1]: + sys.exit("Runtime argument must be in the format: $RUNTIME_ID//$RUNTIME_BRANCH") + +DEFAULT_WHEEL_ARCHES = ["x86_64", "aarch64"] + +if opts.prefer_wheels: + if not opts.runtime: + sys.exit( + "--prefer-wheels requires --runtime to ensure correct platform wheel selection" + ) + +if opts.wheel_arches: + DEFAULT_WHEEL_ARCHES = opts.wheel_arches + if opts.requirements_file and opts.pyproject_file: sys.exit("Can't use both requirements and pyproject files at the same time") @@ -141,6 +171,309 @@ sys.exit("Please install the 'PyYAML' module") +def get_flatpak_runtime_scope(runtime: str) -> str: + for scope in ("--user", "--system"): + try: + subprocess.run( + ["flatpak", "info", scope, runtime], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + return scope + except subprocess.CalledProcessError: + continue + sys.exit(f"Runtime {runtime} not found for user or system") + + +runtime_scope = get_flatpak_runtime_scope(opts.runtime) if opts.runtime else None + + +def get_runtime_arch() -> str: + cmd = [ + "flatpak", + "info", + runtime_scope, + "-r", + opts.runtime, + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + for line in result.stdout.splitlines(): + if line.startswith("runtime/"): + parts = line.split("/") + if len(parts) >= 3: + return parts[2] + raise RuntimeError(f"Failed to determine architecture for runtime {opts.runtime}") + + +runtime_tags_cache: dict[str, set[Tag] | None] = {} + + +def get_platform_tags_from_runtime(arch: str) -> set[Tag] | None: + if arch in runtime_tags_cache: + return runtime_tags_cache[arch] + cmd = [ + "flatpak", + f"--arch={arch}", + runtime_scope, + "--devel", + "--command=python3", + "run", + opts.runtime, + "-c", + ( + "import json; " + "from packaging import tags; " + "print(json.dumps([str(t) for t in tags.sys_tags()]))" + ), + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + tags_list = [Tag(*t.split("-")) for t in json.loads(result.stdout)] + runtime_tags_cache[arch] = set(tags_list) + return runtime_tags_cache[arch] + except subprocess.CalledProcessError: + runtime_tags_cache[arch] = None + return None + + +SUPPORTED_TAG_SET: set[Tag] | None = None + +if opts.prefer_wheels: + runtime_arch = get_runtime_arch() + platform_tags = get_platform_tags_from_runtime(runtime_arch) + + if not platform_tags: + sys.exit( + "Failed to obtain platform tags from runtime. Cannot select platform wheels." + ) + + for arch in DEFAULT_WHEEL_ARCHES: + if arch == runtime_arch: + continue + + if get_platform_tags_from_runtime(arch) is None: + print( + f"Warning: Runtime for arch '{arch}' is not installed ", + f"platform tags will be extrapolated from the '{runtime_arch}' runtime.", + file=sys.stderr, + ) + + SUPPORTED_TAG_SET = set(platform_tags) + + assert SUPPORTED_TAG_SET is not None + + +def normalize_name(name: str) -> str: + return re.sub(r"[-_]+", "-", name.lower()) + + +def make_source( + file_info: dict[str, str | dict[str, str]], only_arches: list[str] | None = None +) -> dict[str, str | list[str] | dict[str, str]]: + url = file_info["url"] + digests = file_info["digests"] + filename = file_info["filename"] + + if not isinstance(url, str): + raise TypeError("file_info['url'] must be str") + if not isinstance(digests, dict): + raise TypeError("file_info['digests'] must be dict") + if not isinstance(filename, str): + raise TypeError("file_info['filename'] must be str") + + source: dict[str, str | list[str] | dict[str, str]] = {} + + source["type"] = "file" + source["url"] = url + source["sha256"] = digests["sha256"] + + if only_arches: + source["only-arches"] = only_arches + + if opts.checker_data: + pkg_name = normalize_name(get_package_name(filename)) + checker: dict[str, str] = {"type": "pypi", "name": pkg_name} + if filename.endswith(".whl"): + checker["packagetype"] = "bdist_wheel" + source["x-checker-data"] = checker + + return source + + +def resolve_package_sources( + name: str, + version: str, + candidates: list[str], + is_preferred: bool, +) -> tuple[list[dict], list[str]]: + pypi_files: list[dict] | None = None + + def get_pypi_files() -> list[dict]: + nonlocal pypi_files + if pypi_files is None: + url = f"https://pypi.org/pypi/{name}/{version}/json" + print(f"Fetching PyPI metadata for {name}=={version}") + with urllib.request.urlopen(url) as response: # noqa: S310 + pypi_files = json.loads(response.read().decode("utf-8"))["urls"] + return pypi_files + + def is_universal(filename: str) -> bool: + return filename.endswith(".whl") and filename[:-4].split("-")[-1] == "any" + + def is_platform_wheel(filename: str) -> bool: + return filename.endswith(".whl") and filename[:-4].split("-")[-1] != "any" + + def adapt_tags_for_arch(tag_set: set[Tag], arch: str) -> set[Tag]: + out = set() + for t in tag_set: + plat = t.platform + for known_arch in DEFAULT_WHEEL_ARCHES: + if plat.endswith(f"_{known_arch}"): + plat = plat[: -len(known_arch)] + arch + break + out.add(Tag(t.interpreter, t.abi, plat)) + return out + + def get_tags_for_arch(arch: str) -> set[Tag]: + assert SUPPORTED_TAG_SET is not None + runtime_tags = get_platform_tags_from_runtime(arch) + if runtime_tags is not None: + return runtime_tags + return adapt_tags_for_arch(SUPPORTED_TAG_SET, arch) + + def runtime_python_ver(tag_set: set[Tag]) -> int | None: + for t in tag_set: + if ( + t.interpreter.startswith("cp") + and t.abi.startswith("cp") + and t.interpreter == t.abi + ): + with suppress(ValueError): + return int(t.interpreter[2:]) + return None + + def wheel_priority(filename: str, arch_tag_set: set[Tag]) -> int: + parts = filename[:-4].split("-") + pytags = parts[-3].split(".") + abitags = parts[-2].split(".") + platformtags = parts[-1].split(".") + wheel_tags = { + Tag(py, abi, plat) + for py in pytags + for abi in abitags + for plat in platformtags + } + return len(wheel_tags & arch_tag_set) + + def arch_platform_candidates(arch: str, py_ver: int) -> list[dict]: + def is_arch_match(platform_tag: str) -> bool: + return platform_tag != "any" and arch in platform_tag + + def parse_wheel_ver(py: str) -> int | None: + if not py.startswith("cp"): + return None + with suppress(ValueError): + return int(py[2:]) + return None + + def is_abi_free(abitags: list[str]) -> bool: + return all(abi == "none" for abi in abitags) + + def strict_compat(pytags: list[str], abitags: list[str], py_ver: int) -> bool: + if is_abi_free(abitags): + return True + for py, abi in zip(pytags, abitags): + ver = parse_wheel_ver(py) + if ver is None: + continue + if ver == py_ver: + return True + if abi == "abi3" and ver <= py_ver: + return True + return False + + def relaxed_compat(pytags: list[str], abitags: list[str], py_ver: int) -> bool: + if is_abi_free(abitags): + return True + for py in pytags: + ver = parse_wheel_ver(py) + if ver is not None and ver <= py_ver: + return True + return False + + def collect(compat_fn) -> list[dict]: + result = [] + for f in get_pypi_files(): + fn = f["filename"] + if not fn.endswith(".whl"): + continue + parts = fn[:-4].split("-") + if not is_arch_match(parts[-1]): + continue + if py_ver is None or compat_fn( + parts[-3].split("."), parts[-2].split(".") + ): + result.append(f) + return result + + found = collect(lambda pytags, abitags: strict_compat(pytags, abitags, py_ver)) + if not found and py_ver is not None: + found = collect( + lambda pytags, abitags: relaxed_compat(pytags, abitags, py_ver) + ) + return found + + pypi_universal = next( + (f for f in get_pypi_files() if is_universal(f["filename"])), + None, + ) + if pypi_universal: + return [make_source(pypi_universal)], [] + + if not is_preferred: + pypi_sdist = next( + (f for f in get_pypi_files() if not f["filename"].endswith(".whl")), + None, + ) + if pypi_sdist: + return [make_source(pypi_sdist)], [] + + if any(is_platform_wheel(f["filename"]) for f in get_pypi_files()): + return [], [f"__PLATFORM_ONLY__:{name}"] + + return [], [f"{name}: No suitable source found on PyPI"] + + assert SUPPORTED_TAG_SET is not None + native_py_ver = runtime_python_ver(SUPPORTED_TAG_SET) + + sources_out: list[dict] = [] + errors: list[str] = [] + + for arch in DEFAULT_WHEEL_ARCHES: + arch_tags = get_tags_for_arch(arch) + py_ver = runtime_python_ver(arch_tags) or native_py_ver + + if py_ver is None: + errors.append( + f"{name}: Unable to determine Python version for arch '{arch}'" + ) + continue + + arch_candidates = arch_platform_candidates(arch, cast(int, py_ver)) + + if not arch_candidates: + errors.append(f"{name}: No platform wheel found for arch '{arch}' on PyPI") + continue + + wheel = max( + arch_candidates, key=lambda f: wheel_priority(f["filename"], arch_tags) + ) + sources_out.append(make_source(wheel, only_arches=[arch])) + + return sources_out, errors + + def get_poetry_deps(pyproject_data: dict[str, Any]) -> list[str]: poetry_deps = pyproject_data.get("tool", {}).get("poetry", {}).get("dependencies") @@ -188,30 +521,6 @@ def format_dependency_version(name: str, value: Any) -> str: ) -def get_pypi_url(name: str, filename: str) -> str: - url = f"https://pypi.org/pypi/{name}/json" - print("Extracting download url for", name) - with urllib.request.urlopen(url) as response: # noqa: S310 - body = json.loads(response.read().decode("utf-8")) - for release in body["releases"].values(): - for source in release: - if source["filename"] == filename: - return str(source["url"]) - raise Exception(f"Failed to extract url from {url}") - - -def get_tar_package_url_pypi(name: str, version: str) -> str: - url = f"https://pypi.org/pypi/{name}/{version}/json" - with urllib.request.urlopen(url) as response: # noqa: S310 - body = json.loads(response.read().decode("utf-8")) - for ext in ["bz2", "gz", "xz", "zip", "none-any.whl"]: - for source in body["urls"]: - if source["url"].endswith(ext): - return str(source["url"]) - err = f"Failed to get {name}-{version} source from {url}" - raise Exception(err) - - def get_package_name(filename: str) -> str: if filename.endswith(("bz2", "gz", "xz", "zip")): segments = filename.split("-") @@ -285,21 +594,6 @@ def fprint(string: str) -> None: print(separator) -def get_flatpak_runtime_scope(runtime: str) -> str: - for scope in ("--user", "--system"): - try: - subprocess.run( - ["flatpak", "info", scope, runtime], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=True, - ) - return scope - except subprocess.CalledProcessError: - continue - sys.exit(f"Runtime {runtime} not found for user or system") - - def handle_req_env_markers(requirements_text: str) -> str: def handle_sys_platform(marker: str) -> bool: pattern = r'sys_platform\s*(==|!=)\s*["\']([^"\']+)["\']' @@ -557,7 +851,7 @@ def to_ver(v: str) -> Version | None: flatpak_cmd = [ "flatpak", - get_flatpak_runtime_scope(opts.runtime), + runtime_scope, "--devel", "--share=network", f"--filesystem={tempfile.gettempdir()}", @@ -600,9 +894,10 @@ def to_ver(v: str) -> Version | None: modules: list[dict[str, str | list[str] | list[dict[str, Any]]]] = [] vcs_modules: list[dict[str, str | list[str] | list[dict[str, Any]]]] = [] -sources = {} - +sources: dict[str, Any] = {} unresolved_dependencies_errors = [] +prefer_wheels_missing: list[str] = [] +prefer_set = {normalize_name(p) for p in opts.prefer_wheels} tempdir_prefix = f"pip-generator-{output_package}" with tempfile.TemporaryDirectory(prefix=tempdir_prefix) as tempdir: @@ -635,41 +930,19 @@ def to_ver(v: str) -> Version | None: with suppress(FileNotFoundError): os.remove(requirements_file_output) - fprint("Downloading arch independent packages") - for filename in os.listdir(tempdir): - if not filename.endswith(("bz2", "any.whl", "gz", "xz", "zip")): - version = get_file_version(filename) - name = get_package_name(filename) - try: - url = get_tar_package_url_pypi(name, version) - print(f"Downloading {url}") - download_tar_pypi(url, tempdir) - except Exception as err: - # Can happen if only an arch dependent wheel is - # available like for wasmtime-27.0.2 - unresolved_dependencies_errors.append(err) - print("Deleting", filename) - with suppress(FileNotFoundError): - os.remove(os.path.join(tempdir, filename)) - - files: dict[str, list[str]] = {get_package_name(f): [] for f in os.listdir(tempdir)} + grouped: dict[str, list[str]] = {} for filename in os.listdir(tempdir): name = get_package_name(filename) - files[name].append(filename) - - # Delete redundant sources, for vcs sources - for name, files_list in files.items(): - if len(files_list) > 1: - zip_source = False - for fname in files[name]: - if fname.endswith(".zip"): - zip_source = True - if zip_source: - for fname in files[name]: - if not fname.endswith(".zip"): - with suppress(FileNotFoundError): - os.remove(os.path.join(tempdir, fname)) + grouped.setdefault(name, []).append(filename) + + # Delete redundant sources, for vcs sources keeping zip + for name, files_list in grouped.items(): + if len(files_list) > 1 and any(f.endswith(".zip") for f in files_list): + for fname in files_list: + if not fname.endswith(".zip"): + with suppress(FileNotFoundError): + os.remove(os.path.join(tempdir, fname)) vcs_packages: dict[str, dict[str, str | None]] = { str(x.name): {"vcs": x.vcs, "revision": x.revision, "uri": x.uri} @@ -678,12 +951,13 @@ def to_ver(v: str) -> Version | None: } fprint("Obtaining hashes and urls") + + grouped = {} for filename in os.listdir(tempdir): - source: OrderedDict[str, str | dict[str, str]] = OrderedDict() name = get_package_name(filename) - sha256 = get_file_hash(os.path.join(tempdir, filename)) - is_pypi = False + grouped.setdefault(name, []).append(filename) + for name, candidates in grouped.items(): if name in vcs_packages: uri = vcs_packages[name]["uri"] if not uri: @@ -695,28 +969,35 @@ def to_ver(v: str) -> Version | None: f"Unable to determine VCS type for VCS package: {name}" ) url = "https://" + uri.split("://", 1)[1] - s = "commit" - if vcs == "svn": - s = "revision" - source["type"] = vcs - source["url"] = url + vcs_source: dict[str, Any] = {"type": vcs, "url": url} if revision: - source[s] = revision - is_vcs = True + vcs_source["commit" if vcs != "svn" else "revision"] = revision + sources[name] = {"source": [vcs_source], "vcs": True, "pypi": False} else: - name = name.casefold() - is_pypi = True - url = get_pypi_url(name, filename) - source["type"] = "file" - source["url"] = url - source["sha256"] = sha256 - if opts.checker_data: - checker_data = {"type": "pypi", "name": name} - if url.endswith(".whl"): - checker_data["packagetype"] = "bdist_wheel" - source["x-checker-data"] = checker_data - is_vcs = False - sources[name] = {"source": source, "vcs": is_vcs, "pypi": is_pypi} + name_cf = normalize_name(name) + version = get_file_version(candidates[0]) + is_preferred = name_cf in prefer_set + + resolved, errors = resolve_package_sources( + name_cf, version, candidates, is_preferred + ) + + for error in errors: + if error.startswith("__PLATFORM_ONLY__:"): + pkg = error.removeprefix("__PLATFORM_ONLY__:") + prefer_wheels_missing.append(pkg) + unresolved_dependencies_errors.append( + f"Only platform wheels are available for: {pkg}" + ) + else: + unresolved_dependencies_errors.append(error) + + if resolved: + sources[name_cf] = { + "source": resolved, + "vcs": False, + "pypi": True, + } # Python3 packages that come as part of org.freedesktop.Sdk. system_packages = [ @@ -799,7 +1080,7 @@ def to_ver(v: str) -> Version | None: is_vcs = bool(package.vcs) package_sources = [] for dependency in dependencies: - casefolded = dependency.casefold() + casefolded = normalize_name(dependency) if casefolded in sources and sources[casefolded].get("pypi") is True: source = sources[casefolded] elif dependency in sources and sources[dependency].get("pypi") is False: @@ -817,10 +1098,10 @@ def to_ver(v: str) -> Version | None: else: continue - if not (not source["vcs"] or is_vcs): + if source["vcs"] and not is_vcs: continue - package_sources.append(source["source"]) + package_sources.extend(source["source"]) name_for_pip = "." if package.vcs else pkg @@ -875,10 +1156,8 @@ def to_ver(v: str) -> Version | None: if opts.yaml: class OrderedDumper(yaml.Dumper): - def increase_indent( - self, flow: bool = False, indentless: bool = False - ) -> None: - return super().increase_indent(flow, indentless) + def increase_indent(self, flow: bool = False, indentless: bool = False): + return super().increase_indent(flow, False) def dict_representer( dumper: yaml.Dumper, data: OrderedDict[str, Any] @@ -890,28 +1169,27 @@ def dict_representer( output.write( "# Generated with flatpak-pip-generator " + " ".join(sys.argv[1:]) + "\n" ) - yaml.dump(pypi_module, output, Dumper=OrderedDumper) + yaml.dump( + pypi_module, + output, + Dumper=OrderedDumper, + sort_keys=False, + ) else: output.write(json.dumps(pypi_module, indent=4) + "\n") + print(f"Output saved to {output_filename}") if len(unresolved_dependencies_errors) != 0: - print("Unresolved dependencies. Handle them manually") for e in unresolved_dependencies_errors: print(f"- ERROR: {e}") - workaround = """Example on how to handle arch dependent wheels: - - type: file - url: https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl - sha256: 7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e - only-arches: - - aarch64 - - type: file - url: https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - sha256: 666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5 - only-arches: - - x86_64 - """ - raise Exception( - f"Not all dependencies can be determined. Handle them manually.\n{workaround}" - ) + if prefer_wheels_missing: + pkgs = ",".join(sorted(set(prefer_wheels_missing))) + print( + f"\nOnly platform wheels are available for: {pkgs}. " + f"Use '--runtime $RUNTIME_ID//$RUNTIME_BRANCH --prefer-wheels={pkgs}'\n", + file=sys.stderr, + ) + + raise Exception("Unresolved dependencies. Handle them manually") diff --git a/pip/readme.md b/pip/readme.md index f47639e7..4584926e 100644 --- a/pip/readme.md +++ b/pip/readme.md @@ -38,16 +38,54 @@ You can use that in your manifest like ] ``` +## Source Selection + +By default, this tool selects artifacts from PyPI using the following +priority: universal wheels (`none-any.whl`) > sdists. + +If neither is available for a module, the an error is raised. +Platform-specific wheels are ignored unless explicitly enabled via +`--prefer-wheels=module1,module2,...`. + +When `--prefer-wheels` is used, a Flatpak runtime must be provided with +the `--runtime` argument. The runtime must include `Python`, `pip`, and +`python-packaging` module. This is used to determine platform tags +(Python version, ABI, and architecture). + +By default, platform wheels are considered for the following +`x86_64` and `aarch64`. This can be overridden with +`--wheel-arches arch1 arch2 ...`. + +If the specified runtime is only available for a single architecture, +platform tags for other architectures are inferred from it. + +## Examples for preferring platform wheels + +### Generate for x86_64 and aarch64 + +```sh +./flatpak-pip-generator --runtime org.freedesktop.Sdk//25.08 --prefer-wheels=cryptography,cffi cryptography +``` + +### Generate for only x86_64 + +```sh +./flatpak-pip-generator --runtime org.freedesktop.Sdk//25.08 --prefer-wheels=cryptography,cffi --wheel-arches x86_64 cryptography +``` + +### Generate for x86_64 and ppc64le + +```sh +./flatpak-pip-generator --runtime org.freedesktop.Sdk//25.08 --prefer-wheels=cryptography,cffi --wheel-arches x86_64,ppc64le cryptography +``` + ## Options ``` -usage: flatpak-pip-generator.py [-h] [--python2] [--cleanup {scripts,all}] - [--requirements-file REQUIREMENTS_FILE] - [--pyproject-file PYPROJECT_FILE] [--build-only] - [--build-isolation][--ignore-installed IGNORE_INSTALLED] - [--checker-data] [--output OUTPUT] [--runtime RUNTIME] - [--yaml] [--ignore-errors] [--ignore-pkg [IGNORE_PKG ...]] - [packages ...] +usage: flatpak-pip-generator [-h] [--python2] [--cleanup {scripts,all}] [--requirements-file REQUIREMENTS_FILE] [--pyproject-file PYPROJECT_FILE] [--optdep-groups [GROUP ...]] [--build-only] + [--build-isolation] [--ignore-installed IGNORE_INSTALLED] [--checker-data] [--output OUTPUT] [--runtime RUNTIME] [--yaml] [--ignore-errors] [--ignore-pkg [IGNORE_PKG ...]] + [--prefer-wheels PREFER_WHEELS] [--wheel-arches WHEEL_ARCHES] + [packages ...] positional arguments: packages @@ -61,20 +99,23 @@ options: Specify requirements.txt file. Cannot be used with pyproject file. --pyproject-file PYPROJECT_FILE Specify pyproject.toml file. Cannot be used with requirements file. + --optdep-groups [GROUP ...] + Specify optional dependency groups to include. Can only be used with pyproject file. --build-only Clean up all files after build --build-isolation Do not disable build isolation. Mostly useful on pip that does't support the feature. --ignore-installed IGNORE_INSTALLED - Comma-separated list of package names for which pip should ignore already installed packages. - Useful when the package is installed in the SDK but not in the runtime. + Comma-separated list of package names for which pip should ignore already installed packages. Useful when the package is installed in the SDK but not in the runtime. --checker-data Include x-checker-data in output for the "Flatpak External Data Checker" --output, -o OUTPUT Specify output file name - --runtime RUNTIME Specify a flatpak to run pip inside of a sandbox, ensures python version compatibility. - Format: $RUNTIME_ID//$RUNTIME_BRANCH + --runtime RUNTIME Specify a flatpak to run pip inside of a sandbox, ensures python version compatibility. Format: $RUNTIME_ID//$RUNTIME_BRANCH --yaml Use YAML as output format instead of JSON --ignore-errors Ignore errors when downloading packages --ignore-pkg [IGNORE_PKG ...] - Ignore packages when generating the manifest. Needs to be specified - with version constraints if present (e.g. --ignore-pkg 'foo>=3.0.0' 'baz>=21.0'). + Ignore packages when generating the manifest. Needs to be specified with version constraints if present (e.g. --ignore-pkg 'foo>=3.0.0' 'baz>=21.0'). + --prefer-wheels PREFER_WHEELS + Comma-separated list of packages for which platform wheels should be preferred over sdists + --wheel-arches WHEEL_ARCHES + Comma-separated list of architectures for which platform wheels should be generated (default: x86_64,aarch64) ``` ## Development