diff --git a/CHANGES.md b/CHANGES.md index 919b43b7..e509319a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,6 +13,33 @@ development source code and as such may not be routinely kept up to date. # __NEXT__ +## Improvements + +* Use of an alternate Conda package repository is now supported during + `nextstrain setup conda` and `nextstrain update conda` if you cannot or do + not want to use the default package repository hosted by Anaconda. Set the + [`NEXTSTRAIN_CONDA_CHANNEL_ALIAS`][] environment variable to the base URL of + the repository. This corresponds to the [`channel_alias` Conda config + setting][]. + ([#436](https://github.com/nextstrain/cli/pull/436)) + +* The Conda runtime no longer requires Rosetta 2 for macOS running on aarch64 + (aka arm64, Apple Silicon, M1/M2/…) hardware. This improves performance when + using the runtime. Run `nextstrain update conda` to receive the update. + ([#436](https://github.com/nextstrain/cli/pull/436)) + +[`NEXTSTRAIN_CONDA_CHANNEL_ALIAS`]: https://docs.nextstrain.org/projects/cli/en/__NEXT__/runtimes/conda/#envvar-NEXTSTRAIN_CONDA_CHANNEL_ALIAS +[`channel_alias` Conda config setting]: https://docs.conda.io/projects/conda/en/latest/user-guide/configuration/settings.html#set-ch-alias + +## Development + +* The `NEXTSTRAIN_CONDA_MICROMAMBA_VERSION` environment variable is no longer + supported (i.e. for use with `nextstrain setup conda`). Please use + [`NEXTSTRAIN_CONDA_MICROMAMBA_URL`][] instead. + ([#436](https://github.com/nextstrain/cli/pull/436)) + +[`NEXTSTRAIN_CONDA_MICROMAMBA_URL`]: https://docs.nextstrain.org/projects/cli/en/__NEXT__/runtimes/conda/#envvar-NEXTSTRAIN_CONDA_MICROMAMBA_URL + # 10.0.0 (7 May 2025) diff --git a/doc/conf.py b/doc/conf.py index 438d0ba9..21816c7c 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -14,6 +14,8 @@ # import sys # sys.path.insert(0, os.path.abspath('.')) +import re + # -- Project information ----------------------------------------------------- @@ -81,16 +83,25 @@ ## string" matching happening, and something like a plain `r'google'` ## regular expression will _NOT_ match all google.com URLs. linkcheck_ignore = [ - # we have links to localhost for explanatory purposes; obviously - # they will never work in the linkchecker - r'^http://127\.0\.0\.1:\d+', - r'^http://localhost:\d+', + # Fixed-string prefixes + *map(re.escape, [ + # we have links to localhost for explanatory purposes; obviously + # they will never work in the linkchecker + 'http://127.0.0.1:', + 'http://localhost:', + + # Cloudflare "protection" gets in the way with a 403 + 'https://conda.anaconda.org', + ]), ] linkcheck_anchors_ignore_for_url = [ - # Github uses anchor-looking links for highlighting lines but - # handles the actual resolution with Javascript, so skip anchor - # checks for Github URLs: - r'^https://github\.com', - r'^https://console\.aws\.amazon\.com/batch/home', - r'^https://console\.aws\.amazon\.com/ec2/v2/home', + # Fixed-string prefixes + *map(re.escape, [ + # Github uses anchor-looking links for highlighting lines but + # handles the actual resolution with Javascript, so skip anchor + # checks for Github URLs: + 'https://github.com', + 'https://console.aws.amazon.com/batch/home', + 'https://console.aws.amazon.com/ec2/v2/home', + ]), ] diff --git a/nextstrain/cli/requests.py b/nextstrain/cli/requests.py index 92aa3545..05c1a7bd 100644 --- a/nextstrain/cli/requests.py +++ b/nextstrain/cli/requests.py @@ -21,6 +21,7 @@ aggregate usage metrics, so we do not recommend omitting it unless necessary. """ +import certifi import os import platform import requests @@ -40,6 +41,10 @@ USER_AGENT_MINIMAL = bool(os.environ.get("NEXTSTRAIN_CLI_USER_AGENT_MINIMAL")) +CA_BUNDLE = os.environ.get("REQUESTS_CA_BUNDLE") \ + or os.environ.get("CURL_CA_BUNDLE") \ + or certifi.where() + class Session(requests.Session): def __init__(self): diff --git a/nextstrain/cli/runner/conda.py b/nextstrain/cli/runner/conda.py index 773067c2..0af48a4a 100644 --- a/nextstrain/cli/runner/conda.py +++ b/nextstrain/cli/runner/conda.py @@ -38,8 +38,24 @@ Environment variables ===================== +.. envvar:: NEXTSTRAIN_CONDA_CHANNEL_ALIAS + + The base URL to prepend to channel names. Equivalent to the |channel_alias + Conda config setting|_. + + Useful if you want to use a Conda package mirror that's not the default + (i.e. not Anaconda's). + + Defaults to the Conda ecosystem's default of + ``__. + +.. |channel_alias Conda config setting| replace:: ``channel_alias`` Conda config setting +.. _channel_alias Conda config setting: https://docs.conda.io/projects/conda/en/latest/user-guide/configuration/settings.html#set-ch-alias + + .. warning:: - For development only. You don't need to set these during normal operation. + The remaining variables are for development only. You don't need to set + these during normal operation. .. envvar:: NEXTSTRAIN_CONDA_CHANNEL @@ -60,34 +76,39 @@ .. _Conda package match spec: https://docs.conda.io/projects/conda/en/latest/user-guide/concepts/pkg-specs.html#package-match-specifications -.. envvar:: NEXTSTRAIN_CONDA_MICROMAMBA_VERSION +.. envvar:: NEXTSTRAIN_CONDA_MICROMAMBA_URL + + URL of a Micromamba release tarball (e.g. Conda package) to use for setup + and updates. + + May be a full URL or a relative URL to be joined with + :envvar:`NEXTSTRAIN_CONDA_CHANNEL_ALIAS`. Any occurrence of ``{subdir}`` + will be replaced with the current platform's Conda subdir value. - Version of Micromamba to use for setup and upgrade of the Conda runtime - env. Must be a version available from the `conda-forge channel - `__, or the special string - ``latest``. + Replaces the previously-supported development environment variable + ``NEXTSTRAIN_CONDA_MICROMAMBA_VERSION``. - Defaults to ``1.5.8``. + Defaults to ``conda-forge/{subdir}/micromamba-1.5.8-0.tar.bz2``. """ import json import os import platform -import re import shutil import subprocess import sys import tarfile import traceback from pathlib import Path, PurePosixPath -from typing import Iterable, NamedTuple, Optional, cast -from urllib.parse import urljoin, quote as urlquote +from tempfile import TemporaryFile +from typing import IO, Iterable, List, NamedTuple, Optional, cast +from urllib.parse import urljoin from .. import config from .. import requests -from ..errors import InternalError +from ..errors import InternalError, UserError from ..paths import RUNTIMES from ..types import Env, RunnerModule, SetupStatus, SetupTestResults, UpdateStatus -from ..util import capture_output, colored, exec_or_return, parse_version_lax, runner_name, setup_tests_ok, test_rosetta_enabled, warn +from ..util import capture_output, colored, exec_or_return, parse_version_lax, runner_name, setup_tests_ok, test_rosetta_enabled, uniq, warn RUNTIME_ROOT = RUNTIMES / "conda/" @@ -99,8 +120,11 @@ MICROMAMBA = MICROMAMBA_ROOT / "bin/micromamba" # If you update the version pin below, please update the docstring above too. -MICROMAMBA_VERSION = os.environ.get("NEXTSTRAIN_CONDA_MICROMAMBA_VERSION") \ - or "1.5.8" +MICROMAMBA_URL = os.environ.get("NEXTSTRAIN_CONDA_MICROMAMBA_URL") \ + or "conda-forge/{subdir}/micromamba-1.5.8-0.tar.bz2" + +CHANNEL_ALIAS = os.environ.get("NEXTSTRAIN_CONDA_CHANNEL_ALIAS") \ + or "https://conda.anaconda.org" NEXTSTRAIN_CHANNEL = os.environ.get("NEXTSTRAIN_CONDA_CHANNEL") \ or "nextstrain" @@ -171,10 +195,14 @@ def run(opts, argv, working_volume = None, extra_env: Env = {}, cpus: int = None def setup(dry_run: bool = False, force: bool = False) -> SetupStatus: + return _setup(dry_run, force) + + +def _setup(dry_run: bool = False, force: bool = False, install_dist: 'PackageDistribution' = None) -> SetupStatus: if not setup_micromamba(dry_run, force): return False - if not setup_prefix(dry_run, force): + if not setup_prefix(dry_run, force, install_dist): return False return True @@ -194,48 +222,46 @@ def setup_micromamba(dry_run: bool = False, force: bool = False) -> bool: if not dry_run: shutil.rmtree(str(MICROMAMBA_ROOT)) - # Query for Micromamba release try: - dist = package_distribution("conda-forge", "micromamba", MICROMAMBA_VERSION) + subdir = platform_subdir() except InternalError as err: warn(err) return False - assert dist, f"unable to find micromamba dist" - - # download_url is scheme-less, so add our preferred scheme but in a way - # that won't break if it starts including a scheme later. - dist_url = urljoin("https:", dist["download_url"]) + url = urljoin(CHANNEL_ALIAS, MICROMAMBA_URL.replace('{subdir}', subdir)) - print(f"Requesting Micromamba from {dist_url}…") + print(f"Requesting Micromamba from {url}…") if not dry_run: - response = requests.get(dist_url, stream = True) + response = requests.get(url, stream = True) response.raise_for_status() content_type = response.headers["Content-Type"] - assert content_type == "application/x-tar", \ - f"unknown content-type for micromamba dist: {content_type}" - - with tarfile.open(fileobj = response.raw, mode = "r|*") as tar: - # Ignore archive members starting with "/" and or including ".." parts, - # as these can be used (maliciously or accidentally) to overwrite - # unintended files (e.g. files outside of MICROMAMBA_ROOT). - safe_members = ( - member - for member in tar - if not member.name.startswith("/") - and ".." not in PurePosixPath(member.name).parts) - - print(f"Downloading and extracting Micromamba to {MICROMAMBA_ROOT}…") - tar.extractall(path = str(MICROMAMBA_ROOT), members = safe_members) + try: + with tarfile.open(fileobj = response.raw, mode = "r|*") as tar: + # Ignore archive members starting with "/" and or including ".." parts, + # as these can be used (maliciously or accidentally) to overwrite + # unintended files (e.g. files outside of MICROMAMBA_ROOT). + safe_members = ( + member + for member in tar + if not member.name.startswith("/") + and ".." not in PurePosixPath(member.name).parts) + + print(f"Downloading and extracting Micromamba to {MICROMAMBA_ROOT}…") + tar.extractall(path = str(MICROMAMBA_ROOT), members = safe_members) + + except tarfile.TarError as err: + raise UserError(f""" + Failed to extract {url} (Content-Type: {content_type}) as tar archive: {err} + """) else: print(f"Downloading and extracting Micromamba to {MICROMAMBA_ROOT}…") return True -def setup_prefix(dry_run: bool = False, force: bool = False) -> bool: +def setup_prefix(dry_run: bool = False, force: bool = False, install_dist: 'PackageDistribution' = None) -> bool: """ Install Conda packages with Micromamba into our ``PREFIX``. """ @@ -249,30 +275,22 @@ def setup_prefix(dry_run: bool = False, force: bool = False) -> bool: if not dry_run: shutil.rmtree(str(PREFIX)) - # We accept a package match spec, which one to three space-separated parts.¹ - # If we got a spec, then we use it as-is. - # - # ¹ - # - if " " in NEXTSTRAIN_BASE.strip(): - install_spec = NEXTSTRAIN_BASE - else: - latest_version = (package_distribution(NEXTSTRAIN_CHANNEL, NEXTSTRAIN_BASE) or {}).get("version") - - if latest_version: - install_spec = f"{NEXTSTRAIN_BASE} =={latest_version}" + if not install_dist: + for subdir in [platform_subdir(), *alternate_platform_subdirs()]: + if install_dist := package_distribution(NEXTSTRAIN_CHANNEL, NEXTSTRAIN_BASE, subdir): + break else: - warn(f"Unable to find latest version of {NEXTSTRAIN_BASE} package; falling back to non-specific install") + raise UserError(f"Unable to find latest version of {NEXTSTRAIN_BASE} package in {NEXTSTRAIN_CHANNEL}") - install_spec = NEXTSTRAIN_BASE + install_spec = f"{install_dist.name} =={install_dist.version}" # Create environment print(f"Installing Conda packages into {PREFIX}…") - print(f" - {install_spec}") + print(f" - {install_spec} ({install_dist.subdir})") if not dry_run: try: - micromamba("create", install_spec) + micromamba("create", install_spec, "--platform", install_dist.subdir) except InternalError as err: warn(err) traceback.print_exc() @@ -291,7 +309,7 @@ def setup_prefix(dry_run: bool = False, force: bool = False) -> bool: return True -def micromamba(*args, add_prefix: bool = True) -> None: +def micromamba(*args, stdout: IO[bytes] = None, add_prefix: bool = True) -> None: """ Runs our installed Micromamba with appropriate global options and options for prefix and channel selection. @@ -303,6 +321,9 @@ def micromamba(*args, add_prefix: bool = True) -> None: For convenience, all arguments are converted to strings before being passed to :py:func:`subprocess.run`. + Set the keyword-only argument *stdout* to a binary file-like object (with a + file descriptor) to redirect the process's stdout. + Set the keyword-only argument *add_prefix* to false to omit the ``--prefix`` option and channel-related options which are otherwise automatically added. @@ -332,9 +353,9 @@ def micromamba(*args, add_prefix: bool = True) -> None: # own channel. "--override-channels", "--strict-channel-priority", - "--channel", NEXTSTRAIN_CHANNEL, - "--channel", "conda-forge", - "--channel", "bioconda", + "--channel", urljoin(CHANNEL_ALIAS, NEXTSTRAIN_CHANNEL), + "--channel", urljoin(CHANNEL_ALIAS, "conda-forge"), + "--channel", urljoin(CHANNEL_ALIAS, "bioconda"), # Don't automatically pin Python so nextstrain-base deps can change # it on upgrade. @@ -346,6 +367,11 @@ def micromamba(*args, add_prefix: bool = True) -> None: # explicit here. "--allow-uninstall", "--allow-downgrade", + + # Honor same method of CA certificate overriding as requests, + # except without support for cert directories (only files). + *(["--cacert-path", requests.CA_BUNDLE] + if not Path(requests.CA_BUNDLE).is_dir() else []), ) env = { @@ -386,7 +412,7 @@ def micromamba(*args, add_prefix: bool = True) -> None: } try: - subprocess.run(argv, env = env, check = True) + subprocess.run(argv, env = env, stdout = stdout, check = True) except (OSError, subprocess.CalledProcessError) as err: raise InternalError(f"Error running {argv!r}") from err @@ -453,9 +479,6 @@ def supported_os() -> bool: if system == "Linux": return machine == "x86_64" - # Note even on arm64 (e.g. aarch64, Apple Silicon M1) we use x86_64 - # binaries because of current ecosystem compatibility, but Rosetta will - # make it work. elif system == "Darwin": return machine in {"x86_64", "arm64"} @@ -467,8 +490,6 @@ def supported_os() -> bool: yield ('operating system is supported', supported_os()) - yield from test_rosetta_enabled() - yield ("runtime data dir doesn't have spaces", " " not in str(RUNTIME_ROOT)) @@ -484,44 +505,63 @@ def update() -> UpdateStatus: """ Update all installed packages with Micromamba. """ - current_version = (package_meta(NEXTSTRAIN_BASE) or {}).get("version") - - # We accept a package match spec, which one to three space-separated parts.¹ - # If we got a spec, then we need to handle updates a bit differently. - # - # ¹ + # In the comparisons and logic below, we handle selecting the version to + # update to but still let Micromamba select the specific package _build_ to + # use. While our package creation automation currently doesn't support + # multiple builds of a version, it's worth noting that 1) Conda's data + # model allows for it, and 2) we may start producing multiple builds in the + # future (e.g. for varying x86_64-microarch-level dependencies¹ or other + # platform compatibility reasons). If we do, the code below should still + # work fine. However, if we start making "fixup" builds of existing + # versions (e.g. build 1 of version X after build 0 of version X), the "do + # we need to update?" logic below would not deal with them properly. + # -trs, 9 April 2025 & 13 May 2025 # - if " " in NEXTSTRAIN_BASE.strip(): - pkg = PackageSpec.parse(NEXTSTRAIN_BASE) - print(colored("bold", f"Updating {pkg.name} from {current_version} to {pkg.version_spec}…")) - update_spec = NEXTSTRAIN_BASE + # ¹ - else: - latest_version = (package_distribution(NEXTSTRAIN_CHANNEL, NEXTSTRAIN_BASE) or {}).get("version") + nextstrain_base = PackageSpec.parse(NEXTSTRAIN_BASE) + + current_meta = package_meta(NEXTSTRAIN_BASE) or {} + current_version = current_meta.get("version") + current_subdir = current_meta.get("subdir") or platform_subdir() - if latest_version: - if latest_version == current_version: - print(f"Conda package {NEXTSTRAIN_BASE} {current_version} already at latest version") - print() - return True + assert current_meta.get("name") in {nextstrain_base.name, None} - print(colored("bold", f"Updating Conda package {NEXTSTRAIN_BASE} from {current_version} to {latest_version}…")) + # Prefer the platform subdir if possible (e.g. to migrate from osx-64 → + # osx-arm64). Otherwise, use the prefix's current subdir or alternate + # platform subdirs (e.g. to allow "downgrade" from osx-arm64 → osx-64). + for subdir in uniq([platform_subdir(), current_subdir, *alternate_platform_subdirs()]): + if latest_dist := package_distribution(NEXTSTRAIN_CHANNEL, NEXTSTRAIN_BASE, subdir): + assert latest_dist.name == nextstrain_base.name + break + else: + raise UserError(f"Unable to find latest version of {NEXTSTRAIN_BASE} package in {NEXTSTRAIN_CHANNEL}") - update_spec = f"{NEXTSTRAIN_BASE} =={latest_version}" + latest_version = latest_dist.version + latest_subdir = latest_dist.subdir - else: - warn(f"Unable to find latest version of {NEXTSTRAIN_BASE} package; falling back to non-specific update") + if latest_version == current_version: + print(f"Conda package {nextstrain_base.name} {current_version} already at latest version") + else: + print(colored("bold", f"Updating Conda package {nextstrain_base.name} from {current_version} to {latest_version}…")) - print(colored("bold", f"Updating Conda package {NEXTSTRAIN_BASE} from {current_version}…")) + # Do we need to force a new setup? + if current_subdir != latest_subdir: + print(f"Updating platform from {current_subdir} → {latest_subdir} by setting up from scratch again…") + return _setup(install_dist = latest_dist, dry_run = False, force = True) + + # Anything to do? + if latest_version == current_version: + return True - update_spec = NEXTSTRAIN_BASE + update_spec = f"{latest_dist.name} =={latest_version}" print() print(f"Updating Conda packages in {PREFIX}…") - print(f" - {update_spec}") + print(f" - {update_spec} ({latest_dist.subdir})") try: - micromamba("update", update_spec) + micromamba("update", update_spec, "--platform", latest_dist.subdir) except InternalError as err: warn(err) traceback.print_exc() @@ -564,12 +604,7 @@ def package_version(spec: str) -> str: version = meta.get("version", "unknown") build = meta.get("build", "unknown") - channel = meta.get("channel", "unknown") - - anaconda_channel = re.search(r'^https://conda[.]anaconda[.]org/(?P.+?)/(?:linux|osx)-64$', channel) - - if anaconda_channel: - channel = anaconda_channel["repo"] + channel = meta.get("channel", "unknown") # full URL; includes subdir return f"{name} {version} ({build}, {channel})" @@ -584,56 +619,64 @@ def package_meta(spec: str) -> Optional[dict]: return json.loads(metafile.read_bytes()) -def package_distribution(channel: str, package: str, version: str = None, label: str = "main") -> Optional[dict]: - # If *package* is a package spec, convert it just to a name. - package = package_name(package) +def package_distribution(channel: str, spec: str, subdir: str) -> Optional['PackageDistribution']: + with TemporaryFile() as tmp: + micromamba( + "repoquery", "search", spec, - if version is None: - version = latest_package_label_version(channel, package, label) - if version is None: - warn(f"Could not find latest version of package {package!r} with label {label!r}.", - "\nUsing 'latest' version instead, which will be the latest version of the package regardless of label.") - version = "latest" + # Channel (repo) to search + "--override-channels", + "--strict-channel-priority", + "--channel", urljoin(CHANNEL_ALIAS, channel), + "--platform", subdir, - response = requests.get(f"https://api.anaconda.org/release/{urlquote(channel)}/{urlquote(package)}/{urlquote(version)}") - response.raise_for_status() + # Always check that we have latest package index + "--repodata-ttl", 0, - dists = response.json().get("distributions", []) + # Emit JSON so we can process it + "--json", - system = platform.system() - machine = platform.machine() + # Honor same method of CA certificate overriding as requests, + # except without support for cert directories (only files). + *(["--cacert-path", requests.CA_BUNDLE] + if not Path(requests.CA_BUNDLE).is_dir() else []), - if (system, machine) == ("Linux", "x86_64"): - subdir = "linux-64" - elif (system, machine) in {("Darwin", "x86_64"), ("Darwin", "arm64")}: - # Use the x86 arch even on arm (https://docs.nextstrain.org/en/latest/reference/faq.html#why-intel-miniconda-installer-on-apple-silicon) - subdir = "osx-64" - else: - raise InternalError(f"Unsupported system/machine: {system}/{machine}") + add_prefix = False, + stdout = tmp) - # Releases have other attributes related to system/machine, but they're - # informational-only and subdir is what Conda *actually* uses to - # differentiate distributions/files/etc. Use it too so we have the same - # view of reality. - subdir_dists = (d for d in dists if d.get("attrs", {}).get("subdir") == subdir) - dist = max(subdir_dists, default=None, key=lambda d: d.get("attrs", {}).get("build_number", 0)) + tmp.seek(0) - return dist + result = json.load(tmp).get("result", {}) + assert (status := result.get("status")) == "OK", \ + f"repoquery {status=}, not OK" -def package_name(spec: str) -> str: - return PackageSpec.parse(spec).name + dists = result.get("pkgs", []) + + # Default '0-dev' should be the lowest version according to PEP440.¹ + # + # We're intentionally ignoring build number as we let Micromamba sort out + # the best build variant for a given version of our nextstrain-base + # package. We currently do not produce multiple builds per version, but we + # may in the future. See also the comment at the top of update(). + # + # ¹ + dist = max(dists, default = None, key = lambda d: parse_version_lax(d.get("version", "0-dev"))) + if not dist: + return None -def latest_package_label_version(channel: str, package: str, label: str) -> Optional[str]: - response = requests.get(f"https://api.anaconda.org/package/{urlquote(channel)}/{urlquote(package)}/files") - response.raise_for_status() + return PackageDistribution(dist["name"], dist["version"], dist["subdir"]) - label_files = (file for file in response.json() if label in file.get("labels", [])) - # Default '0-dev' should be the lowest version according to PEP440 - # See https://peps.python.org/pep-0440/#summary-of-permitted-suffixes-and-relative-ordering - latest_file: dict = max(label_files, default={}, key=lambda file: parse_version_lax(file.get('version', '0-dev'))) - return latest_file.get("version") + +class PackageDistribution(NamedTuple): + name: str + version: str + subdir: str + + +def package_name(spec: str) -> str: + return PackageSpec.parse(spec).name class PackageSpec(NamedTuple): @@ -659,3 +702,40 @@ def parse(spec): return PackageSpec(parts[0], parts[1], None) except IndexError: return PackageSpec(parts[0], None, None) + + +def platform_subdir() -> str: + """ + Conda subdir to use for the :mod:`platform` on which we're running. + + One of ``linux-64``, ``osx-64``, or ``osx-arm64``. + + Raises an :exc:`InternalError` if the platform is currently unsupported. + """ + system = platform.system() + machine = platform.machine() + + if (system, machine) == ("Linux", "x86_64"): + subdir = "linux-64" + elif (system, machine) == ("Darwin", "x86_64"): + subdir = "osx-64" + elif (system, machine) == ("Darwin", "arm64"): + subdir = "osx-arm64" + else: + raise InternalError(f"Unsupported system/machine: {system}/{machine}") + + return subdir + + +def alternate_platform_subdirs() -> List[str]: + """ + Alternative Conda subdirs that this :mod:`platform` can use. + """ + system = platform.system() + machine = platform.machine() + + if (system, machine) == ("Darwin", "arm64"): + if setup_tests_ok(test_rosetta_enabled()): + return ["osx-64"] + + return [] diff --git a/nextstrain/cli/util.py b/nextstrain/cli/util.py index 2c0ffe94..77772dbf 100644 --- a/nextstrain/cli/util.py +++ b/nextstrain/cli/util.py @@ -6,7 +6,7 @@ import sys from functools import partial from importlib.metadata import distribution as distribution_info, PackageNotFoundError -from typing import Any, Callable, Iterable, Literal, Mapping, List, Optional, Sequence, Tuple, Union, overload +from typing import Any, Callable, Iterable, Literal, Mapping, List, Optional, Sequence, Tuple, TypeVar, Union, overload from packaging.version import Version, InvalidVersion, parse as parse_version_strict from pathlib import Path, PurePath from shlex import quote as shquote @@ -808,3 +808,14 @@ def __init__(self, version: str, *, compliant: bool, original: str = None): super().__init__(version) self.compliant = compliant self.original = original if original is not None else version + + +T = TypeVar("T") + +def uniq(xs: Iterable[T]) -> Iterable[T]: + """ + Filter an iterable *xs* to its unique elements, preserving order. + + Elements must be hashable. + """ + return dict.fromkeys(xs).keys() diff --git a/setup.py b/setup.py index a27dfbeb..6eae2d49 100644 --- a/setup.py +++ b/setup.py @@ -88,6 +88,7 @@ def find_namespaced_packages(namespace): python_requires = '>=3.8', install_requires = [ + "certifi", "docutils", "fasteners", "importlib_resources >=5.3.0; python_version < '3.11'",