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'",