diff --git a/CHANGES.md b/CHANGES.md index 8428c733..c3e5bff5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,6 +15,11 @@ development source code and as such may not be routinely kept up to date. ## Improvements +* `nextstrain setup ` and `nextstrain version --pathogens` now list + the available workflows for a pathogen if the pathogen lists the workflows + in the it's top-level `nextstrain-pathogen.yaml` file. + ([#461](https://github.com/nextstrain/cli/pull/461)) + * Snakemake's storage support downloaded files (stored in `.snakemake/storage/`) are now downloaded from AWS Batch builds by default. diff --git a/doc/changes.md b/doc/changes.md index 15044d75..d7341315 100644 --- a/doc/changes.md +++ b/doc/changes.md @@ -19,6 +19,11 @@ development source code and as such may not be routinely kept up to date. (v-next-improvements)= ### Improvements +* `nextstrain setup ` and `nextstrain version --pathogens` now list + the available workflows for a pathogen if the pathogen lists the workflows + in the it's top-level `nextstrain-pathogen.yaml` file. + ([#461](https://github.com/nextstrain/cli/pull/461)) + * Snakemake's storage support downloaded files (stored in `.snakemake/storage/`) are now downloaded from AWS Batch builds by default. diff --git a/doc/commands/run.rst b/doc/commands/run.rst index b920391c..a36b176b 100644 --- a/doc/commands/run.rst +++ b/doc/commands/run.rst @@ -60,8 +60,11 @@ positional arguments Available workflows may vary per pathogen (and possibly between pathogen version). Some pathogens may provide multiple variants or base configurations of a top-level workflow, e.g. as in - ``phylogenetic/mpxv`` and ``phylogenetic/hmpxv1``. Refer to the - pathogen's own documentation for valid workflow names. + ``phylogenetic/mpxv`` and ``phylogenetic/hmpxv1``. + Run ``nextstrain version --pathogens`` to see a list of registered + workflows per pathogen version. If the pathogen does not have + registered workflows, then refer to the pathogen's own documentation + for valid workflow names. Workflow names conventionally correspond directly to directory paths in the pathogen source, but this may not always be the case. diff --git a/nextstrain/cli/command/run.py b/nextstrain/cli/command/run.py index 5340841f..d278fb95 100644 --- a/nextstrain/cli/command/run.py +++ b/nextstrain/cli/command/run.py @@ -67,8 +67,11 @@ def register_parser(subparser): Available workflows may vary per pathogen (and possibly between pathogen version). Some pathogens may provide multiple variants or base configurations of a top-level workflow, e.g. as in - ``phylogenetic/mpxv`` and ``phylogenetic/hmpxv1``. Refer to the - pathogen's own documentation for valid workflow names. + ``phylogenetic/mpxv`` and ``phylogenetic/hmpxv1``. + Run ``nextstrain version --pathogens`` to see a list of registered + workflows per pathogen version. If the pathogen does not have + registered workflows, then refer to the pathogen's own documentation + for valid workflow names. Workflow names conventionally correspond directly to directory paths in the pathogen source, but this may not always be the case. @@ -225,6 +228,9 @@ def run(opts): # Resolve pathogen and workflow names to a local workflow directory. pathogen = PathogenVersion(opts.pathogen) + if opts.workflow not in pathogen.registered_workflows(): + print(f"The {opts.workflow!r} workflow is not registered as a compatible workflow, but trying to run anyways.") + workflow_directory = pathogen.workflow_path(opts.workflow) if not workflow_directory.is_dir() or not (workflow_directory / "Snakefile").is_file(): diff --git a/nextstrain/cli/command/version.py b/nextstrain/cli/command/version.py index 498c5a35..4ab8a925 100644 --- a/nextstrain/cli/command/version.py +++ b/nextstrain/cli/command/version.py @@ -71,5 +71,12 @@ def run(opts): print(" " + str(version) + (f"={version.url or ''}" if opts.verbose else ""), "(default)" if is_default else "") if opts.verbose: print(" " + str(version.path)) + + if registered_workflows := version.registered_workflows(): + print(" " + "Available workflows:") + for workflow in registered_workflows: + print(" " + workflow) + else: + print(" " + "No workflows listed, please refer to pathogen docs.") else: print(" (none)") diff --git a/nextstrain/cli/pathogens.py b/nextstrain/cli/pathogens.py index 60c533fe..7afbdc49 100644 --- a/nextstrain/cli/pathogens.py +++ b/nextstrain/cli/pathogens.py @@ -130,6 +130,7 @@ class PathogenVersion: setup_receipt: Optional[dict] = None url: Optional[URL] = None + registration: Optional[dict] = None def __init__(self, name_version_url: str, new_setup: bool = False): @@ -243,6 +244,9 @@ def __init__(self, name_version_url: str, new_setup: bool = False): self.registration_path = self.path / "nextstrain-pathogen.yaml" self.setup_receipt_path = self.path.with_suffix(self.path.suffix + ".json") + if self.registration_path.exists(): + self.registration = read_pathogen_registration(self.registration_path) + if not new_setup: if not self.path.is_dir(): # XXX TODO: This error case should maybe be handled outside of @@ -301,6 +305,23 @@ def __init__(self, name_version_url: str, new_setup: bool = False): self.url = url + def registered_workflows(self) -> Dict[str, Dict]: + """ + Parses :attr:`.registration` to return a dict of registered + compatible workflows, where the keys are workflow names. + """ + if self.registration is None: + debug("pathogen does not have a registration") + return {} + + workflows = self.registration.get("compatibility", {}).get("nextstrain run") + if not isinstance(workflows, dict): + debug(f"pathogen registration.compatibility['nextstrain runs'] is not a dict (got a {type(workflows).__name__})") + return {} + + return workflows + + def workflow_path(self, workflow: str) -> Path: return self.path / workflow @@ -448,6 +469,8 @@ def setup(self, dry_run: bool = False, force: bool = False) -> SetupStatus: json.dump(self.setup_receipt, f, indent = " ") print(file = f) + self.registration = read_pathogen_registration(self.registration_path) + return True @@ -455,20 +478,22 @@ def test_setup(self) -> SetupTestResults: def test_compatibility() -> SetupTestResult: msg = "nextstrain-pathogen.yaml declares `nextstrain run` compatibility" - try: - registration = read_pathogen_registration(self.registration_path) - except (OSError, yaml.YAMLError, ValueError): - if DEBUGGING: - traceback.print_exc() + if self.registration is None: return msg + "\n(couldn't read registration)", False try: - compatibility = registration["compatibility"]["nextstrain run"] + compatibility = self.registration["compatibility"]["nextstrain run"] except (KeyError, IndexError, TypeError): if DEBUGGING: traceback.print_exc() return msg + "\n(couldn't find 'compatibility: nextstrain run: …' field)", False + if compatibility: + if workflows := self.registered_workflows(): + msg += f"\nAvailable workflows: {list(workflows.keys())}" + else: + msg += f"\nNo workflows listed, please refer to pathogen docs." + return msg, bool(compatibility) return [ @@ -841,21 +866,28 @@ def sorted_versions(vs: Iterable[str]) -> List[str]: return [v.original for v in [*reversed(compliant), *non_compliant]] -def read_pathogen_registration(path: Path) -> Dict: +def read_pathogen_registration(path: Path) -> Optional[Dict]: """ Reads a ``nextstrain-pathogen.yaml`` file at *path* and returns a dict of its deserialized contents. - """ - with path.open("r", encoding = "utf-8") as f: - registration = yaml.safe_load(f) - # XXX TODO SOON: Consider doing actual schema validation here in the - # future. - # -trs, 12 Dec 2024 - if not isinstance(registration, dict): - raise ValueError(f"pathogen registration not a dict (got a {type(registration).__name__}): {str(path)!r}") - - return registration + Returns ``None`` if there was an issue reading the registration. + """ + try: + with path.open("r", encoding = "utf-8") as f: + registration = yaml.safe_load(f) + + # XXX TODO SOON: Consider doing actual schema validation here in the + # future. + # -trs, 12 Dec 2024 + if not isinstance(registration, dict): + raise ValueError(f"pathogen registration not a dict (got a {type(registration).__name__}): {str(path)!r}") + + return registration + except (OSError, yaml.YAMLError, ValueError): + if DEBUGGING: + traceback.print_exc() + return None # We query a nextstrain.org API instead of querying GitHub's API directly for a