diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fe7099..97be47e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ on: pull_request: branches: - main + - develop workflow_dispatch: jobs: diff --git a/config_templates/config.ini b/config_templates/config.ini index e8573a1..1341154 100644 --- a/config_templates/config.ini +++ b/config_templates/config.ini @@ -40,3 +40,10 @@ ANALYSIS_NAME = analysis RUN_DB_PATH = /Group Functions/mdfactory/runs ANALYSIS_DB_PATH = /Group Functions/mdfactory/analysis ARTIFACT_DB_PATH = /Group Functions/mdfactory/artifacts + +[slurm] +; Optional manual overrides. When empty, values are autodiscovered via sinfo/sacctmgr. +ACCOUNT = +PARTITION_CPU = +PARTITION_GPU = +DEFAULT_QOS = diff --git a/mdfactory/analysis/store.py b/mdfactory/analysis/store.py index 51af7c0..253c7c1 100644 --- a/mdfactory/analysis/store.py +++ b/mdfactory/analysis/store.py @@ -40,6 +40,7 @@ def __init__( roots: list[Path | str] | Path | str, trajectory_file: str = "prod.xtc", structure_file: str = "system.pdb", + min_status: str = "production", ): """Initialize store with one or more root paths. @@ -51,6 +52,9 @@ def __init__( Trajectory filename to discover structure_file : str Structure filename to discover + min_status : str + Minimum simulation status to include in discovery. One of: + "build", "equilibrated", "production", "completed". """ # Normalize roots to list of Paths @@ -61,6 +65,7 @@ def __init__( self.trajectory_file = trajectory_file self.structure_file = structure_file + self.min_status = min_status self._simulations: dict[str, Simulation] = {} # hash -> Simulation self._discovery_df: pd.DataFrame | None = None @@ -105,6 +110,7 @@ def discover(self, refresh: bool = False) -> pd.DataFrame: root, trajectory_file=self.trajectory_file, structure_file=self.structure_file, + min_status=self.min_status, ) dfs.append(df) @@ -240,6 +246,12 @@ def build_metadata_table( "path": path, **flattened, } + + # Merge tags into metadata row + if build_input.tags: + for tag_key, tag_val in build_input.tags.items(): + metadata_row[f"tag_{tag_key}"] = tag_val + metadata_rows.append(metadata_row) metadata_df = pd.DataFrame(metadata_rows) @@ -247,6 +259,124 @@ def build_metadata_table( return metadata_df + def search( + self, + *, + simulation_type: str | None = None, + status: str | None = None, + hash_prefix: str | None = None, + tags: dict[str, str] | None = None, + smiles: str | None = None, + ) -> pd.DataFrame: + """Search and filter discovered simulations. + + Applies all provided filters conjunctively (AND logic). + + Parameters + ---------- + simulation_type : str | None + Filter by simulation type (exact match). + status : str | None + Filter by minimum status threshold. + hash_prefix : str | None + Filter by hash prefix (case-insensitive). + tags : dict[str, str] | None + Filter by tag key-value pairs (all must match). + smiles : str | None + Filter by SMILES substructure match against any species. + + Returns + ------- + pd.DataFrame + Filtered DataFrame with columns: hash, path, simulation_type, + status, tags (dict or None). + + """ + from loguru import logger + + self._ensure_discovered() + + if len(self._discovery_df) == 0: + logger.info("No simulations to search") + return pd.DataFrame(columns=["hash", "path", "simulation_type", "status", "tags"]) + + # Pre-validate and import dependencies before iterating + from .constants import STATUS_ORDER + + if status is not None and status not in STATUS_ORDER: + raise ValueError(f"Invalid status '{status}'. Must be one of: {STATUS_ORDER}") + + smiles_substructure_match = None + if smiles is not None: + try: + from mdfactory.utils.chemistry_utilities import ( + smiles_substructure_match, + ) + except ImportError: + raise ImportError( + "RDKit is required for SMILES search. " + "Install it via conda: conda install -c conda-forge rdkit" + ) + + # Build result rows from discovery + rows = [] + for _, row in self._discovery_df.iterrows(): + sim = row["simulation"] + bi = sim.build_input + sim_hash = row["hash"] + sim_path = row["path"] + sim_status = sim.status + + # Filter: simulation_type + if simulation_type is not None and bi.simulation_type != simulation_type: + continue + + # Filter: status (minimum threshold) + if status is not None: + status_idx = STATUS_ORDER.index(sim_status) + min_idx = STATUS_ORDER.index(status) + if status_idx < min_idx: + continue + + # Filter: hash prefix + if hash_prefix is not None: + if not sim_hash.upper().startswith(hash_prefix.upper()): + continue + + # Filter: tags + if tags is not None: + if bi.tags is None: + continue + if not all(bi.tags.get(k) == v for k, v in tags.items()): + continue + + # Filter: SMILES substructure + if smiles is not None: + match_found = False + for species in bi.system.species: + species_smiles = getattr(species, "smiles", None) + if species_smiles and smiles_substructure_match(smiles, species_smiles): + match_found = True + break + if not match_found: + continue + + rows.append( + { + "hash": sim_hash, + "path": sim_path, + "simulation_type": bi.simulation_type, + "status": sim_status, + "tags": bi.tags, + } + ) + + logger.info(f"Search returned {len(rows)} results") + return pd.DataFrame( + rows, + columns=["hash", "path", "simulation_type", "status", "tags"], + ) + def load_analysis_with_metadata( self, analysis_name: str, diff --git a/mdfactory/analysis/submit.py b/mdfactory/analysis/submit.py index b14caf9..55c1648 100644 --- a/mdfactory/analysis/submit.py +++ b/mdfactory/analysis/submit.py @@ -6,7 +6,6 @@ import os import shutil -from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Iterable @@ -16,42 +15,15 @@ from mdfactory.analysis.artifacts import ARTIFACT_REGISTRY from mdfactory.analysis.simulation import ANALYSIS_REGISTRY, Simulation - -@dataclass(frozen=True) -class SlurmConfig: - """Configuration for submitit/SLURM execution.""" - - account: str - partition: str = "cpu" - time: str = "2h" - cpus_per_task: int = 4 - mem_gb: int = 8 - qos: str | None = None - constraint: str | None = None - job_name_prefix: str = "mdfactory-analysis" - - -def normalize_slurm_time(value: str) -> str: - """Normalize SLURM time strings to accepted formats.""" - raw = value.strip() - if ":" in raw: - return raw - lowered = raw.lower() - if lowered.endswith("d"): - days = int(lowered[:-1]) - return f"{days}-00:00:00" - if lowered.endswith("h"): - hours = int(lowered[:-1]) - return f"{hours:02d}:00:00" - if lowered.endswith("m"): - minutes = int(lowered[:-1]) - hours, minutes = divmod(minutes, 60) - return f"{hours:02d}:{minutes:02d}:00" - if lowered.isdigit(): - minutes = int(lowered) - hours, minutes = divmod(minutes, 60) - return f"{hours:02d}:{minutes:02d}:00" - return raw +# SlurmConfig and normalize_slurm_time live in the performance package so that +# every SLURM-facing backend (submitit, Parsl, Nextflow) can share them. +# Re-exported here for backward compatibility: +# from mdfactory.analysis.submit import SlurmConfig # still works +# from mdfactory.analysis.submit import normalize_slurm_time # still works +from mdfactory.performance.slurm_config import ( # noqa: F401 (re-export) + SlurmConfig, + normalize_slurm_time, +) def resolve_simulation_paths( diff --git a/mdfactory/cli.py b/mdfactory/cli.py index 478015e..176cef8 100644 --- a/mdfactory/cli.py +++ b/mdfactory/cli.py @@ -8,7 +8,7 @@ import sys from datetime import datetime from pathlib import Path -from typing import Annotated, Literal +from typing import TYPE_CHECKING, Annotated, Literal import pandas as pd import questionary @@ -33,7 +33,10 @@ from .utils.data_manager import check_run_exists from .utils.push import push_systems from .utils.utilities import working_directory -from .workflows import run_build_from_file +from .workflows import run_build_from_dict, run_build_from_file + +if TYPE_CHECKING: + from .orchestration import ExecutorConfig app = App(name="MDFactory", version=__version__) @@ -82,26 +85,7 @@ def prepare_build(input: Path, output: Path = Path(".")): logger.info("Created DataFrame for systems:\n{}", df) - dirs = [] - for _, row in df.iterrows(): - out = output / Path(row.hash) - out.mkdir(parents=True, exist_ok=False) - yml_path = out / f"{row.hash}.yaml" - dirs.append(str(out.resolve())) - with open(yml_path, "w") as fb: - yaml.safe_dump(row.model.model_dump(), fb) - - summary = { - "n_systems": df.shape[0], - "input": str(input), - "output": str(output), - "hash": df.hash.values.tolist(), - "simulation_type": [x.simulation_type for x in df.model.values], - "system_directory": dirs, - "date": datetime.now(), - } - with open(yml_build_path, "w") as fb: - yaml.safe_dump(summary, fb) + _prepare_system_directories(models, input, output, exist_ok=False) def df_models_from_input_csv(input): @@ -112,22 +96,312 @@ def df_models_from_input_csv(input): return df, models, errors +def _prepare_system_directories( + models: list, + input_path: Path, + output: Path, + *, + exist_ok: bool = True, +) -> tuple[list[str], Path]: + """Create per-hash build directories, write system YAMLs, and generate summary. + + Parameters + ---------- + models : list + List of BuildInput models. + input_path : Path + Original input file path (used for summary YAML naming). + output : Path + Base output directory. + exist_ok : bool + Whether to allow existing directories. + + Returns + ------- + tuple[list[str], Path] + List of system directory paths, and path to the summary YAML. + + """ + dirs = [] + for model in models: + build_dir = output / model.hash + build_dir.mkdir(parents=True, exist_ok=exist_ok) + yml_path = build_dir / f"{model.hash}.yaml" + if not yml_path.exists(): + with open(yml_path, "w") as f: + yaml.safe_dump(model.model_dump(), f) + dirs.append(str(build_dir.resolve())) + + summary_path = output / f"{input_path.stem}.yaml" + summary = { + "n_systems": len(models), + "input": str(input_path), + "output": str(output), + "hash": [m.hash for m in models], + "simulation_type": [m.simulation_type for m in models], + "system_directory": dirs, + "date": datetime.now(), + } + with open(summary_path, "w") as f: + yaml.safe_dump(summary, f) + return dirs, summary_path + + +def _resolve_slurm_flag(slurm: str | None) -> "ExecutorConfig | Path | None": + """Resolve the ``--slurm`` CLI flag to an executor config or YAML path. + + Parameters + ---------- + slurm : str or None + Raw value from the ``--slurm`` flag. ``"tui"`` launches the + interactive wizard (which returns a config object directly). + Any other non-None value is treated as a file path. + + Returns + ------- + ExecutorConfig, Path, or None + An ExecutorConfig when using TUI mode, a Path to executor config + YAML, or None if no SLURM config requested. + + """ + if slurm is None: + return None + + if slurm.strip().lower() == "tui": + from mdfactory.orchestration.tui import UserCancelledError, configure_slurm_interactive + + try: + return configure_slurm_interactive() + except UserCancelledError: + logger.info("SLURM configuration cancelled.") + sys.exit(0) + + path = Path(slurm) + if not path.exists(): + logger.error(f"SLURM config file not found: {path}") + sys.exit(1) + return path + + +def _load_executor_config(config: "ExecutorConfig | Path | None") -> "ExecutorConfig": + """Load executor config from path or return directly if already loaded. + + Parameters + ---------- + config : ExecutorConfig, Path, or None + An already-loaded config object, a path to a YAML file, or None + for default local execution. + + Returns + ------- + ExecutorConfig + The resolved executor configuration. + + """ + if config is None: + from mdfactory.orchestration import ExecutorConfig + + return ExecutorConfig() + if isinstance(config, Path): + from mdfactory.orchestration import ExecutorConfig + + return ExecutorConfig.from_yaml(config) + return config # already an ExecutorConfig instance + + @app.command(name="build", group="Build") -def build_system(input: Path, output: Path = Path(".")): - """Build MD system from YAML input file. +def build_system( + input: Path, + output: Path = Path("."), + slurm: Annotated[ + str | None, + Parameter( + help=("SLURM executor config: path to YAML file, or 'tui' for interactive setup.") + ), + ] = None, + dry_run: Annotated[ + bool, Parameter(help="Print what would be built without executing.") + ] = False, +): + """Build MD system(s) from YAML, CSV, or summary input. + + Supports three input modes: + + - Single YAML: builds one system locally (original behavior) + - CSV file: builds systems from each row (parallel with --slurm, sequential without) + - Summary YAML: dispatches builds for previously prepared systems Parameters ---------- input : Path - Path to the YAML file specifying the `BuildInput` + Path to input file (YAML for single build, CSV for batch, summary YAML for prepared). output : Path, optional - Output directory, by default Path(".") + Output directory, by default Path("."). + slurm : str, optional + Path to SLURM executor config YAML, or ``"tui"`` to launch the + interactive configuration wizard. + dry_run : bool, optional + Print what would be built without executing. """ input = input.resolve() - logger.info(f"Building system from YAML file {input} and output directory: {output}.") - with working_directory(output, create=True): - run_build_from_file(input) + output = output.resolve() + output.mkdir(parents=True, exist_ok=True) + + # Resolve --slurm flag: "tui" launches the interactive wizard, + # anything else is treated as a path to a YAML config file. + config: "ExecutorConfig | Path | None" = _resolve_slurm_flag(slurm) + + suffix = input.suffix.lower() + + if suffix == ".csv": + _build_from_csv(input, output, config=config, dry_run=dry_run) + elif suffix in (".yaml", ".yml"): + _build_from_yaml(input, output, config=config, dry_run=dry_run) + else: + logger.error(f"Unsupported input file format: {suffix}. Use .csv, .yaml, or .yml.") + sys.exit(1) + + +def _build_from_csv( + input: Path, + output: Path, + *, + config: "ExecutorConfig | Path | None" = None, + dry_run: bool = False, +): + """Handle CSV input for the build command.""" + df, models, errors = df_models_from_input_csv(input) + if errors: + logger.error("Errors building models from CSV:") + for k, v in errors.items(): + logger.error(f" Row {k}: {v}") + sys.exit(1) + + if dry_run: + from mdfactory.orchestration import build_systems + + executor_config = _load_executor_config(config) + build_systems(models, executor_config, output_dir=output, dry_run=True) + return + + # Create per-hash directories, write per-system YAMLs, and generate summary + dirs, summary_path = _prepare_system_directories(models, input, output, exist_ok=True) + logger.info(f"Summary YAML written to {summary_path}") + + if config is not None: + # Parallel builds via Parsl + from mdfactory.orchestration import build_systems + + executor_config = _load_executor_config(config) + results = build_systems(models, executor_config, output_dir=output) + _report_build_results(results) + else: + # Sequential local builds + logger.info(f"Building {len(models)} system(s) sequentially.") + for model in models: + build_dir = output / model.hash + logger.info(f"Building {model.hash} ({model.simulation_type})...") + with working_directory(build_dir, create=True): + run_build_from_dict(model) + + +def _build_from_yaml( + input: Path, + output: Path, + *, + config: "ExecutorConfig | Path | None" = None, + dry_run: bool = False, +): + """Handle YAML input for the build command.""" + with open(input) as f: + data = yaml.safe_load(f) + + if not isinstance(data, dict): + logger.error(f"Invalid YAML input file (empty or not a mapping): {input}") + sys.exit(1) + + if "system_directory" in data: + # Summary YAML -- dispatch builds for prepared systems + _build_from_summary_yaml(data, output, config=config, dry_run=dry_run) + else: + # Single build YAML + from mdfactory.models.input import BuildInput + + model = BuildInput(**data) + + if config is not None or dry_run: + from mdfactory.orchestration import build_systems + + executor_config = _load_executor_config(config) + results = build_systems([model], executor_config, output_dir=output, dry_run=dry_run) + if not dry_run: + _report_build_results(results) + else: + build_dir = output / model.hash + logger.info(f"Building {model.hash} ({model.simulation_type}) -> {build_dir}") + with working_directory(build_dir, create=True): + run_build_from_file(input) + + +def _build_from_summary_yaml( + data: dict, + output: Path, + *, + config: "ExecutorConfig | Path | None" = None, + dry_run: bool = False, +): + """Handle summary YAML (from prepare-build) for the build command.""" + dirs = data.get("system_directory", []) + hashes = data.get("hash", []) + + if not dirs: + logger.error("Summary YAML contains no system_directory entries.") + sys.exit(1) + + if len(dirs) != len(hashes): + logger.error( + f"Summary YAML is malformed: system_directory has {len(dirs)} entries " + f"but hash has {len(hashes)}." + ) + sys.exit(1) + + from mdfactory.models.input import BuildInput + + models = [] + for sim_dir, sim_hash in zip(dirs, hashes): + yml_path = Path(sim_dir) / f"{sim_hash}.yaml" + if not yml_path.exists(): + logger.error(f"Build YAML not found: {yml_path}") + sys.exit(1) + with open(yml_path) as f: + model_data = yaml.safe_load(f) + models.append(BuildInput(**model_data)) + + if config is not None or dry_run: + from mdfactory.orchestration import build_systems + + executor_config = _load_executor_config(config) + results = build_systems(models, executor_config, output_dir=output, dry_run=dry_run) + if not dry_run: + _report_build_results(results) + else: + # Sequential local builds + logger.info(f"Building {len(models)} prepared system(s) sequentially.") + for model, sim_dir in zip(models, dirs): + logger.info(f"Building {model.hash} ({model.simulation_type})...") + with working_directory(Path(sim_dir), create=True): + run_build_from_dict(model) + + +def _report_build_results(results: list[dict]): + """Report build results to the user.""" + succeeded = sum(1 for r in results if r.get("status") == "success") + failed = sum(1 for r in results if r.get("status") == "failed") + logger.info(f"Build complete: {succeeded} succeeded, {failed} failed.") + for r in results: + if r.get("status") == "failed": + logger.error(f" {r.get('hash', 'unknown')}: {r.get('error', 'unknown error')}") @app.command(name="clean") @@ -317,6 +591,109 @@ def _exit_sync_push_error(exc: Exception) -> None: sys.exit(1) +def _write_output(df: pd.DataFrame, output: Path) -> None: + """Write a DataFrame to a file (CSV or JSON). + + Parameters + ---------- + df : pd.DataFrame + DataFrame to write. + output : Path + Output file path. If the suffix is ``.json``, writes + newline-delimited JSON; otherwise defaults to CSV. + + """ + output = output.resolve() + suffix = output.suffix.lower() + + if suffix == ".json": + df.to_json(output, orient="records", lines=True) + else: + # Default to CSV + df.to_csv(output, index=False) + + logger.success(f"Wrote {len(df)} record(s) to {output}") + + +def _resolve_slurm_config( + *, + account: str | None, + partition: str | None, + time: str, + cpus: int, + mem_gb: int, + qos: str | None, + constraint: str | None, + job_name_prefix: str, +) -> SlurmConfig: + """Build a :class:`SlurmConfig`, using autodiscovery when *account* is ``None``. + + Parameters + ---------- + account : str or None + SLURM account. If ``None``, cluster autodiscovery is attempted. + partition : str or None + SLURM partition. Passed through to autodiscovery or defaults to + ``"cpu"`` when *account* is given explicitly. + time : str + Wall-clock time limit (e.g. ``"2h"``). + cpus : int + Number of CPUs per task (also used as *min_cpus* for autodiscovery). + mem_gb : int + Memory in GB (also used as *min_mem_gb* for autodiscovery). + qos : str or None + Quality-of-service string. + constraint : str or None + SLURM constraint string. + job_name_prefix : str + Prefix for SLURM job names. + + Returns + ------- + SlurmConfig + + Raises + ------ + ValueError + If autodiscovery fails and no *account* was provided. + + """ + if account is None: + try: + slurm_cfg = SlurmConfig.from_cluster( + needs_gpu=False, + min_cpus=cpus, + min_mem_gb=mem_gb, + time=time, + cpus_per_task=cpus, + mem_gb=mem_gb, + qos=qos, + constraint=constraint, + job_name_prefix=job_name_prefix, + **({"partition": partition} if partition is not None else {}), + ) + logger.info( + f"Using autodiscovered SLURM config: account={slurm_cfg.account}, " + f"partition={slurm_cfg.partition}" + ) + except RuntimeError as e: + raise ValueError( + f"SLURM autodiscovery failed: {e}\nPlease specify --account explicitly." + ) from e + else: + slurm_cfg = SlurmConfig( + account=account, + partition=partition or "cpu", + time=time, + cpus_per_task=cpus, + mem_gb=mem_gb, + qos=qos, + constraint=constraint, + job_name_prefix=job_name_prefix, + ) + return slurm_cfg + + sync_push_app = App(help="Push local systems or analyses into the database.") sync_app.command(sync_push_app, name="push") @@ -439,16 +816,7 @@ def sync_pull_systems( # File output - always includes all columns if output is not None: - output = output.resolve() - suffix = output.suffix.lower() - - if suffix == ".json": - df.to_json(output, orient="records", lines=True) - else: - # Default to CSV - df.to_csv(output, index=False) - - logger.success(f"Wrote {len(df)} record(s) to {output}") + _write_output(df, output) return # CLI output @@ -975,16 +1343,7 @@ def sync_pull_analysis( # File output if output is not None: - output = output.resolve() - suffix = output.suffix.lower() - - if suffix == ".json": - df.to_json(output, orient="records", lines=True) - else: - # Default to CSV - df.to_csv(output, index=False) - - logger.success(f"Wrote {len(df)} record(s) to {output}") + _write_output(df, output) return # CLI output @@ -1069,16 +1428,7 @@ def format_fn(x): # File output if output is not None: - output = output.resolve() - suffix = output.suffix.lower() - - if suffix == ".json": - df.to_json(output, orient="records", lines=True) - else: - # Default to CSV - df.to_csv(output, index=False) - - logger.success(f"Wrote {len(df)} record(s) to {output}") + _write_output(df, output) return # CLI output @@ -1153,7 +1503,7 @@ def analysis_run( skip_existing: bool = True, slurm: bool = False, account: str | None = None, - partition: str = "cpu", + partition: str | None = None, time: str = "2h", cpus: int = 4, mem_gb: int = 8, @@ -1187,8 +1537,6 @@ def analysis_run( if source is None: raise ValueError("Provide SOURCE as a simulation directory or build summary YAML.") - if slurm and account is None: - raise ValueError("--account is required when using --slurm.") if analysis_workers is not None and analysis_workers < 1: raise ValueError("--analysis-workers must be >= 1.") @@ -1233,11 +1581,11 @@ def analysis_run( print(result_df) return - slurm_cfg = SlurmConfig( - account=account or "", + slurm_cfg = _resolve_slurm_config( + account=account, partition=partition, time=time, - cpus_per_task=cpus, + cpus=cpus, mem_gb=mem_gb, qos=qos, constraint=constraint, @@ -1473,7 +1821,7 @@ def analysis_artifacts_run( skip_existing: bool = True, slurm: bool = False, account: str | None = None, - partition: str = "cpu", + partition: str | None = None, time: str = "2h", cpus: int = 4, mem_gb: int = 8, @@ -1520,19 +1868,17 @@ def analysis_artifacts_run( print(summary) return - if account is None: - raise ValueError("--account is required when using --slurm.") - - slurm_cfg = SlurmConfig( + slurm_cfg = _resolve_slurm_config( account=account, partition=partition, time=time, - cpus_per_task=cpus, + cpus=cpus, mem_gb=mem_gb, qos=qos, constraint=constraint, job_name_prefix=job_name_prefix, ) + if log_dir is None: log_dir = determine_log_dir(sim_paths) result_df = submit_artifacts_slurm( @@ -1682,6 +2028,190 @@ def analysis_remove( print(df) +# ─── search ───────────────────────────────────────────────────────────────────── + + +@app.command(name="search") +def search_simulations( + source: Annotated[Path, Parameter(help="Root directory to search for simulations.")], + *, + simulation_type: Annotated[ + str | None, + Parameter(name=["--type", "-t"], help="Filter by simulation type."), + ] = None, + status: Annotated[ + str | None, + Parameter(name=["--status", "-s"], help="Minimum status threshold."), + ] = None, + hash_prefix: Annotated[ + str | None, + Parameter(name=["--hash"], help="Filter by hash prefix."), + ] = None, + tag: Annotated[ + list[str] | None, + Parameter(name=["--tag"], help="Filter by tag (key=value). Repeatable."), + ] = None, + smiles: Annotated[ + str | None, + Parameter(name=["--smiles"], help="Filter by SMILES substructure match."), + ] = None, + trajectory_file: Annotated[ + str, Parameter(name=["--trajectory-file"], help="Trajectory filename.") + ] = "prod.xtc", + structure_file: Annotated[ + str, Parameter(name=["--structure-file"], help="Structure filename.") + ] = "system.pdb", + min_status: Annotated[ + str, Parameter(name=["--min-status"], help="Minimum status for discovery.") + ] = "build", +): + """Search and filter simulations in a directory tree. + + Parameters + ---------- + source : Path + Root directory to search for simulations. + simulation_type : str | None + Filter by simulation type (e.g., bilayer, mixedbox, lnp). + status : str | None + Minimum status threshold for results. + hash_prefix : str | None + Filter by hash prefix. + tag : list[str] | None + Tag filters as key=value pairs. Repeatable. + smiles : str | None + SMILES substructure to match against species. + trajectory_file : str + Trajectory filename for discovery. + structure_file : str + Structure filename for discovery. + min_status : str + Minimum status for discovery (default: build to find all sims). + + """ + from rich.console import Console + from rich.table import Table + + # Parse tag filters + tag_filters = None + if tag: + tag_filters = {} + for t in tag: + if "=" not in t: + print(f"Error: Invalid tag format '{t}'. Use key=value.") + raise SystemExit(1) + k, v = t.split("=", 1) + tag_filters[k] = v + + store = SimulationStore( + source.resolve(), + trajectory_file=trajectory_file, + structure_file=structure_file, + min_status=min_status, + ) + store.discover() + + try: + results = store.search( + simulation_type=simulation_type, + status=status, + hash_prefix=hash_prefix, + tags=tag_filters, + smiles=smiles, + ) + except (ValueError, ImportError) as e: + print(f"Error: {e}") + raise SystemExit(1) + + console = Console() + + if results.empty: + console.print("[yellow]No simulations found matching the filters.[/yellow]") + return + + table = Table(title=f"Found {len(results)} simulation(s)") + table.add_column("Hash", style="cyan", no_wrap=True) + table.add_column("Type", style="green") + table.add_column("Status", style="magenta") + table.add_column("Tags") + table.add_column("Path", style="dim") + + for _, row in results.iterrows(): + tags_str = "" + if row["tags"]: + tags_str = ", ".join(f"{k}={v}" for k, v in row["tags"].items()) + + table.add_row( + row["hash"][:12], + row["simulation_type"], + row["status"], + tags_str, + str(row["path"]), + ) + + console.print(table) + + +# ─── browse ────────────────────────────────────────────────────────────────────── + + +@app.command(name="browse") +def browse_simulations( + source: Annotated[Path, Parameter(help="Root directory to browse for simulations.")], + *, + trajectory_file: Annotated[ + str, Parameter(name=["--trajectory-file"], help="Trajectory filename.") + ] = "prod.xtc", + structure_file: Annotated[ + str, Parameter(name=["--structure-file"], help="Structure filename.") + ] = "system.pdb", + min_status: Annotated[ + str, Parameter(name=["--min-status"], help="Minimum status for discovery.") + ] = "build", +): + """Launch interactive TUI for browsing simulations. + + Requires the [tui] extra: pip install mdfactory[tui] + + Parameters + ---------- + source : Path + Root directory to browse for simulations. + trajectory_file : str + Trajectory filename for discovery. + structure_file : str + Structure filename for discovery. + min_status : str + Minimum status for discovery (default: build to find all sims). + + """ + try: + from .tui import _check_textual_available + + _check_textual_available() + except ImportError as e: + print(str(e)) + raise SystemExit(1) + + from loguru import logger as _logger + + from .tui.app import SimulationBrowser + + # Remove all loguru handlers — stderr output corrupts the TUI + _logger.remove() + + store = SimulationStore( + source.resolve(), + trajectory_file=trajectory_file, + structure_file=structure_file, + min_status=min_status, + ) + store.discover() + + browser = SimulationBrowser(store=store) + browser.run() + + config_app = App(help="Manage mdfactory configuration.") app.command(config_app, name="config") @@ -1750,6 +2280,131 @@ def config_edit(): subprocess.run([editor, str(config_path)], check=False) +@config_app.command(name="slurm") +def config_slurm(): + """Interactive wizard to configure a SLURM executor and save to YAML. + + Queries the local SLURM scheduler for available accounts, partitions, + and hardware, then walks through resource selection interactively. + The result is saved to a YAML file that can be reused with + ``mdfactory build --slurm ``. + + On non-SLURM machines, falls back to manual text entry. + """ + from mdfactory.orchestration.tui import UserCancelledError, configure_and_save_slurm + + try: + configure_and_save_slurm() + except UserCancelledError: + logger.info("SLURM configuration cancelled.") + + +@config_app.command(name="cluster") +def config_cluster( + json_output: Annotated[bool, Parameter("--json", help="Output in JSON format.")] = False, +): + """Show discovered SLURM cluster information. + + Queries the local SLURM scheduler and displays available partitions, + accounts, and QOS policies. Useful for verifying autodiscovery works + and understanding cluster resources before submitting jobs. + + On non-SLURM machines, prints a helpful message instead of failing. + """ + import json as json_module + + from mdfactory.performance.cluster import discover_cluster + + cluster = discover_cluster() + + if cluster is None: + if json_output: + print(json_module.dumps({"error": "SLURM not available", "cluster": None})) + else: + print("SLURM cluster not detected.") + print() + print("This machine does not appear to be a SLURM cluster node,") + print("or SLURM commands (sinfo, sacctmgr) are not in PATH.") + print() + print("To use SLURM submission, run this command on a cluster login node.") + return + + if json_output: + # Build JSON-serializable structure + data = { + "default_account": cluster.default_account, + "accounts": cluster.accounts, + "qos_policies": cluster.qos_policies, + "partitions": [ + { + "name": p.name, + "state": p.state, + "is_default": p.is_default, + "max_time": p.max_time, + "default_time": p.default_time, + "total_nodes": p.total_nodes, + "node_types": [ + { + "cpus": nt.cpus, + "memory_mb": nt.memory_mb, + "gpu_specs": [ + {"count": count, "type": gtype} for count, gtype in nt.gpu_specs + ], + "features": list(nt.features), + "count": nt.count, + } + for nt in p.node_types + ], + } + for p in cluster.partitions + ], + } + print(json_module.dumps(data, indent=2)) + return + + # Human-readable output + print("SLURM Cluster Information") + print("=" * 50) + print() + + # Account info + print(f"Default Account: {cluster.default_account or '(none)'}") + if cluster.accounts: + print(f"Available Accounts: {', '.join(cluster.accounts)}") + print() + + # QOS info + if cluster.qos_policies: + print(f"QOS Policies: {', '.join(cluster.qos_policies)}") + print() + + # Partition info + print("Partitions:") + print("-" * 50) + for partition in cluster.partitions: + default_marker = " (default)" if partition.is_default else "" + state_marker = f" [{partition.state}]" if partition.state != "up" else "" + print(f"\n {partition.name}{default_marker}{state_marker}") + print(f" Nodes: {partition.total_nodes}") + print(f" Max Time: {partition.max_time}") + if partition.default_time != partition.max_time: + print(f" Default Time: {partition.default_time}") + + # Summarize node types + for nt in partition.node_types: + gpu_info = "" + if nt.gpu_specs: + # Format multiple GPU types like "7x b200, 7x 1g.23gb" + gpu_parts = [f"{count}x {gtype or 'unknown'}" for count, gtype in nt.gpu_specs] + gpu_info = f", {', '.join(gpu_parts)}" + mem_gb = nt.memory_mb // 1024 + features_str = "" + if nt.features: + features_str = f" [{', '.join(nt.features)}]" + count_str = f" ({nt.count} node{'s' if nt.count != 1 else ''})" + print(f" - {nt.cpus} CPUs, {mem_gb} GB{gpu_info}{features_str}{count_str}") + + def main(): app() diff --git a/mdfactory/models/input.py b/mdfactory/models/input.py index e24497a..9fe6613 100644 --- a/mdfactory/models/input.py +++ b/mdfactory/models/input.py @@ -32,11 +32,14 @@ class BuildInput(BaseModel): None, description="Parametrization-specific configuration. If None, uses defaults." ) engine: Literal["gromacs"] = Field("gromacs", description="MD engine.") + tags: dict[str, str] | None = Field( + None, description="User-defined metadata tags, excluded from hash." + ) @property def hash(self): """Return a SHA-1 hash of the full JSON representation.""" - json_repr = self.model_dump_json() + json_repr = self.model_dump_json(exclude={"tags"}) return hashlib.sha1(json_repr.encode("UTF-8")).hexdigest().upper() @property @@ -84,6 +87,7 @@ def metadata(self) -> dict[str, Any]: "species_composition": species_composition, "system_specific": system_specific, "build_input_json": self.model_dump_json(), + "tags": self.tags, } def to_data_row(self) -> dict[str, Any]: diff --git a/mdfactory/orchestration/__init__.py b/mdfactory/orchestration/__init__.py new file mode 100644 index 0000000..d2b7058 --- /dev/null +++ b/mdfactory/orchestration/__init__.py @@ -0,0 +1,15 @@ +# ABOUTME: Public API for Parsl-based parallel build orchestration +# ABOUTME: Exports executor configs and build dispatch functions +"""Parsl-based parallel build orchestration for mdfactory.""" + +from .build import build_systems +from .config import ExecutorConfig, SlurmExecutorConfig +from .tui import configure_and_save_slurm, configure_slurm_interactive + +__all__ = [ + "ExecutorConfig", + "SlurmExecutorConfig", + "build_systems", + "configure_and_save_slurm", + "configure_slurm_interactive", +] diff --git a/mdfactory/orchestration/apps.py b/mdfactory/orchestration/apps.py new file mode 100644 index 0000000..3de3e95 --- /dev/null +++ b/mdfactory/orchestration/apps.py @@ -0,0 +1,68 @@ +# ABOUTME: Parsl application definitions for build orchestration +# ABOUTME: Wraps run_build_from_dict as a @python_app with runtime decoration +"""Parsl application definitions for build orchestration.""" + + +def _build_system_impl(build_input_dict: dict) -> dict: + """Run a single system build inside a Parsl worker. + + All heavy imports (OpenMM, MDAnalysis, OpenFF) happen inside the + worker process — only the plain dict crosses the serialization boundary. + + Parameters + ---------- + build_input_dict : dict + Serialized BuildInput as a plain dictionary. May contain a special + ``_build_dir`` key specifying the output directory. + + Returns + ------- + dict + Result dictionary with hash, status, and directory. + + """ + import os + from pathlib import Path + + from mdfactory.models.input import BuildInput + from mdfactory.workflows import run_build_from_dict + + # Extract and remove internal keys before validation + input_copy = {k: v for k, v in build_input_dict.items() if not k.startswith("_")} + model = BuildInput(**input_copy) + build_dir = Path(build_input_dict.get("_build_dir", model.hash)) + build_dir.mkdir(parents=True, exist_ok=True) + original_dir = os.getcwd() + try: + os.chdir(build_dir) + run_build_from_dict(model) + finally: + os.chdir(original_dir) + return {"hash": model.hash, "status": "success", "directory": str(build_dir.resolve())} + + +def get_build_app(): + """Create and return the Parsl python_app for building systems. + + The ``@python_app`` decorator is applied here (not at module level) + because it requires an active Parsl DataFlowKernel. + + Returns + ------- + callable + A Parsl ``@python_app`` wrapping the build implementation. + + Raises + ------ + ImportError + If parsl is not installed. + + """ + try: + from parsl import python_app # type: ignore[import-not-found] + except ImportError as exc: + raise ImportError( + "parsl is required for build orchestration. " + "Install with `pip install 'mdfactory[parsl]'`." + ) from exc + return python_app(_build_system_impl) diff --git a/mdfactory/orchestration/build.py b/mdfactory/orchestration/build.py new file mode 100644 index 0000000..889a16c --- /dev/null +++ b/mdfactory/orchestration/build.py @@ -0,0 +1,386 @@ +# ABOUTME: Build orchestration dispatch via Parsl +# ABOUTME: Submits parallel builds and provides dry-run preview +"""Build orchestration via Parsl.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from loguru import logger + +from mdfactory.models.input import BuildInput + +from .apps import get_build_app +from .session import ( + _get_slurm_job_ids, + _scancel_jobs, + _shutdown_parsl, + parsl_session, +) + +if TYPE_CHECKING: + from .config import ExecutorConfig + +# Re-exported for backward compatibility — the canonical home is ``session``. +__all__ = [ + "build_systems", + "parsl_session", + "_shutdown_parsl", + "_get_slurm_job_ids", + "_scancel_jobs", +] + + +def build_systems( + build_inputs: list, + config: "ExecutorConfig", + *, + output_dir: Path | None = None, + wait: bool = True, + dry_run: bool = False, +) -> list: + """Submit parallel builds via Parsl (or preview with dry_run). + + Parameters + ---------- + build_inputs : list + List of BuildInput models or dicts. + config : ExecutorConfig + Executor configuration for Parsl. + output_dir : Path, optional + Base output directory for builds. Defaults to current directory. + wait : bool, optional + Whether to wait for all futures to complete. Default True. + If False, returns raw AppFutures and the caller is responsible + for calling ``parsl.clear()`` after all futures complete. + dry_run : bool, optional + If True, log what would be built and return descriptions without + loading Parsl or submitting any work. Default False. + + Returns + ------- + list + If dry_run=True, list of description dicts. + If wait=True, list of result dicts. + If wait=False, list of AppFutures. + + """ + output_dir = Path(output_dir) if output_dir else Path.cwd() + + # Resolve all inputs upfront + resolved: list[tuple[BuildInput, dict]] = [] + for inp in build_inputs: + if isinstance(inp, BuildInput): + model = inp + input_dict = inp.model_dump() + elif isinstance(inp, dict): + model = BuildInput(**inp) + input_dict = inp.copy() + else: + raise TypeError(f"Expected BuildInput or dict, got {type(inp)}") + input_dict["_build_dir"] = str(output_dir / model.hash) + resolved.append((model, input_dict)) + + # Dry-run: log plan and return without loading Parsl + if dry_run: + descriptions = [] + for model, _ in resolved: + desc = { + "hash": model.hash, + "simulation_type": model.simulation_type, + "parametrization": model.parametrization, + "engine": model.engine, + "output_directory": str(output_dir / model.hash), + } + descriptions.append(desc) + logger.info( + f"[dry-run] {model.hash} | {model.simulation_type} | " + f"{model.parametrization} | {model.engine} -> {output_dir / model.hash}" + ) + logger.info(f"[dry-run] {len(descriptions)} system(s) would be built") + logger.info(f"[dry-run] Provider: {config.provider}") + return descriptions + + # parsl_session owns the full DFK lifecycle: guard, load, and shutdown + # (including scancel of lingering SLURM jobs) on exit. + with parsl_session(config) as session: + build_app = get_build_app() + + # Submit all builds + futures = [] + input_hashes = [] + for model, input_dict in resolved: + input_hashes.append(model.hash) + futures.append(build_app(input_dict)) + + logger.info(f"Submitted {len(futures)} build(s) to Parsl") + + if not wait: + logger.warning("Returning raw futures — caller must call parsl.clear() when done.") + session.detach() + return futures + + # Poll futures with live status reporting + return _wait_with_progress(futures, hashes=input_hashes, label="Parsl Builds") + + +def _describe_failure(exc: BaseException) -> tuple[str, str]: + """Extract ``(failure_type, error_detail)`` from a future's exception. + + Parsl surfaces worker errors differently across versions: modern Parsl + re-raises the *original* exception (via ``RemoteExceptionWrapper.reraise()``), + while older versions wrapped it and exposed the underlying error on + ``.e_value``. Unwrap defensively so callers always see the *underlying* + error type — letting future retry logic distinguish, e.g., a GROMACS crash + (``CalledProcessError``) from an infrastructure failure (OOM / preemption). + + Parameters + ---------- + exc : BaseException + The exception raised by ``future.result()``. + + Returns + ------- + tuple[str, str] + ``(failure_type, error_detail)`` — the underlying exception's class + name and its string representation. + + """ + underlying = getattr(exc, "e_value", None) or exc + return type(underlying).__name__, str(underlying) + + +def _collect_results(results: list, hashes: list[str]) -> list[dict]: + """Return all captured results, raising if any slot is still uncaptured. + + The polling loop only exits once every future has been recorded, so a + ``None`` slot here signals an internal bug rather than a normal outcome. + Failing explicitly is safer than silently returning a shorter list than + the caller submitted (which would corrupt any ``len()``-based bookkeeping). + + Parameters + ---------- + results : list + Per-future result slots; ``None`` marks an uncaptured future. + hashes : list[str] + Display hashes aligned with ``results``, used for the error message. + + Returns + ------- + list[dict] + The complete list of result dicts. + + Raises + ------ + RuntimeError + If one or more result slots were never captured. + + """ + missing = [hashes[i] for i, r in enumerate(results) if r is None] + if missing: + raise RuntimeError( + f"{len(missing)} build result(s) were never captured " + f"({', '.join(h[:12] for h in missing)}); the polling loop exited " + "prematurely. This is an internal error." + ) + return list(results) + + +def _wait_with_progress( + futures: list, + *, + hashes: list[str] | None = None, + label: str = "Parsl Builds", + poll_interval: float = 2.0, + stall_timeout: float = 600.0, +) -> list[dict]: + """Wait for futures with a live terminal progress display. + + Shows a progress bar with summary counts and a scrolling activity log + of recent completions/failures. Scales to any number of builds. + + Parameters + ---------- + futures : list + List of Parsl AppFutures. + hashes : list[str], optional + Known hashes for each build (displayed in activity log). + label : str + Heading shown next to the progress bar. Defaults to ``"Parsl Builds"``; + pass e.g. ``"Simulations"`` to reuse this display for other workflows. + poll_interval : float + Seconds between status polls. + stall_timeout : float + Seconds without any new future completing before a warning is logged. + Defaults to 600 (10 minutes). Only one warning is emitted per stall + period; the timer resets when progress resumes. + + Returns + ------- + list[dict] + Result dicts for each future. + + """ + import time + + from rich.console import Console, Group + from rich.live import Live + from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn + from rich.text import Text + + total = len(futures) + results: list[dict | None] = [None] * total + display_hashes = hashes or [f"build-{i}" for i in range(total)] + console = Console() + + # Track counts + succeeded = 0 + failed = 0 + # Activity log (last N events) + max_activity = 12 + activity: list[Text] = [] + + # Progress bar + progress = Progress( + TextColumn(f"⚒ [bold]{label}[/]"), + BarColumn(bar_width=40), + MofNCompleteColumn(), + TextColumn("·"), + TextColumn("[green]{task.fields[succeeded]} ✓[/]"), + TextColumn("[red]{task.fields[failed]} ✗[/]"), + TextColumn("[yellow]{task.fields[running]} ●[/]"), + console=console, + transient=False, + ) + task_id = progress.add_task("builds", total=total, succeeded=0, failed=0, running=0) + + def _get_block_status() -> str: + """Query Parsl for SLURM block (job) statuses.""" + try: + import parsl # type: ignore[import-not-found] + + dfk = parsl.dfk() + counts: dict[str, int] = {} + for executor in dfk.executors.values(): + if not hasattr(executor, "status"): + continue + block_statuses = executor.status() + for _block_id, job_status in block_statuses.items(): + state = str(job_status.state.name).lower() + counts[state] = counts.get(state, 0) + 1 + if not counts: + return "" + parts = [] + if counts.get("running", 0): + parts.append(f"[green]{counts['running']} running[/]") + if counts.get("pending", 0): + parts.append(f"[yellow]{counts['pending']} pending[/]") + if counts.get("completed", 0): + parts.append(f"[dim]{counts['completed']} completed[/]") + if counts.get("failed", 0): + parts.append(f"[red]{counts['failed']} failed[/]") + # Catch-all for other states + for state, count in counts.items(): + if state not in ("running", "pending", "completed", "failed"): + parts.append(f"[dim]{count} {state}[/]") + return " · ".join(parts) + except Exception: + return "" + + def _render(): + done_count = succeeded + failed + running = sum(1 for i, f in enumerate(futures) if results[i] is None and _is_running(f)) + progress.update( + task_id, + completed=done_count, + succeeded=succeeded, + failed=failed, + running=running, + ) + # Combine progress bar + SLURM status + activity log + parts = [progress] + block_info = _get_block_status() + if block_info: + parts.append(Text.from_markup(f" ▸ SLURM: {block_info}")) + if activity: + parts.append(Text("")) # blank line + for line in activity[-max_activity:]: + parts.append(line) + return Group(*parts) + + def _is_running(future) -> bool: + try: + status = future.task_status() + return status in ("launched", "running", "running_ended") + except Exception: + return False + + # Stall detection: warn (once) when no future completes for stall_timeout seconds + last_progress_time = time.time() + stall_warned = False + + try: + with Live(_render(), console=console, refresh_per_second=2) as live: + while True: + prev_done = succeeded + failed + done_count = 0 + for i, future in enumerate(futures): + if results[i] is not None: + done_count += 1 + continue + if future.done(): + try: + result = future.result() + results[i] = result + succeeded += 1 + line = Text() + line.append(" ✓ ", style="bold green") + line.append(display_hashes[i][:12], style="cyan") + line.append(" done", style="dim") + activity.append(line) + except Exception as exc: + failure_type, error_detail = _describe_failure(exc) + results[i] = { + "hash": display_hashes[i], + "status": "failed", + "error": error_detail, + "failure_type": failure_type, + "error_detail": error_detail, + } + failed += 1 + line = Text() + line.append(" ✗ ", style="bold red") + line.append(display_hashes[i][:12], style="cyan") + line.append(f" {error_detail}", style="red") + activity.append(line) + done_count += 1 + + # Stall detection + new_done = succeeded + failed + if new_done > prev_done: + last_progress_time = time.time() + stall_warned = False + elif not stall_warned and time.time() - last_progress_time > stall_timeout: + remaining = total - new_done + elapsed = time.time() - last_progress_time + logger.warning( + f"No progress for {elapsed:.0f}s — " + f"{remaining} future(s) still pending. " + "Use Ctrl+C to abort if stuck." + ) + stall_warned = True + + live.update(_render()) + + if done_count == total: + break + + time.sleep(poll_interval) + + except KeyboardInterrupt: + console.print("\n[bold yellow]Interrupted — cancelling SLURM jobs...[/]") + # Don't call _shutdown_parsl() here — build_systems' finally block owns cleanup + raise + + return _collect_results(results, display_hashes) diff --git a/mdfactory/orchestration/config.py b/mdfactory/orchestration/config.py new file mode 100644 index 0000000..aeb1019 --- /dev/null +++ b/mdfactory/orchestration/config.py @@ -0,0 +1,300 @@ +# ABOUTME: Pydantic models for Parsl executor configuration +# ABOUTME: Supports local and SLURM providers with YAML serialization +"""Executor configuration models for Parsl workflows.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal + +import yaml +from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator + +from mdfactory.performance.slurm_config import BaseSlurmConfig + +if TYPE_CHECKING: + import parsl + + +def _import_parsl(): + """Import parsl with a clear error message if not installed. + + Returns + ------- + module + The parsl module. + + Raises + ------ + ImportError + If parsl is not installed. + + """ + try: + import parsl # type: ignore[import-not-found] + except ImportError as exc: + raise ImportError( + "parsl is required for build orchestration. " + "Install with `pip install 'mdfactory[parsl]'`." + ) from exc + return parsl + + +class ExecutorConfig(BaseModel): + """Base executor configuration for Parsl workflows. + + Parameters + ---------- + provider : str + Execution provider type ("local" or "slurm"). + worker_init : str + Shell commands to run before starting workers (module loads, activation). + working_directory : Path or None + Working directory for the executor. + max_workers_per_node : int + Maximum number of parallel tasks per compute node. + max_blocks : int + Maximum number of execution blocks (for local: process groups). + available_accelerators : int or list[str] + GPU pinning for Parsl workers. An integer count (or explicit list of + device IDs) makes Parsl assign each worker a distinct accelerator and + set ``CUDA_VISIBLE_DEVICES`` accordingly. Defaults to ``0`` (no pinning, + all workers share the node's uncontrolled GPU context). Set this when + running ``max_workers_per_node > 1`` on GPU nodes to avoid silent + wrong-GPU contention. + run_dir : Path + Directory where Parsl writes its ``runinfo`` logs and database. + Defaults to ``~/.parsl/mdfactory`` so logs land in a predictable, + controllable location instead of scattering ``runinfo/`` into the + current working directory (typically the login-node home on HPC). + + """ + + provider: Literal["local", "slurm"] = "local" + worker_init: str = "" + working_directory: Path | None = None + max_workers_per_node: int = 1 + max_blocks: int = 1 + available_accelerators: int | list[str] = 0 + run_dir: Path = Field(default_factory=lambda: Path("~/.parsl/mdfactory").expanduser()) + + @field_validator("run_dir", mode="before") + @classmethod + def _expand_run_dir(cls, v: Any) -> Any: + """Expand ``~`` in user-supplied run_dir values.""" + return Path(v).expanduser() if v is not None else v + + @field_serializer("run_dir", "working_directory") + def _serialize_paths(self, v: Path | None) -> str | None: + """Serialize Path fields as plain strings for YAML compatibility.""" + return str(v) if v is not None else None + + def to_parsl_config(self) -> "parsl.Config": + """Build a Parsl Config with HighThroughputExecutor + LocalProvider. + + Returns + ------- + parsl.Config + Configured Parsl Config object. + + """ + parsl = _import_parsl() + from parsl.executors import HighThroughputExecutor + from parsl.providers import LocalProvider + + provider = LocalProvider( + worker_init=self.worker_init, + min_blocks=0, + init_blocks=1, + max_blocks=self.max_blocks, + parallelism=1, + ) + executor = HighThroughputExecutor( + label="local", + provider=provider, + max_workers_per_node=self.max_workers_per_node, + available_accelerators=self.available_accelerators, + working_dir=str(self.working_directory) if self.working_directory else None, + ) + return parsl.Config(executors=[executor], run_dir=str(self.run_dir)) + + @classmethod + def from_yaml(cls, path: Path) -> "ExecutorConfig | SlurmExecutorConfig": + """Load executor configuration from a YAML file. + + Parameters + ---------- + path : Path + Path to the YAML configuration file. + + Returns + ------- + ExecutorConfig or SlurmExecutorConfig + The appropriate config model based on the ``provider`` field. + + """ + with open(path) as f: + data = yaml.safe_load(f) + if not isinstance(data, dict): + raise ValueError( + f"Executor config YAML is empty or invalid (expected a mapping): {path}" + ) + provider = data.get("provider", "local") + if provider == "slurm": + return SlurmExecutorConfig(**data) + return cls(**data) + + +class SlurmExecutorConfig(ExecutorConfig, BaseSlurmConfig): + """SLURM executor configuration for Parsl workflows. + + Inherits the four cross-cutting SLURM fields (``account``, ``partition``, + ``qos``, ``constraint``) and the authoritative ``from_cluster()`` factory + from :class:`~mdfactory.performance.slurm_config.BaseSlurmConfig`, and the + Parsl executor fields (``run_dir``, ``available_accelerators``, …) from + :class:`ExecutorConfig`. Only Parsl/SLURM-job-specific fields are declared + here. + + Field names mirror sbatch flags where possible. The only Parsl-specific + field is ``max_workers_per_node`` (number of parallel tasks per node). + + Notes + ----- + ``BaseSlurmConfig`` is frozen (immutable), but executor configs are mutated + in places (e.g. the TUI wizard), so this subclass explicitly sets + ``frozen=False`` to match :class:`ExecutorConfig`'s mutable behaviour. + + Parameters + ---------- + account : str + SLURM account (``--account``). + partition : str + SLURM partition (``--partition``). + walltime : str + Wall-clock time limit (``--time``). + nodes : int + Number of nodes per SLURM job (``--nodes``). + cpus_per_node : int + CPUs requested per node. Wired to Parsl ``SlurmProvider(cores_per_node=...)``, + which Parsl uses to size the worker pool (roughly ``--ntasks`` / + cores-per-node) — it is **not** the sbatch ``--cpus-per-task`` flag. For + MPI+OpenMP workloads (e.g. GROMACS), OpenMP threads-per-rank must be set + separately via ``scheduler_options`` / ``worker_init``. + gres : str or None + Generic resource specification (``--gres``), e.g. ``"gpu:l40s:1"``. + mem : str or None + Memory per node (``--mem``), e.g. ``"32G"``. + qos : str or None + Quality of service (``--qos``). + constraint : str or None + Node feature constraint (``--constraint``). + max_blocks : int + Maximum number of simultaneous SLURM jobs. Each block is one + SLURM job; set this to control how many run in parallel. + scheduler_options : str + Additional raw ``#SBATCH`` lines for anything not covered above + (allocation-level flags injected into the job script). + launch_options : str + Extra ``srun`` flags forwarded to Parsl's ``SrunLauncher(overrides=...)`` + for task-placement / binding control (e.g. + ``"--cpu-bind=cores --distribution=block:block"`` for NUMA-local CPU + binding in MPI+OpenMP workers). Empty string leaves Parsl's default + launcher untouched. + + Examples + -------- + .. code-block:: yaml + + provider: slurm + account: hpc_chem + partition: gpu + walltime: "4:00:00" + nodes: 1 + cpus_per_node: 12 + gres: "gpu:l40s:1" + max_blocks: 5 + max_workers_per_node: 1 + worker_init: | + eval "$(pixi shell-hook -e default)" + + """ + + model_config = ConfigDict(frozen=False) + + provider: Literal["slurm"] = "slurm" + # account, partition, qos, constraint inherited from BaseSlurmConfig. + # from_cluster() inherited from BaseSlurmConfig — extra fields below are + # forwarded to the constructor via resolve_slurm_fields(). + walltime: str = Field(default="2h", validate_default=True) + nodes: int = 1 + cpus_per_node: int = 12 + gres: str | None = None + mem: str | None = None + scheduler_options: str = "" + launch_options: str = "" + + @field_validator("walltime", mode="before") + @classmethod + def _normalize_walltime(cls, v: str) -> str: + """Normalize walltime string on construction.""" + from mdfactory.performance.slurm_config import normalize_slurm_time + + return normalize_slurm_time(v) + + def to_parsl_config(self) -> "parsl.Config": + """Build a Parsl Config with HighThroughputExecutor + SlurmProvider. + + Returns + ------- + parsl.Config + Configured Parsl Config object. + + """ + parsl = _import_parsl() + from parsl.executors import HighThroughputExecutor + from parsl.providers import SlurmProvider + + # Build scheduler_options from structured fields + raw extras + opts = [] + if self.gres: + opts.append(f"#SBATCH --gres={self.gres}") + if self.mem: + opts.append(f"#SBATCH --mem={self.mem}") + if self.qos: + opts.append(f"#SBATCH --qos={self.qos}") + if self.constraint: + opts.append(f"#SBATCH --constraint={self.constraint}") + if self.scheduler_options: + opts.append(self.scheduler_options) + scheduler_options = "\n".join(opts) + + # Only override Parsl's default launcher when srun-level flags are given, + # so NUMA/task-placement binding is opt-in (relevant for MPI+OpenMP work). + provider_kwargs: dict[str, Any] = {} + if self.launch_options: + from parsl.launchers import SrunLauncher + + provider_kwargs["launcher"] = SrunLauncher(overrides=self.launch_options) + + provider = SlurmProvider( + account=self.account, + partition=self.partition, + walltime=self.walltime, + nodes_per_block=self.nodes, + cores_per_node=self.cpus_per_node, + worker_init=self.worker_init, + scheduler_options=scheduler_options, + min_blocks=0, + init_blocks=1, + max_blocks=self.max_blocks, + parallelism=1, + **provider_kwargs, + ) + executor = HighThroughputExecutor( + label="slurm", + provider=provider, + max_workers_per_node=self.max_workers_per_node, + available_accelerators=self.available_accelerators, + working_dir=str(self.working_directory) if self.working_directory else None, + ) + return parsl.Config(executors=[executor], run_dir=str(self.run_dir)) diff --git a/mdfactory/orchestration/session.py b/mdfactory/orchestration/session.py new file mode 100644 index 0000000..8c8a531 --- /dev/null +++ b/mdfactory/orchestration/session.py @@ -0,0 +1,180 @@ +# ABOUTME: Generic Parsl session lifecycle management (guard, load, shutdown) +# ABOUTME: Reusable context manager shared by build/simulation/benchmark orchestration +"""Parsl session management. + +Provides :func:`parsl_session`, a context manager that owns the full Parsl +``DataFlowKernel`` lifecycle — guarding against an already-active DFK, loading +the executor config, and guaranteeing shutdown (including ``scancel`` of any +lingering SLURM jobs) on exit. Build, simulation, and benchmark orchestration +all share this single implementation rather than duplicating the guard / load / +cleanup boilerplate. +""" + +from __future__ import annotations + +import subprocess +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from loguru import logger + +from .config import _import_parsl + +if TYPE_CHECKING: + from .config import ExecutorConfig + + +@dataclass +class ParslSession: + """Handle yielded by :func:`parsl_session`. + + Parameters + ---------- + parsl : module + The imported ``parsl`` module, for submitting apps or inspecting the DFK. + detached : bool + When ``True``, the context manager will **not** shut down the DFK on + exit; the caller takes ownership of cleanup. Set via :meth:`detach`. + + """ + + parsl: object + detached: bool = False + + def detach(self) -> None: + """Transfer DFK ownership to the caller (skip shutdown on exit). + + Used when returning raw futures (e.g. ``wait=False``): the caller is + then responsible for calling ``parsl.clear()`` once all futures + complete. + """ + self.detached = True + + +@contextmanager +def parsl_session(config: "ExecutorConfig") -> Iterator[ParslSession]: + """Manage a Parsl ``DataFlowKernel`` lifecycle for an orchestration run. + + Guards against an already-active DFK, loads ``config``'s Parsl config, + yields a :class:`ParslSession`, and guarantees shutdown on exit unless the + session was detached via :meth:`ParslSession.detach`. + + Examples + -------- + >>> with parsl_session(config) as session: # doctest: +SKIP + ... futures = [app(x) for x in inputs] + ... results = wait(futures) + + Parameters + ---------- + config : ExecutorConfig + Executor configuration whose ``to_parsl_config()`` drives the DFK. + + Yields + ------ + ParslSession + Session handle exposing the ``parsl`` module and a ``detach()`` escape + hatch. + + Raises + ------ + RuntimeError + If a Parsl ``DataFlowKernel`` is already active. + + """ + parsl = _import_parsl() + _guard_no_active_dfk(parsl) + parsl.load(config.to_parsl_config()) + session = ParslSession(parsl=parsl) + try: + yield session + finally: + if not session.detached: + _shutdown_parsl() + + +def _guard_no_active_dfk(parsl) -> None: + """Raise if a Parsl ``DataFlowKernel`` is already loaded. + + Parameters + ---------- + parsl : module + The imported ``parsl`` module. + + Raises + ------ + RuntimeError + If a DFK is already active. + + """ + try: + parsl.dfk() + except Exception: + return # No active DFK, good + raise RuntimeError( + "A Parsl DataFlowKernel is already active. " + "Call parsl.clear() before starting a new session." + ) + + +def _shutdown_parsl(): + """Shut down Parsl and cancel any remaining SLURM jobs.""" + try: + import parsl # type: ignore[import-not-found] + except ImportError: + return + + job_ids = [] + try: + dfk = parsl.dfk() + job_ids = _get_slurm_job_ids(dfk) + except Exception as exc: + logger.debug(f"Could not query Parsl DFK for SLURM jobs: {exc}") + + try: + parsl.clear() + except Exception as exc: + logger.warning(f"Parsl shutdown failed — DFK may still be loaded: {exc}") + + # Always attempt scancel if we found job IDs + if job_ids: + _scancel_jobs(job_ids) + + +def _get_slurm_job_ids(dfk) -> list[str]: + """Extract active SLURM job IDs from the Parsl DataFlowKernel.""" + job_ids = [] + try: + for executor in dfk.executors.values(): + if hasattr(executor, "provider") and hasattr(executor.provider, "resources"): + for _block_id, resource in executor.provider.resources.items(): + job_id = resource.get("remote_job_id") or resource.get("job_id") + if job_id: + job_ids.append(str(job_id)) + except Exception as exc: + logger.debug(f"Could not enumerate SLURM jobs for explicit scancel: {exc}") + return job_ids + + +def _scancel_jobs(job_ids: list[str]): + """Run scancel on the given SLURM job IDs.""" + if not job_ids: + return + try: + cmd = ["scancel"] + job_ids + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10, check=False) + if result.returncode == 0: + logger.info(f"Cancelled SLURM jobs: {', '.join(job_ids)}") + else: + logger.debug(f"scancel stderr: {result.stderr.strip()}") + except FileNotFoundError: + logger.debug("scancel not found (not on SLURM cluster)") + except subprocess.TimeoutExpired: + logger.warning( + f"scancel timed out after 10 s — SLURM jobs {', '.join(job_ids)} may still " + f"be running. Run `scancel {' '.join(job_ids)}` manually." + ) + except Exception as exc: + logger.debug(f"scancel failed: {exc}") diff --git a/mdfactory/orchestration/tui.py b/mdfactory/orchestration/tui.py new file mode 100644 index 0000000..954df74 --- /dev/null +++ b/mdfactory/orchestration/tui.py @@ -0,0 +1,589 @@ +# ABOUTME: Interactive SLURM configuration wizard using questionary. +# ABOUTME: Leverages cluster autodiscovery to guide users through executor setup. +"""Interactive SLURM configuration wizard. + +Provides a terminal-based wizard that queries the local SLURM cluster +(via :func:`~mdfactory.performance.cluster.discover_cluster`) and guides +the user through selecting accounts, partitions, and resource limits. +The result is a :class:`~mdfactory.orchestration.config.SlurmExecutorConfig` +that can be saved as YAML and used with Parsl-based workflows. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml +from rich.console import Console + +from mdfactory.orchestration.config import SlurmExecutorConfig +from mdfactory.performance.cluster import ClusterInfo, Partition, discover_cluster + +console = Console() + + +def _import_questionary(): + """Import questionary with a clear error message if not installed. + + ``questionary`` ships in the ``[parsl]`` optional extra. Importing it + lazily (rather than at module level) means ``import mdfactory.orchestration`` + succeeds for users who never touch the TUI, matching the lazy-import + pattern used for ``parsl`` and ``rich``. + + Returns + ------- + module + The questionary module. + + Raises + ------ + ImportError + If questionary is not installed. + + """ + try: + import questionary + except ImportError as exc: + raise ImportError( + "questionary is required for the SLURM TUI. " + "Install with: pip install 'mdfactory[parsl]'" + ) from exc + return questionary + + +def _default_worker_init() -> str: + """Auto-detect the pixi environment and return an appropriate worker_init. + + On HPC clusters with a shared filesystem, workers need the same pixi + environment as the submitting process. This function locates the + project root via ``mdfactory.__file__`` and returns a pixi shell-hook + command if the environment exists. + + Returns + ------- + str + A shell command to activate the pixi environment, or ``""`` if + no pixi environment is detected. + """ + try: + import mdfactory as _mdf + + project_root = Path(_mdf.__file__).parent.parent + pixi_env = project_root / ".pixi" / "envs" / "default" + if pixi_env.exists(): + return f'eval "$(pixi shell-hook --manifest-path {project_root} -e default)"' + except Exception: + pass + return "" + + +class UserCancelledError(Exception): + """Raised when the user cancels an interactive prompt.""" + + +def _require(value: str | None, label: str) -> str: + """Return *value* or raise if the user cancelled the prompt. + + Parameters + ---------- + value : str or None + Return value from a ``questionary`` ``.ask()`` call. + label : str + Human-readable label used in the error message. + + Returns + ------- + str + The validated, non-None value. + + Raises + ------ + UserCancelledError + If *value* is None (user pressed Ctrl-C / Ctrl-D). + """ + if value is None: + raise UserCancelledError(f"Prompt cancelled at: {label}") + return value + + +# --------------------------------------------------------------------------- +# Partition / node helpers +# --------------------------------------------------------------------------- + + +def _format_node_type_summary(partition: Partition) -> str: + """Build a one-line summary of node hardware for a partition label. + + Parameters + ---------- + partition : Partition + Partition whose node types to summarise. + + Returns + ------- + str + Human-readable summary, e.g. ``"96 cpu, 512 GB, 4×a100"`` + """ + parts: list[str] = [] + for nt in partition.node_types: + items = [f"{nt.cpus} cpu", f"{nt.memory_mb // 1024} GB"] + for count, gpu_type in nt.gpu_specs: + gpu_label = f"{count}×{gpu_type}" if gpu_type else f"{count}×gpu" + items.append(gpu_label) + parts.append(", ".join(items)) + return " | ".join(parts) + + +def _partition_has_gpus(partition: Partition) -> bool: + """Check whether any node type in *partition* has GPUs.""" + return any(len(nt.gpu_specs) > 0 for nt in partition.node_types) + + +def _suggest_gres(partition: Partition) -> str: + """Suggest a ``--gres`` string from the first GPU-equipped node type. + + Parameters + ---------- + partition : Partition + Selected partition. + + Returns + ------- + str + Suggested GRES string, e.g. ``"gpu:a100:1"``, or empty string. + """ + for nt in partition.node_types: + for count, gpu_type in nt.gpu_specs: + if gpu_type: + return f"gpu:{gpu_type}:1" + return "gpu:1" + return "" + + +def _suggest_mem(partition: Partition) -> str: + """Suggest a ``--mem`` value from the first node type. + + Parameters + ---------- + partition : Partition + Selected partition. + + Returns + ------- + str + Suggested memory string in GB, e.g. ``"64G"``. + """ + if partition.node_types: + mem_gb = partition.node_types[0].memory_mb // 1024 + return f"{mem_gb}G" + return "16G" + + +def _default_walltime(partition: Partition) -> str: + """Pick a sensible default walltime from the partition. + + Parameters + ---------- + partition : Partition + Selected partition. + + Returns + ------- + str + Walltime string (falls back to ``"2:00:00"``). + """ + mt = partition.max_time + if mt and mt not in ("unknown", "infinite"): + return mt + return "2:00:00" + + +# --------------------------------------------------------------------------- +# Selection helpers +# --------------------------------------------------------------------------- + + +def _select_with_custom( + message: str, + choices: list[str], + default: str = "", +) -> str: + """Present a selection list with a "Custom…" escape hatch. + + Parameters + ---------- + message : str + Prompt message shown to the user. + choices : list[str] + Pre-defined choices (e.g. ``["1h", "2h", "4h"]``). + default : str + Pre-selected value in the list. + + Returns + ------- + str + The selected or custom-entered value. + + Raises + ------ + UserCancelledError + If the user cancels. + """ + questionary = _import_questionary() + _CUSTOM = "Custom…" + all_choices = [*choices, _CUSTOM] + selected = _require( + questionary.select(message, choices=all_choices, default=default).ask(), + message, + ) + if selected == _CUSTOM: + return _require( + questionary.text(f"{message} (enter value):").ask(), + message, + ) + return selected + + +# --------------------------------------------------------------------------- +# Interactive prompts — cluster-assisted +# --------------------------------------------------------------------------- + + +def _configure_with_cluster(cluster: ClusterInfo) -> SlurmExecutorConfig: + """Run the interactive wizard using autodiscovered cluster info. + + Parameters + ---------- + cluster : ClusterInfo + Cluster information from ``discover_cluster()``. + + Returns + ------- + SlurmExecutorConfig + Fully populated SLURM executor config. + + Raises + ------ + UserCancelledError + If the user cancels any prompt. + """ + questionary = _import_questionary() + # --- Account --- + if cluster.accounts: + account = _require( + questionary.select( + "SLURM account:", + choices=cluster.accounts, + default=cluster.default_account or cluster.accounts[0], + ).ask(), + "account", + ) + else: + account = _require( + questionary.text("SLURM account (no accounts discovered):").ask(), + "account", + ) + + # --- Partition --- + up_partitions = [p for p in cluster.partitions if p.state == "up"] + if not up_partitions: + console.print("⚠ No partitions in 'up' state — showing all partitions.") + up_partitions = list(cluster.partitions) + + if not up_partitions: + raise UserCancelledError("No partitions available on the cluster.") + + partition_choices = [ + questionary.Choice( + title=f"{p.name} ({p.total_nodes} nodes — {_format_node_type_summary(p)})", + value=p.name, + ) + for p in up_partitions + ] + default_partition = next((p.name for p in up_partitions if p.is_default), up_partitions[0].name) + + partition_name = _require( + questionary.select( + "SLURM partition:", + choices=partition_choices, + default=default_partition, + ).ask(), + "partition", + ) + + partition = next(p for p in up_partitions if p.name == partition_name) + + # --- Display node types --- + console.print(f"\n Partition '{partition_name}' node types:") + for nt in partition.node_types: + gpu_info = "" + if nt.gpu_specs: + gpu_parts = [f"{c}×{t}" if t else f"{c}×gpu" for c, t in nt.gpu_specs] + gpu_info = f", GPUs: {', '.join(gpu_parts)}" + console.print( + f" {nt.count} nodes — {nt.cpus} CPUs, {nt.memory_mb // 1024} GB RAM{gpu_info}" + ) + console.print() + + # --- Walltime --- + walltime = _select_with_custom( + "Walltime (--time):", + choices=["30m", "1h", "2h", "4h", "8h", "12h", "1d"], + default="2h", + ) + + # --- CPUs per node --- + max_cpus = partition.node_types[0].cpus if partition.node_types else 128 + cpu_choices = [str(c) for c in [1, 2, 4, 8, 16, 32, 64, max_cpus] if c <= max_cpus] + # Deduplicate (max_cpus might equal one of the fixed values) + cpu_choices = list(dict.fromkeys(cpu_choices)) + cpus_per_node = int( + _select_with_custom( + "CPUs per node (--cpus-per-task):", + choices=cpu_choices, + default="4" if "4" in cpu_choices else cpu_choices[0], + ) + ) + + # --- GPU --- + gres: str | None = None + if _partition_has_gpus(partition): + gres_default = _suggest_gres(partition) + gres_input = _require( + questionary.text( + "GRES (--gres), leave empty to skip:", + default=gres_default, + ).ask(), + "gres", + ) + gres = gres_input.strip() or None + + # --- Memory --- + mem_gb = partition.node_types[0].memory_mb // 1024 if partition.node_types else 128 + mem_choices = [f"{m}G" for m in [10, 20, 50, 100, 200, 500, mem_gb] if m <= mem_gb] + mem_choices = list(dict.fromkeys(mem_choices)) + mem_selected = _select_with_custom( + "Memory per node (--mem):", + choices=mem_choices, + default="20G" if "20G" in mem_choices else mem_choices[0], + ) + mem: str | None = mem_selected.strip() or None + + # --- Max blocks --- + max_blocks = int( + _select_with_custom( + "Max simultaneous SLURM jobs (max_blocks):", + choices=["1", "2", "4", "8", "16", "32"], + default="4", + ) + ) + + # --- Worker init --- + default_init = _default_worker_init() + worker_init = _require( + questionary.text( + "Worker init script (activates environment on compute nodes):", + default=default_init, + ).ask(), + "worker_init", + ) + + # --- QOS --- + qos: str | None = None + if cluster.qos_policies: + use_qos = questionary.confirm("Configure QOS?", default=False).ask() + if use_qos is None: + raise UserCancelledError("Prompt cancelled at: qos confirm") + if use_qos: + qos = _require( + questionary.select("QOS policy:", choices=cluster.qos_policies).ask(), + "qos", + ) + + # --- Constraint --- + constraint_input = _require( + questionary.text( + "Node feature constraint (--constraint), leave empty to skip:", + default="", + ).ask(), + "constraint", + ) + constraint: str | None = constraint_input.strip() or None + + return SlurmExecutorConfig( + account=account, + partition=partition_name, + walltime=walltime, + cpus_per_node=cpus_per_node, + gres=gres, + mem=mem, + qos=qos, + constraint=constraint, + max_blocks=max_blocks, + worker_init=worker_init, + ) + + +# --------------------------------------------------------------------------- +# Interactive prompts — manual fallback +# --------------------------------------------------------------------------- + + +def _configure_manual() -> SlurmExecutorConfig: + """Run the interactive wizard with manual text entry (no cluster info). + + Returns + ------- + SlurmExecutorConfig + Fully populated SLURM executor config. + + Raises + ------ + UserCancelledError + If the user cancels any prompt. + """ + questionary = _import_questionary() + account = _require(questionary.text("SLURM account:").ask(), "account") + partition = _require(questionary.text("SLURM partition:", default="gpu").ask(), "partition") + walltime = _require(questionary.text("Walltime (--time):", default="2:00:00").ask(), "walltime") + cpus_per_node = int( + _require(questionary.text("CPUs per node:", default="12").ask(), "cpus_per_node") + ) + + gres_input = _require( + questionary.text("GRES (--gres), leave empty to skip:", default="").ask(), + "gres", + ) + gres: str | None = gres_input.strip() or None + + mem_input = _require( + questionary.text("Memory per node (--mem), leave empty to skip:", default="").ask(), + "mem", + ) + mem: str | None = mem_input.strip() or None + + qos_input = _require( + questionary.text("QOS (--qos), leave empty to skip:", default="").ask(), + "qos", + ) + qos: str | None = qos_input.strip() or None + + max_blocks = int( + _require( + questionary.text("Max simultaneous SLURM jobs (max_blocks):", default="4").ask(), + "max_blocks", + ) + ) + + default_init = _default_worker_init() + worker_init = _require( + questionary.text( + "Worker init script (activates environment on compute nodes):", + default=default_init, + ).ask(), + "worker_init", + ) + + return SlurmExecutorConfig( + account=account, + partition=partition, + walltime=walltime, + cpus_per_node=cpus_per_node, + gres=gres, + mem=mem, + qos=qos, + max_blocks=max_blocks, + worker_init=worker_init, + ) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def configure_slurm_interactive() -> SlurmExecutorConfig: + """Interactive SLURM configuration wizard. + + Attempts to autodiscover the SLURM cluster. If discovery succeeds, + the wizard presents select menus populated with real partitions, + accounts, and hardware specs. Otherwise it falls back to free-text + prompts. + + Returns + ------- + SlurmExecutorConfig + A validated SLURM executor config ready for use with Parsl. + + Raises + ------ + UserCancelledError + If the user cancels any prompt (Ctrl-C / Ctrl-D). + """ + questionary = _import_questionary() + console.print("Querying SLURM cluster...") + cluster = discover_cluster() + + if cluster is not None: + console.print( + f"✓ Cluster discovered: {len(cluster.partitions)} partitions, " + f"{len(cluster.accounts)} accounts\n" + ) + return _configure_with_cluster(cluster) + + console.print("⚠ SLURM not detected (sinfo unavailable). Falling back to manual entry.\n") + proceed = questionary.confirm("Enter SLURM configuration manually?", default=True).ask() + if not proceed: + raise UserCancelledError("User declined manual SLURM configuration.") + return _configure_manual() + + +def save_slurm_config_yaml(config: SlurmExecutorConfig, path: Path) -> None: + """Write a SLURM executor config to a YAML file. + + Parameters + ---------- + config : SlurmExecutorConfig + The config to serialise. + path : Path + Destination file path. Parent directories are created if needed. + """ + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + + data = config.model_dump(exclude_none=True) + with open(path, "w") as fh: + yaml.dump(data, fh, default_flow_style=False, sort_keys=False) + + console.print(f"✓ SLURM config written to {path}") + + +def configure_and_save_slurm() -> SlurmExecutorConfig: + """Run the interactive wizard and save the result to YAML. + + Combines :func:`configure_slurm_interactive` with a file-save prompt. + + Returns + ------- + SlurmExecutorConfig + The config that was saved. + + Raises + ------ + UserCancelledError + If the user cancels any prompt. + """ + questionary = _import_questionary() + try: + config = configure_slurm_interactive() + except UserCancelledError: + console.print("Configuration cancelled.") + raise + + save_path = _require( + questionary.text("Save config to:", default="slurm_executor.yaml").ask(), + "save path", + ) + + save_slurm_config_yaml(config, Path(save_path)) + return config diff --git a/mdfactory/parametrize.py b/mdfactory/parametrize.py index e18937c..7ce61de 100644 --- a/mdfactory/parametrize.py +++ b/mdfactory/parametrize.py @@ -269,6 +269,11 @@ def parametrize_smirnoff_gromacs( if not itp_path.is_file(): workdir.mkdir(parents=True, exist_ok=True) molecule = species.openff_molecule + # Force molecule name to "SOL" for consistent Interchange output, + # regardless of the user-provided resname (e.g. "WAT"). + # This ensures atom types are always SOL_0, SOL_1, ... and the + # generated ITP is always SOL_SOL.itp. + molecule.name = "SOL" topology = Topology.from_molecules([molecule]) forcefield = ForceField(water_model) interchange = Interchange.from_smirnoff(force_field=forcefield, topology=topology) @@ -280,6 +285,9 @@ def parametrize_smirnoff_gromacs( generated_itp = workdir / "SOL_SOL.itp" if generated_itp.is_file(): generated_itp.rename(itp_path) + # Remove stale params if atomtypes were regenerated + if params_itp_path.is_file(): + params_itp_path.unlink() if top_path.is_file() and atomtypes_path.is_file() and not params_itp_path.is_file(): _build_smirnoff_parameter_itp(top_path, atomtypes_path, params_itp_path) diff --git a/mdfactory/performance/__init__.py b/mdfactory/performance/__init__.py new file mode 100644 index 0000000..f8986a1 --- /dev/null +++ b/mdfactory/performance/__init__.py @@ -0,0 +1,18 @@ +# ABOUTME: HPC performance optimization package for mdfactory. +# ABOUTME: Cluster autodiscovery, CPU affinity, benchmarking, and GPU MPS management. +"""HPC performance optimization utilities. + +Modules +------- +cluster + SLURM cluster autodiscovery — query partitions, node types, accounts, and QOS. +slurm_config + SLURM configuration models shared across all submission backends. + ``BaseSlurmConfig`` provides 3-tier autodiscovery for account and partition. + ``SlurmConfig`` is the submitit backend configuration. +""" + +from mdfactory.performance import cluster +from mdfactory.performance.slurm_config import BaseSlurmConfig, SlurmConfig + +__all__ = ["cluster", "BaseSlurmConfig", "SlurmConfig"] diff --git a/mdfactory/performance/cluster.py b/mdfactory/performance/cluster.py new file mode 100644 index 0000000..cf7e81f --- /dev/null +++ b/mdfactory/performance/cluster.py @@ -0,0 +1,674 @@ +# ABOUTME: SLURM cluster autodiscovery — query partitions, node types, accounts, QOS. +# ABOUTME: Parses sinfo/sacctmgr output into structured dataclasses for resource-aware scheduling. +"""SLURM cluster autodiscovery. + +Query the local SLURM scheduler and return a structured representation of +available resources (partitions, node types, accounts, QOS policies, GPU types). + +Functions +--------- +discover_cluster + Main entry point — returns ``ClusterInfo`` or ``None`` if SLURM is unavailable. +select_partition + Heuristic partition selection given resource requirements. + +Examples +-------- +>>> from mdfactory.performance.cluster import discover_cluster, select_partition +>>> cluster = discover_cluster() +>>> if cluster is not None: +... gpu_part = select_partition(cluster, needs_gpu=True) +""" + +from __future__ import annotations + +import os +import shutil + +from pydantic import BaseModel, ConfigDict, Field + +from mdfactory.utils.utilities import run_command + +# Single-element list used as a mutable cache cell (avoids 'global', ruff PLW0603). +# _CLUSTER_CACHE_SENTINEL = not yet queried; None = sinfo absent; ClusterInfo = success. +# Transient sinfo failures are not cached so the next call retries. +_CLUSTER_CACHE_SENTINEL = object() +_cluster_cache: list = [_CLUSTER_CACHE_SENTINEL] + + +class NodeType(BaseModel): + """Hardware specification of a node type within a partition. + + Parameters + ---------- + cpus : int + Number of CPU cores per node. + memory_mb : int + Memory in megabytes per node. + gpu_specs : tuple of (int, str | None) + GPU specifications as (count, type) tuples. Empty if CPU-only. + Multiple entries represent different GPU types on the same node. + Type can be None if SLURM reports GPU count without type info. + features : tuple of str + SLURM feature/constraint tags on this node type (immutable). + count : int + Number of nodes with this exact configuration. + """ + + model_config = ConfigDict(frozen=True) + + cpus: int + memory_mb: int + gpu_specs: tuple[tuple[int, str | None], ...] = Field(default_factory=tuple) + features: tuple[str, ...] = Field(default_factory=tuple) + count: int = 1 + + +class Partition(BaseModel): + """A SLURM partition with its node types and limits. + + Parameters + ---------- + name : str + Partition name (e.g., ``"gpu"``, ``"cpu"``). + state : str + Partition-level state: ``"up"`` if any node is schedulable, otherwise + the last observed unhealthy state (e.g., ``"down"``, ``"drained"``). + max_time : str + Maximum walltime (SLURM format, e.g., ``"3-00:00:00"``). + default_time : str + Default walltime assigned when user does not specify one. + Populated from sinfo ``%L``; equals ``max_time`` on legacy output. + node_types : list of NodeType + Distinct hardware configurations available in this partition. + total_nodes : int + Total number of nodes in the partition. + is_default : bool + Whether this is the cluster's default partition. + """ + + model_config = ConfigDict(frozen=True) + + name: str + state: str + max_time: str + default_time: str + node_types: list[NodeType] = Field(default_factory=list) + total_nodes: int = 0 + is_default: bool = False + + +class ClusterInfo(BaseModel): + """Structured representation of a SLURM cluster's resources. + + Parameters + ---------- + partitions : list of Partition + All discovered partitions. + accounts : list of str + SLURM accounts available to the current user. + qos_policies : list of str + Available QOS policy names. + default_account : str or None + The user's default account, if determinable. + """ + + model_config = ConfigDict(frozen=True) + + partitions: list[Partition] = Field(default_factory=list) + accounts: list[str] = Field(default_factory=list) + qos_policies: list[str] = Field(default_factory=list) + default_account: str | None = None + + +def _parse_gres(gres_str: str) -> list[tuple[int, str | None]]: + """Parse SLURM GRES string to extract GPU count and type for all GPU entries. + + Parameters + ---------- + gres_str : str + GRES field from sinfo (e.g., ``"gpu:a100:4"``, ``"gpu:2"``, ``"(null)"``). + May contain multiple GPU entries separated by commas. + + Returns + ------- + list of tuple (int, str or None) + List of (gpu_count, gpu_type) for each GPU entry. Empty list when no GPUs. + + Examples + -------- + >>> _parse_gres("gpu:a100:4") + [(4, 'a100')] + >>> _parse_gres("gpu:b200:8(S:0-1),gpu:1g.23gb:7(S:1)") + [(8, 'b200'), (7, '1g.23gb')] + >>> _parse_gres("(null)") + [] + """ + if not gres_str or gres_str == "(null)": + return [] + + gpu_entries = [] + + # Handle multiple GRES entries separated by commas + for raw_entry in gres_str.split(","): + entry = raw_entry.strip() + if not entry.startswith("gpu"): + continue + + # Strip socket binding suffix like "(S:0-1)" or "(IDX:0-3)" + if "(" in entry: + entry = entry.split("(")[0] + + parts = entry.split(":") + if len(parts) == 3: + # gpu:type:count + _, gpu_type, count_str = parts + try: + gpu_entries.append((int(count_str), gpu_type)) + except ValueError: + continue # skip malformed GRES entry + elif len(parts) == 2: + # gpu:count (no type specified) + _, count_str = parts + # Check if second part is a number or a type + try: + count = int(count_str) + gpu_entries.append((count, None)) + except ValueError: + # gpu:type with implicit count of 1 + gpu_entries.append((1, count_str)) + + return gpu_entries + + +def _parse_time_limit(time_str: str) -> str: + """Normalize SLURM time limit strings. + + Parameters + ---------- + time_str : str + Time limit from sinfo (e.g., ``"3-00:00:00"``, ``"infinite"``, ``"2:00:00"``). + + Returns + ------- + str + Cleaned time string. + """ + if not time_str or time_str == "n/a": + return "unknown" + return time_str.strip() + + +def _parse_memory_mb(mem_str: str) -> int: + """Parse memory string from sinfo to megabytes. + + Parameters + ---------- + mem_str : str + Memory field from sinfo (numeric, in MB by default). + + Returns + ------- + int + Memory in MB. Returns 0 on parse failure. + """ + try: + # sinfo %m gives memory in MB as an integer + cleaned = mem_str.strip().rstrip("+") + return int(cleaned) + except (ValueError, AttributeError): + return 0 + + +def _parse_features(features_str: str) -> tuple[str, ...]: + """Parse SLURM features/constraints string. + + Parameters + ---------- + features_str : str + Features field from sinfo (comma-separated or ``"(null)"``). + + Returns + ------- + tuple of str + Feature strings (immutable). + """ + if not features_str or features_str == "(null)": + return () + return tuple(f.strip() for f in features_str.split(",") if f.strip()) + + +def _parse_sinfo(output: str) -> list[Partition]: + """Parse sinfo output into Partition objects. + + Expects output from: + sinfo -N --noheader -o "%P %n %c %m %G %f %l %L %T" + + Fields: Partition, NodeName, CPUs, Memory(MB), GRES, Features, + MaxTimeLimit, DefaultTimeLimit, State + + Also supports the legacy 8-field format (without %L) for backward + compatibility — in that case ``default_time`` equals ``max_time``. + + Parameters + ---------- + output : str + Raw sinfo output. + + Returns + ------- + list of Partition + Parsed partition list with deduplicated node types. + """ + # Collect data per partition + partition_data: dict[str, dict] = {} + + for raw_line in output.splitlines(): + line = raw_line.strip() + if not line: + continue + + parts = line.split() + if len(parts) >= 9: + # 9-field format: %P %n %c %m %G %f %l %L %T + partition_name = parts[0] + cpus_str = parts[2] + mem_str = parts[3] + gres_str = parts[4] + features_str = parts[5] + max_time = parts[6] + default_time = parts[7] + state = parts[8] + elif len(parts) == 8: + # Legacy 8-field format (no %L): default_time = max_time + partition_name = parts[0] + cpus_str = parts[2] + mem_str = parts[3] + gres_str = parts[4] + features_str = parts[5] + max_time = parts[6] + default_time = parts[6] + state = parts[7] + else: + continue + + # Handle default partition marker (trailing asterisk) + is_default = partition_name.endswith("*") + if is_default: + partition_name = partition_name.rstrip("*") + + # Parse node specs + try: + cpus = int(cpus_str) + except ValueError: + continue + + memory_mb = _parse_memory_mb(mem_str) + gpu_entries = _parse_gres(gres_str) + features = _parse_features(features_str) + + if partition_name not in partition_data: + partition_data[partition_name] = { + "max_time": _parse_time_limit(max_time), + "default_time": _parse_time_limit(default_time), + "node_types": {}, # Maps node_key -> count + "node_count": 0, + "is_default": is_default, + "has_healthy_node": False, + "last_unhealthy_state": "down", + } + + # Track if any line marks this as default + if is_default: + partition_data[partition_name]["is_default"] = True + + # Determine if node is operational (can schedule jobs or report accurate config) + # Exclude broken/invalid nodes: down, drained, inval, fail, maint, unknown, error + state_lower = state.lower() + is_broken = any( + broken in state_lower + for broken in ("down", "drained", "inval", "fail", "maint", "unknown", "error") + ) + is_operational = not is_broken + + # Partition is "up" if ANY operational node exists + if is_operational: + partition_data[partition_name]["has_healthy_node"] = True + else: + partition_data[partition_name]["last_unhealthy_state"] = state + + # Count all nodes (including allocated, reserved, etc.) for stable totals + partition_data[partition_name]["node_count"] += 1 + + # Only collect node specs from operational nodes + # (broken nodes may report incorrect/stale hardware configs) + if is_operational: + # Convert GPU entries to a sorted tuple for consistent hashing + # Sort by type name for deterministic ordering + gpu_specs = tuple(sorted(gpu_entries, key=lambda x: x[1] or "")) + + # Use a hashable representation for deduplication + node_key = (cpus, memory_mb, gpu_specs, features) + + # Track count of nodes with this exact configuration + if node_key not in partition_data[partition_name]["node_types"]: + partition_data[partition_name]["node_types"][node_key] = 0 + partition_data[partition_name]["node_types"][node_key] += 1 + + # Build Partition objects + partitions = [] + for name, data in partition_data.items(): + # Partition state: "up" if any node is healthy, otherwise report + # the last observed unhealthy state + if data["has_healthy_node"]: + partition_state = "up" + else: + partition_state = data["last_unhealthy_state"] + + node_types = [ + NodeType( + cpus=cpus, + memory_mb=mem, + gpu_specs=gpu_specs, + features=feats, + count=count, + ) + for (cpus, mem, gpu_specs, feats), count in data["node_types"].items() + ] + # Sort node types by CPU count, memory, then GPU specs for deterministic ordering + node_types.sort(key=lambda n: (n.cpus, n.memory_mb, n.gpu_specs)) + + partitions.append( + Partition( + name=name, + state=partition_state, + max_time=data["max_time"], + default_time=data["default_time"], + node_types=node_types, + total_nodes=data["node_count"], + is_default=data["is_default"], + ) + ) + + # Sort partitions: default first, then alphabetical + partitions.sort(key=lambda p: (not p.is_default, p.name)) + return partitions + + +def _parse_accounts(output: str) -> list[str]: + """Parse sacctmgr account output. + + Parameters + ---------- + output : str + Raw sacctmgr output (one account per line, parsable2 format). + + Returns + ------- + list of str + Unique account names, sorted. + """ + accounts = set() + for line in output.splitlines(): + account = line.strip() + if account: + accounts.add(account) + return sorted(accounts) + + +def _parse_qos(output: str) -> list[str]: + """Parse sacctmgr QOS output. + + Parameters + ---------- + output : str + Raw sacctmgr output (pipe-separated: Name|MaxWall|MaxTRES). + + Returns + ------- + list of str + QOS policy names, sorted. + """ + qos_names = set() + for raw_line in output.splitlines(): + line = raw_line.strip() + if not line: + continue + # Format: Name|MaxWall|MaxTRES + parts = line.split("|") + if parts and parts[0].strip(): + qos_names.add(parts[0].strip()) + return sorted(qos_names) + + +def _discover_partitions() -> list[Partition] | None: + """Query sinfo for partition and node information. + + Returns + ------- + list of Partition or None + Parsed partitions, or None if sinfo is unavailable. + """ + output = run_command( + ["sinfo", "-N", "--noheader", "-o", "%P %n %c %m %G %f %l %L %T"], + timeout=30, + graceful=True, + ) + if output is None: + return None + return _parse_sinfo(output) + + +def _discover_accounts() -> list[str] | None: + """Query sacctmgr for the current user's accounts. + + Returns + ------- + list of str or None + Account names, or None if sacctmgr is unavailable. + """ + user = os.environ.get("USER", os.environ.get("LOGNAME", "")) + if not user: + return None + output = run_command( + [ + "sacctmgr", + "show", + "assoc", + f"user={user}", + "format=Account", + "--noheader", + "--parsable2", + ], + timeout=30, + graceful=True, + ) + if output is None: + return None + return _parse_accounts(output) + + +def _discover_qos() -> list[str] | None: + """Query sacctmgr for available QOS policies. + + Returns + ------- + list of str or None + QOS names, or None if sacctmgr is unavailable. + """ + output = run_command( + [ + "sacctmgr", + "show", + "qos", + "format=Name,MaxWall,MaxTRES", + "--noheader", + "--parsable2", + ], + timeout=30, + graceful=True, + ) + if output is None: + return None + return _parse_qos(output) + + +def _discover_default_account() -> str | None: + """Query sacctmgr for the current user's default SLURM account. + + Returns + ------- + str or None + The user's default account, or None if unavailable. + """ + user = os.environ.get("USER", os.environ.get("LOGNAME", "")) + if not user: + return None + output = run_command( + [ + "sacctmgr", + "show", + "user", + user, + "format=DefaultAccount", + "--noheader", + "--parsable2", + ], + timeout=30, + graceful=True, + ) + if output is None: + return None + account = output.strip().splitlines()[0].strip() if output.strip() else None + return account if account else None + + +def discover_cluster() -> ClusterInfo | None: + """Query SLURM and return structured cluster information. + + Calls ``sinfo`` and ``sacctmgr`` to discover partitions, node types, + accounts, and QOS policies. Returns None gracefully when SLURM commands + are not available (e.g., running on a laptop). + + Successful results and confirmed sinfo-absent are cached for the session + (cluster topology doesn't change mid-run). Transient failures (sinfo + present but exiting non-zero) are not cached so the next call retries. + Call ``discover_cluster.cache_clear()`` to force a full re-query. + + Returns + ------- + ClusterInfo or None + Structured cluster information, or None if SLURM is unavailable. + + Examples + -------- + >>> cluster = discover_cluster() + >>> if cluster is not None: + ... for p in cluster.partitions: + ... print(f"{p.name}: {p.total_nodes} nodes") + """ + if _cluster_cache[0] is not _CLUSTER_CACHE_SENTINEL: + return _cluster_cache[0] # type: ignore[return-value] + + # sinfo is the minimum requirement — if it's not available, we're not + # on a SLURM cluster. This is a stable fact; cache it. + if shutil.which("sinfo") is None: + _cluster_cache[0] = None + return None + + partitions = _discover_partitions() + if partitions is None: + # Transient failure (sinfo exited non-zero) — don't cache, allow retry. + return None + + # Accounts and QOS are best-effort (sacctmgr may be restricted) + accounts = _discover_accounts() or [] + qos_policies = _discover_qos() or [] + + # Query the real SLURM default account; fall back to first available + default_account = _discover_default_account() + if default_account is None and accounts: + default_account = accounts[0] + + result = ClusterInfo( + partitions=partitions, + accounts=accounts, + qos_policies=qos_policies, + default_account=default_account, + ) + _cluster_cache[0] = result + return result + + +def _clear_cluster_cache() -> None: + _cluster_cache[0] = _CLUSTER_CACHE_SENTINEL + + +discover_cluster.cache_clear = _clear_cluster_cache # type: ignore[attr-defined] + + +def select_partition( + cluster: ClusterInfo, + *, + needs_gpu: bool = False, + min_cpus: int = 1, + min_mem_gb: int = 1, +) -> Partition | None: + """Heuristic partition selection given resource requirements. + + Selects the best-matching partition from the cluster based on hardware + needs. Prefers partitions that are ``"up"`` and have nodes meeting the + specified requirements. + + Parameters + ---------- + cluster : ClusterInfo + Cluster information from ``discover_cluster()``. + needs_gpu : bool + If True, only consider partitions with GPU-equipped nodes. + min_cpus : int + Minimum CPUs per node required. + min_mem_gb : int + Minimum memory per node in GB. + + Returns + ------- + Partition or None + Best matching partition, or None if no partition meets requirements. + + Examples + -------- + >>> cluster = discover_cluster() + >>> gpu_partition = select_partition(cluster, needs_gpu=True, min_cpus=8) + """ + min_mem_mb = min_mem_gb * 1024 + candidates: list[Partition] = [] + + for partition in cluster.partitions: + # Skip partitions with no schedulable nodes + if partition.state.lower() != "up": + continue + + # Check if any node type meets requirements + has_qualifying_node = False + for node in partition.node_types: + if node.cpus < min_cpus: + continue + if node.memory_mb < min_mem_mb: + continue + # Check if node has any GPUs + has_gpu = len(node.gpu_specs) > 0 + if needs_gpu and not has_gpu: + continue + has_qualifying_node = True + break + + if has_qualifying_node: + candidates.append(partition) + + if not candidates: + return None + + # Prefer: default partition > most nodes > alphabetical + candidates.sort(key=lambda p: (not p.is_default, -p.total_nodes, p.name)) + return candidates[0] diff --git a/mdfactory/performance/slurm_config.py b/mdfactory/performance/slurm_config.py new file mode 100644 index 0000000..4fb3533 --- /dev/null +++ b/mdfactory/performance/slurm_config.py @@ -0,0 +1,391 @@ +# ABOUTME: SLURM configuration models shared across all submission backends. +# ABOUTME: BaseSlurmConfig with 3-tier autodiscovery; SlurmConfig for submitit analysis jobs. +"""SLURM configuration models for all submission backends. + +Provides a shared ``BaseSlurmConfig`` Pydantic model with four cross-cutting +SLURM fields (``account``, ``partition``, ``qos``, ``constraint``) and a single +authoritative ``from_cluster()`` factory that implements three-tier precedence: + +1. Explicit keyword arguments passed by the caller +2. ``[slurm]`` section in ``config.ini`` +3. Live ``sinfo`` / ``sacctmgr`` autodiscovery via ``mdfactory.performance.cluster`` + +Subclasses add backend-specific fields and inherit ``from_cluster()`` for free. + +Classes +------- +BaseSlurmConfig + Abstract base for all SLURM-facing config objects. +SlurmConfig + Configuration for submitit-based analysis submission. + +Functions +--------- +normalize_slurm_time + Convert human-friendly time strings (``"2h"``, ``"30m"``) to + ``HH:MM:SS`` / ``D-HH:MM:SS`` format accepted by SLURM. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Self + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +def resolve_slurm_fields( + *, + needs_gpu: bool = False, + min_cpus: int = 1, + min_mem_gb: int = 1, + **extra_fields: Any, +) -> dict[str, Any]: + """Resolve SLURM scheduling fields via three-tier precedence. + + Three-tier precedence (highest wins): + + 1. Explicit keyword arguments passed via ``extra_fields`` + (``account``, ``partition``, ``qos``, ``constraint``). + 2. ``[slurm]`` section in ``config.ini`` — read via + ``mdfactory.settings.settings``. + 3. Live ``sinfo`` / ``sacctmgr`` autodiscovery via + ``mdfactory.performance.cluster``. + + Parameters + ---------- + needs_gpu : bool + Select a GPU-capable partition when ``True``. + min_cpus : int + Minimum CPUs per node required (passed to ``select_partition()``). + min_mem_gb : int + Minimum memory per node in GB (passed to ``select_partition()``). + **extra_fields + Any additional fields. The keys ``account``, ``partition``, + ``qos``, and ``constraint`` are consumed for SLURM resolution; + remaining keys are passed through unchanged. + + Returns + ------- + dict[str, Any] + A dict with at least ``account``, ``partition``, ``qos``, and + ``constraint`` plus any remaining ``extra_fields``. + + Raises + ------ + RuntimeError + If SLURM is unavailable *and* the required ``account`` or + ``partition`` value cannot be resolved from config. + """ + from mdfactory.performance.cluster import discover_cluster, select_partition + from mdfactory.settings import settings + + cluster = discover_cluster() + + # --- account: explicit kwarg > config.ini > autodiscovery --- + resolved_account: str | None = extra_fields.pop("account", None) + if resolved_account is None: + resolved_account = settings.slurm_account + if resolved_account is None: + if cluster is None: + raise RuntimeError( + "SLURM autodiscovery failed and no account configured. " + "Set [slurm] ACCOUNT in config.ini or pass account= explicitly." + ) + resolved_account = cluster.default_account + if resolved_account is None: + raise RuntimeError( + "No SLURM account available. " + "Set [slurm] ACCOUNT in config.ini or pass account= explicitly." + ) + + # --- partition: explicit kwarg > config.ini (cpu/gpu) > autodiscovery --- + resolved_partition: str | None = extra_fields.pop("partition", None) + if resolved_partition is None: + resolved_partition = ( + settings.slurm_partition_gpu if needs_gpu else settings.slurm_partition_cpu + ) + if resolved_partition is None: + if cluster is None: + raise RuntimeError( + "SLURM autodiscovery failed and no partition configured. " + "Set [slurm] PARTITION_CPU or PARTITION_GPU in config.ini " + "or pass partition= explicitly." + ) + selected = select_partition( + cluster, + needs_gpu=needs_gpu, + min_cpus=min_cpus, + min_mem_gb=min_mem_gb, + ) + if selected is None: + raise RuntimeError( + f"No suitable partition found for requirements: " + f"needs_gpu={needs_gpu}, cpus={min_cpus}, mem={min_mem_gb}GB" + ) + resolved_partition = selected.name + + # --- qos: explicit kwarg > config.ini --- + resolved_qos: str | None = extra_fields.pop("qos", None) + if resolved_qos is None: + resolved_qos = settings.slurm_qos + + # --- constraint: explicit kwarg only (no config.ini equivalent) --- + resolved_constraint: str | None = extra_fields.pop("constraint", None) + + return { + "account": resolved_account, + "partition": resolved_partition, + "qos": resolved_qos, + "constraint": resolved_constraint, + **extra_fields, + } + + +def normalize_slurm_time(value: str) -> str: + """Normalize SLURM time strings to ``HH:MM:SS`` or ``D-HH:MM:SS`` format. + + Accepts human-friendly shorthand as well as all canonical SLURM formats. + + Parameters + ---------- + value : str + Raw time string, e.g. ``"2h"``, ``"30m"``, ``"90"`` (minutes), + ``"1d"``, ``"01:00:00"``, ``"3-00:00:00"``. + + Returns + ------- + str + Normalized time string. Strings that already contain ``:`` are + returned unchanged (pass-through for HH:MM:SS / D-HH:MM:SS). + + Examples + -------- + >>> normalize_slurm_time("2h") + '02:00:00' + >>> normalize_slurm_time("30m") + '00:30:00' + >>> normalize_slurm_time("90") + '01:30:00' + >>> normalize_slurm_time("1d") + '1-00:00:00' + >>> normalize_slurm_time("01:00:00") + '01:00:00' + """ + raw = value.strip() + if ":" in raw: + return raw + lowered = raw.lower() + if lowered.endswith("d"): + days = int(lowered[:-1]) + return f"{days}-00:00:00" + if lowered.endswith("h"): + hours = int(lowered[:-1]) + return f"{hours:02d}:00:00" + if lowered.endswith("m"): + minutes = int(lowered[:-1]) + hours, minutes = divmod(minutes, 60) + return f"{hours:02d}:{minutes:02d}:00" + if lowered.isdigit(): + minutes = int(lowered) + hours, minutes = divmod(minutes, 60) + return f"{hours:02d}:{minutes:02d}:00" + return raw + + +class BaseSlurmConfig(BaseModel): + """SLURM resource specification shared by all submission backends. + + Base class owning the four cross-cutting SLURM fields and the single + authoritative ``from_cluster()`` factory method. Subclasses add + backend-specific fields (e.g. ``cpus_per_task`` for submitit, + ``cpus_per_node`` for Parsl) and inherit ``from_cluster()`` without + writing any additional code. + + Parameters + ---------- + account : str + SLURM account string (``--account``). + partition : str + SLURM partition name (``--partition``). Defaults to ``"cpu"``. + qos : str or None + Quality-of-service policy name (``--qos``). ``None`` lets SLURM + apply its own default. + constraint : str or None + Node feature constraint string (``--constraint``). + + Notes + ----- + This model is *frozen*: instances are immutable after construction. + Use Pydantic's ``model_copy(update={...})`` to derive a modified copy. + """ + + model_config = ConfigDict(frozen=True) + + account: str + partition: str = "cpu" + qos: str | None = None + constraint: str | None = None + + @classmethod + def from_cluster( + cls, + *, + needs_gpu: bool = False, + min_cpus: int = 1, + min_mem_gb: int = 1, + **extra_fields: Any, + ) -> Self: + """Create an instance with SLURM fields auto-populated from the cluster. + + Three-tier precedence (highest wins): + + 1. Explicit keyword arguments passed by the caller (e.g. + ``account="mygroup"`` or ``partition="gpu"``). + 2. ``[slurm]`` section in ``config.ini`` — read via + ``mdfactory.settings.settings``. + 3. Live ``sinfo`` / ``sacctmgr`` autodiscovery via + ``mdfactory.performance.cluster``. + + The lazy import of ``mdfactory.performance.cluster`` keeps this + module importable on non-SLURM machines. + + Parameters + ---------- + needs_gpu : bool + Select a GPU-capable partition when ``True``. Used both for + partition autodiscovery and for choosing the correct + ``PARTITION_GPU`` config key. + min_cpus : int + Minimum CPUs per node required (passed to ``select_partition()``). + min_mem_gb : int + Minimum memory per node in GB (passed to ``select_partition()``). + **extra_fields + Backend-specific fields forwarded to the subclass constructor. + For ``SlurmConfig``: ``time``, ``cpus_per_task``, ``mem_gb``, + ``job_name_prefix``. + Base-class fields (``account``, ``partition``, ``qos``, + ``constraint``) may also be passed here to override autodiscovery. + + Returns + ------- + BaseSlurmConfig + A fully initialised subclass instance (the concrete type is + determined by ``cls``). + + Raises + ------ + RuntimeError + If SLURM is unavailable *and* the required ``account`` or + ``partition`` value cannot be resolved from config. + """ + fields = resolve_slurm_fields( + needs_gpu=needs_gpu, + min_cpus=min_cpus, + min_mem_gb=min_mem_gb, + **extra_fields, + ) + return cls(**fields) + + +class SlurmConfig(BaseSlurmConfig): + """SLURM configuration for submitit-based analysis submission. + + Backend: submitit — one SLURM job per analysis task. + + The ``time`` field is normalised on construction via + ``normalize_slurm_time()``, so ``"2h"``, ``"120"`` (minutes), and + ``"02:00:00"`` are all accepted and stored as ``"02:00:00"``. + + Parameters + ---------- + account : str + SLURM account (inherited from ``BaseSlurmConfig``). + partition : str + SLURM partition (inherited). Defaults to ``"cpu"``. + qos : str or None + QOS policy (inherited). + constraint : str or None + Node constraint (inherited). + time : str + Job time limit. Maps to SLURM ``--time``. + Accepts human-friendly strings (``"2h"``, ``"30m"``, ``"1d"``) as + well as ``MM``, ``HH:MM:SS``, and ``D-HH:MM:SS`` formats. + cpus_per_task : int + CPUs allocated per task. Maps to SLURM ``--cpus-per-task``. + **Not** the same as ``--cpus-per-node`` used by Parsl executor configs. + mem_gb : int + Memory per task in gigabytes. Maps to SLURM ``--mem``. + job_name_prefix : str + Prefix for SLURM job names submitted via submitit. + + Examples + -------- + Minimal construction (explicit account): + + >>> cfg = SlurmConfig(account="mygroup") + >>> cfg.time + '02:00:00' + + Autodiscovery on a SLURM cluster: + + >>> cfg = SlurmConfig.from_cluster(time="4h", cpus_per_task=8, mem_gb=16) + """ + + time: str = Field(default="2h", validate_default=True) + cpus_per_task: int = 4 + """Maps to SLURM ``--cpus-per-task`` (NOT ``--cpus-per-node``).""" + mem_gb: int = 8 + job_name_prefix: str = "mdfactory-analysis" + + @field_validator("time", mode="before") + @classmethod + def _normalize_time(cls, v: str) -> str: + """Normalize time string on construction.""" + return normalize_slurm_time(v) + + @classmethod + def from_yaml(cls, path: Path | str) -> "SlurmConfig": + """Load a ``SlurmConfig`` from a YAML file. + + Parameters + ---------- + path : Path + Path to a YAML file whose top-level keys match ``SlurmConfig`` + field names. + + Returns + ------- + SlurmConfig + Loaded and validated instance. + + Raises + ------ + FileNotFoundError + If the YAML file does not exist. + pydantic.ValidationError + If the YAML content fails validation. + + Examples + -------- + .. code-block:: yaml + + # slurm.yaml + account: mygroup + partition: cpu + time: 4h + cpus_per_task: 8 + mem_gb: 16 + + >>> cfg = SlurmConfig.from_yaml(Path("slurm.yaml")) + >>> cfg.time + '04:00:00' + """ + import yaml + + path = Path(path) + if not path.exists(): + raise FileNotFoundError(f"SLURM config YAML not found: {path}") + with path.open() as fh: + data = yaml.safe_load(fh) + return cls.model_validate(data or {}) diff --git a/mdfactory/settings.py b/mdfactory/settings.py index 7c99a42..d0773b4 100644 --- a/mdfactory/settings.py +++ b/mdfactory/settings.py @@ -70,6 +70,12 @@ def _get_defaults() -> dict[str, dict[str, str]]: "ANALYSIS_DB_PATH": "/Group Functions/mdfactory/analysis", "ARTIFACT_DB_PATH": "/Group Functions/mdfactory/artifacts", }, + "slurm": { + "ACCOUNT": "", + "PARTITION_CPU": "", + "PARTITION_GPU": "", + "DEFAULT_QOS": "", + }, } # Keep DEFAULT_CONFIG as class attribute for backward compat in tests @@ -96,6 +102,12 @@ def _get_defaults() -> dict[str, dict[str, str]]: "ANALYSIS_DB_PATH": "/Group Functions/mdfactory/analysis", "ARTIFACT_DB_PATH": "/Group Functions/mdfactory/artifacts", }, + "slurm": { + "ACCOUNT": "", + "PARTITION_CPU": "", + "PARTITION_GPU": "", + "DEFAULT_QOS": "", + }, } def __init__(self): @@ -312,6 +324,43 @@ def get_csv_path(self, db_name: str) -> str: raw_path = self.config["csv"].get(path_key, "") return self._resolve_local_path(raw_path) + # --- SLURM properties --- + + @property + def slurm_account(self) -> str | None: + """Return configured SLURM account, or None if not set.""" + val = self.config.get("slurm", "ACCOUNT", fallback="").strip() + return val if val else None + + @property + def slurm_partition_cpu(self) -> str | None: + """Return configured CPU partition name, or None if not set. + + Maps to ``[slurm] PARTITION_CPU`` in config.ini. + Used by ``BaseSlurmConfig.from_cluster()`` when ``needs_gpu=False``. + """ + val = self.config.get("slurm", "PARTITION_CPU", fallback="").strip() + return val if val else None + + @property + def slurm_partition_gpu(self) -> str | None: + """Return configured GPU partition name, or None if not set. + + Maps to ``[slurm] PARTITION_GPU`` in config.ini. + Used by ``BaseSlurmConfig.from_cluster()`` when ``needs_gpu=True``. + """ + val = self.config.get("slurm", "PARTITION_GPU", fallback="").strip() + return val if val else None + + @property + def slurm_qos(self) -> str | None: + """Return configured SLURM default QOS, or None if not set. + + Maps to ``[slurm] DEFAULT_QOS`` in config.ini. + """ + val = self.config.get("slurm", "DEFAULT_QOS", fallback="").strip() + return val if val else None + # Module-level singleton settings = Settings() diff --git a/mdfactory/tests/test_chemistry_extractors.py b/mdfactory/tests/test_chemistry_extractors.py new file mode 100644 index 0000000..2cc62e3 --- /dev/null +++ b/mdfactory/tests/test_chemistry_extractors.py @@ -0,0 +1,256 @@ +# ABOUTME: Tests for chemistry extraction utilities (extract_all_species, +# ABOUTME: get_chemistry_extractor) and SimulationStore convenience methods. +"""Tests for chemistry extraction utilities.""" + +from unittest.mock import Mock, patch + +import pandas as pd +import pytest + +from mdfactory.analysis.utils import extract_all_species, get_chemistry_extractor +from mdfactory.models.input import BuildInput + + +def _make_species(resname, count, fraction, smiles=None): + """Create a mock species object.""" + sp = Mock() + sp.resname = resname + sp.count = count + sp.fraction = fraction + sp.smiles = smiles + return sp + + +def _make_build_input(species, simulation_type="bilayer"): + """Create a mock BuildInput with the given species.""" + bi = Mock(spec=BuildInput) + bi.simulation_type = simulation_type + bi.system = Mock() + bi.system.species = species + bi.system.total_count = sum(sp.count for sp in species) + bi.hash = "TESTHASH" + bi.tags = None + return bi + + +# --------------------------------------------------------------------------- +# extract_all_species tests +# --------------------------------------------------------------------------- + + +class TestExtractAllSpecies: + """Tests for extract_all_species.""" + + def test_basic_extraction(self): + """Test extracting species with SMILES.""" + species = [ + _make_species("HL", 100, 0.5, smiles="CCCCCC"), + _make_species("CHL", 100, 0.5, smiles="C1CCCCC1"), + ] + bi = _make_build_input(species) + + result = extract_all_species(bi) + + assert result["HL_count"] == 100 + assert result["HL_fraction"] == 0.5 + assert result["HL_smiles"] == "CCCCCC" + assert result["CHL_count"] == 100 + assert result["CHL_fraction"] == 0.5 + assert result["CHL_smiles"] == "C1CCCCC1" + assert result["total_species_count"] == 2 + assert result["total_molecule_count"] == 200 + + def test_no_species(self): + """Test extraction when no species are present.""" + bi = _make_build_input([]) + + result = extract_all_species(bi) + + assert result["total_species_count"] == 0 + assert result["total_molecule_count"] == 0 + + def test_species_without_smiles(self): + """Test extraction for species lacking a smiles attribute.""" + sp = Mock() + sp.resname = "WAT" + sp.count = 5000 + sp.fraction = 1.0 + # Deliberately delete smiles to test getattr fallback + del sp.smiles + + bi = _make_build_input([sp]) + + result = extract_all_species(bi) + + assert result["WAT_count"] == 5000 + assert result["WAT_fraction"] == 1.0 + assert result["WAT_smiles"] is None + assert result["total_species_count"] == 1 + assert result["total_molecule_count"] == 5000 + + def test_multiple_species_with_smiles(self): + """Test extraction with multiple species all having SMILES.""" + species = [ + _make_species("ILN", 50, 0.25, smiles="CC(=O)O"), + _make_species("ILP", 50, 0.25, smiles="CC(=O)[O-]"), + _make_species("HL", 60, 0.30, smiles="CCCCCC"), + _make_species("CHL", 40, 0.20, smiles="OC1CCC2C1CCC1C3CCC(O)CC3CCC12"), + ] + bi = _make_build_input(species) + + result = extract_all_species(bi) + + assert result["total_species_count"] == 4 + assert result["total_molecule_count"] == 200 + assert result["ILN_smiles"] == "CC(=O)O" + assert result["ILP_smiles"] == "CC(=O)[O-]" + + +# --------------------------------------------------------------------------- +# get_chemistry_extractor tests +# --------------------------------------------------------------------------- + + +class TestGetChemistryExtractor: + """Tests for get_chemistry_extractor.""" + + def test_mode_all(self): + """Test mode='all' returns extract_all_species.""" + extractor = get_chemistry_extractor(mode="all") + assert extractor is extract_all_species + + def test_mode_lnp(self): + """Test mode='lnp' returns a working LNP extractor.""" + from mdfactory.analysis.utils import extract_lnp_chemistry + + extractor = get_chemistry_extractor(mode="lnp") + assert extractor is extract_lnp_chemistry + + def test_mode_custom(self): + """Test mode='custom' creates an extractor from species_groups.""" + groups = {"lipid": ["HL"], "sterol": ["CHL"]} + extractor = get_chemistry_extractor(mode="custom", species_groups=groups) + + species = [ + _make_species("HL", 80, 0.4, smiles="CCCC"), + _make_species("CHL", 120, 0.6, smiles="C1CCCCC1"), + ] + bi = _make_build_input(species) + + result = extractor(bi) + + assert result["lipid_count"] == 80 + assert result["lipid_fraction"] == 0.4 + assert result["lipid_smiles"] == "CCCC" + assert result["sterol_count"] == 120 + assert result["sterol_fraction"] == 0.6 + assert result["sterol_smiles"] == "C1CCCCC1" + + def test_mode_custom_without_groups_raises(self): + """Test mode='custom' without species_groups raises ValueError.""" + with pytest.raises(ValueError, match="species_groups required"): + get_chemistry_extractor(mode="custom") + + def test_mode_invalid_raises(self): + """Test invalid mode raises ValueError.""" + with pytest.raises(ValueError, match="Unknown mode"): + get_chemistry_extractor(mode="bogus") + + +# --------------------------------------------------------------------------- +# SimulationStore.build_all_species_table convenience method +# --------------------------------------------------------------------------- + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_build_all_species_table(mock_discover, tmp_path): + """Test SimulationStore.build_all_species_table integration.""" + from mdfactory.analysis.simulation import Simulation + from mdfactory.analysis.store import SimulationStore + + species = [ + _make_species("HL", 100, 0.5, smiles="CCCC"), + _make_species("CHL", 100, 0.5, smiles="C1CCCCC1"), + ] + mock_bi = _make_build_input(species, simulation_type="bilayer") + + sim_dir = tmp_path / "sim1" + sim_dir.mkdir() + (sim_dir / "system.pdb").touch() + (sim_dir / "prod.xtc").touch() + sim = Simulation(sim_dir, build_input=mock_bi) + + mock_discover.return_value = pd.DataFrame( + { + "hash": ["TESTHASH"], + "path": [sim_dir], + "simulation": [sim], + } + ) + + store = SimulationStore(tmp_path) + store.discover() + + table = store.build_all_species_table() + + assert len(table) == 1 + assert "HL_count" in table.columns + assert "CHL_count" in table.columns + assert "HL_smiles" in table.columns + assert "total_species_count" in table.columns + assert "total_molecule_count" in table.columns + assert table.iloc[0]["HL_count"] == 100 + assert table.iloc[0]["CHL_smiles"] == "C1CCCCC1" + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_build_chemistry_table_modes(mock_discover, tmp_path): + """Test SimulationStore.build_chemistry_table with different modes.""" + from mdfactory.analysis.simulation import Simulation + from mdfactory.analysis.store import SimulationStore + + species = [ + _make_species("HL", 60, 0.3, smiles="CCCC"), + _make_species("CHL", 40, 0.2, smiles="OC1CC1"), + _make_species("ILN", 50, 0.25, smiles="CC=O"), + _make_species("ILP", 50, 0.25, smiles="CC[O-]"), + ] + mock_bi = _make_build_input(species, simulation_type="bilayer") + + sim_dir = tmp_path / "sim1" + sim_dir.mkdir() + (sim_dir / "system.pdb").touch() + (sim_dir / "prod.xtc").touch() + sim = Simulation(sim_dir, build_input=mock_bi) + + mock_discover.return_value = pd.DataFrame( + { + "hash": ["TESTHASH"], + "path": [sim_dir], + "simulation": [sim], + } + ) + + store = SimulationStore(tmp_path) + store.discover() + + # mode="all" — every species gets its own columns + table_all = store.build_chemistry_table(mode="all") + assert "HL_count" in table_all.columns + assert "ILN_count" in table_all.columns + assert "total_species_count" in table_all.columns + + # mode="lnp" — grouped columns + table_lnp = store.build_chemistry_table(mode="lnp") + assert "HL_count" in table_lnp.columns + assert "CHL_count" in table_lnp.columns + assert "IL_count" in table_lnp.columns # merged ILN+ILP + assert table_lnp.iloc[0]["IL_count"] == 100 + + # mode="custom" + table_custom = store.build_chemistry_table( + mode="custom", + species_groups={"lipids": ["HL", "CHL"]}, + ) + assert "lipids_count" in table_custom.columns + assert table_custom.iloc[0]["lipids_count"] == 100 # 60 + 40 diff --git a/mdfactory/tests/test_chemistry_search.py b/mdfactory/tests/test_chemistry_search.py new file mode 100644 index 0000000..5bb3141 --- /dev/null +++ b/mdfactory/tests/test_chemistry_search.py @@ -0,0 +1,40 @@ +# ABOUTME: Tests for SMILES substructure matching utility +# ABOUTME: Validates chemical substructure search for simulation filtering +"""Tests for SMILES substructure matching.""" + +import pytest + +from mdfactory.utils.chemistry_utilities import smiles_substructure_match + + +def test_substructure_match_basic(): + """Test basic substructure matching.""" + # Ethanol is substructure of propanol + assert smiles_substructure_match("CCO", "CCCO") + # Methanol in ethanol + assert smiles_substructure_match("CO", "CCO") + # Exact match + assert smiles_substructure_match("CCO", "CCO") + + +def test_substructure_no_match(): + """Test non-matching substructures.""" + # Benzene not in propane + assert not smiles_substructure_match("c1ccccc1", "CCC") + # Nitrogen not in ethanol + assert not smiles_substructure_match("N", "CCO") + + +def test_substructure_match_ring(): + """Test ring substructure matching.""" + # Benzene in toluene + assert smiles_substructure_match("c1ccccc1", "Cc1ccccc1") + + +def test_substructure_invalid_smiles(): + """Test that invalid SMILES raises ValueError.""" + with pytest.raises(ValueError, match="Invalid query SMILES"): + smiles_substructure_match("INVALID_SMILES_XYZ", "CCO") + + with pytest.raises(ValueError, match="Invalid target SMILES"): + smiles_substructure_match("CCO", "INVALID_SMILES_XYZ") diff --git a/mdfactory/tests/test_cli_cluster.py b/mdfactory/tests/test_cli_cluster.py new file mode 100644 index 0000000..cc87ee4 --- /dev/null +++ b/mdfactory/tests/test_cli_cluster.py @@ -0,0 +1,320 @@ +# ABOUTME: Tests for the `mdfactory config cluster` CLI command, verifying +# ABOUTME: SLURM autodiscovery output in human-readable and JSON formats. +# ABOUTME: Also covers the SLURM config-building glue in analysis_run / analysis_artifacts_run. +"""Tests for mdfactory config cluster CLI command and analysis SLURM glue.""" + +import json +from pathlib import Path +from unittest.mock import patch + +import pandas as pd +import pytest + +from mdfactory import cli +from mdfactory.performance import cluster as cluster_mod +from mdfactory.performance.slurm_config import SlurmConfig + + +class TestConfigClusterCommand: + """Tests for the config cluster CLI command.""" + + def test_config_cluster_no_slurm(self, monkeypatch, capsys): + """Test graceful message when SLURM is not available.""" + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: None) + + cli.config_cluster(json_output=False) + + captured = capsys.readouterr() + assert "SLURM cluster not detected" in captured.out + assert "login node" in captured.out + + def test_config_cluster_no_slurm_json(self, monkeypatch, capsys): + """Test JSON output when SLURM is not available.""" + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: None) + + cli.config_cluster(json_output=True) + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["cluster"] is None + assert "error" in data + + def test_config_cluster_with_slurm(self, monkeypatch, capsys): + """Test human-readable output with SLURM cluster.""" + mock_partition = cluster_mod.Partition( + name="compute", + state="up", + max_time="7-00:00:00", + default_time="1:00:00", + node_types=[ + cluster_mod.NodeType(cpus=64, memory_mb=256 * 1024, gpu_specs=(), count=100), + ], + total_nodes=100, + is_default=True, + ) + gpu_partition = cluster_mod.Partition( + name="gpu", + state="up", + max_time="2-00:00:00", + default_time="1:00:00", + node_types=[ + cluster_mod.NodeType( + cpus=32, memory_mb=128 * 1024, gpu_specs=((4, "a100"),), count=20 + ), + ], + total_nodes=20, + is_default=False, + ) + mock_cluster = cluster_mod.ClusterInfo( + partitions=[mock_partition, gpu_partition], + accounts=["myaccount", "shared"], + qos_policies=["normal", "high"], + default_account="myaccount", + ) + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: mock_cluster) + + cli.config_cluster(json_output=False) + + captured = capsys.readouterr() + assert "SLURM Cluster Information" in captured.out + assert "Default Account: myaccount" in captured.out + assert "compute" in captured.out + assert "(default)" in captured.out + assert "gpu" in captured.out + assert "a100" in captured.out + assert "64 CPUs" in captured.out + assert "4x a100" in captured.out + + def test_config_cluster_with_slurm_json(self, monkeypatch, capsys): + """Test JSON output with SLURM cluster.""" + mock_partition = cluster_mod.Partition( + name="compute", + state="up", + max_time="7-00:00:00", + default_time="1:00:00", + node_types=[ + cluster_mod.NodeType( + cpus=64, + memory_mb=256 * 1024, + gpu_specs=(), + features=("avx512", "intel"), + count=100, + ), + ], + total_nodes=100, + is_default=True, + ) + mock_cluster = cluster_mod.ClusterInfo( + partitions=[mock_partition], + accounts=["myaccount"], + qos_policies=["normal"], + default_account="myaccount", + ) + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: mock_cluster) + + cli.config_cluster(json_output=True) + + captured = capsys.readouterr() + data = json.loads(captured.out) + + assert data["default_account"] == "myaccount" + assert data["accounts"] == ["myaccount"] + assert data["qos_policies"] == ["normal"] + assert len(data["partitions"]) == 1 + + part = data["partitions"][0] + assert part["name"] == "compute" + assert part["is_default"] is True + assert part["total_nodes"] == 100 + assert len(part["node_types"]) == 1 + + nt = part["node_types"][0] + assert nt["cpus"] == 64 + assert nt["memory_mb"] == 256 * 1024 + assert nt["gpu_specs"] == [] # No GPUs + assert nt["features"] == ["avx512", "intel"] + assert nt["count"] == 100 + + def test_config_cluster_down_partition(self, monkeypatch, capsys): + """Test display of partition with down state.""" + mock_partition = cluster_mod.Partition( + name="maintenance", + state="drained", + max_time="1:00:00", + default_time="0:30:00", + node_types=[cluster_mod.NodeType(cpus=16, memory_mb=32 * 1024, count=5)], + total_nodes=5, + is_default=False, + ) + mock_cluster = cluster_mod.ClusterInfo( + partitions=[mock_partition], + accounts=["myaccount"], + qos_policies=[], + default_account="myaccount", + ) + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: mock_cluster) + + cli.config_cluster(json_output=False) + + captured = capsys.readouterr() + assert "maintenance" in captured.out + assert "[drained]" in captured.out + + +# --------------------------------------------------------------------------- +# Helpers shared by analysis_run / analysis_artifacts_run glue tests +# --------------------------------------------------------------------------- + +_FAKE_CFG = SlurmConfig(account="auto-account", partition="auto-part") +_EMPTY_DF = pd.DataFrame() + + +@pytest.fixture() +def fake_sim_paths(tmp_path: Path) -> list[Path]: + """Return a non-empty list of paths without touching the filesystem.""" + return [tmp_path / "sim1"] + + +# --------------------------------------------------------------------------- +# Tests: analysis_run SLURM config-building glue +# --------------------------------------------------------------------------- + + +class TestAnalysisRunSlurmGlue: + """Tests for the autodiscovery glue inside analysis_run (--slurm mode).""" + + def test_autodiscovery_used_when_no_account(self, monkeypatch, fake_sim_paths): + """account=None triggers SlurmConfig.from_cluster(); result is forwarded.""" + captured = {} + + def fake_submit(sim_paths, analysis_names, *, slurm, **kwargs): + captured["slurm"] = slurm + return _EMPTY_DF + + with ( + patch("mdfactory.cli._resolve_sim_paths", return_value=fake_sim_paths), + patch("mdfactory.cli.SlurmConfig.from_cluster", return_value=_FAKE_CFG) as mock_fc, + patch("mdfactory.cli.submit_analyses_slurm", side_effect=fake_submit), + patch("mdfactory.cli.determine_log_dir", return_value=fake_sim_paths[0].parent), + ): + cli.analysis_run( + source=fake_sim_paths[0].parent, + slurm=True, + account=None, + ) + + mock_fc.assert_called_once() + assert captured["slurm"] is _FAKE_CFG + + def test_autodiscovery_error_reraised_as_value_error(self, monkeypatch, fake_sim_paths): + """RuntimeError from from_cluster() is re-raised as ValueError with guidance.""" + with ( + patch("mdfactory.cli._resolve_sim_paths", return_value=fake_sim_paths), + patch( + "mdfactory.cli.SlurmConfig.from_cluster", + side_effect=RuntimeError("no account"), + ), + ): + with pytest.raises(ValueError, match="Please specify --account explicitly"): + cli.analysis_run( + source=fake_sim_paths[0].parent, + slurm=True, + account=None, + ) + + def test_explicit_account_skips_autodiscovery(self, monkeypatch, fake_sim_paths): + """When account is provided, from_cluster() is never called.""" + captured = {} + + def fake_submit(sim_paths, analysis_names, *, slurm, **kwargs): + captured["slurm"] = slurm + return _EMPTY_DF + + with ( + patch("mdfactory.cli._resolve_sim_paths", return_value=fake_sim_paths), + patch("mdfactory.cli.SlurmConfig.from_cluster") as mock_fc, + patch("mdfactory.cli.submit_analyses_slurm", side_effect=fake_submit), + patch("mdfactory.cli.determine_log_dir", return_value=fake_sim_paths[0].parent), + ): + cli.analysis_run( + source=fake_sim_paths[0].parent, + slurm=True, + account="explicit-account", + ) + + mock_fc.assert_not_called() + assert captured["slurm"].account == "explicit-account" + + +# --------------------------------------------------------------------------- +# Tests: analysis_artifacts_run SLURM config-building glue +# --------------------------------------------------------------------------- + + +class TestAnalysisArtifactsRunSlurmGlue: + """Tests for the autodiscovery glue inside analysis_artifacts_run (--slurm mode).""" + + def test_autodiscovery_used_when_no_account(self, monkeypatch, fake_sim_paths): + """account=None triggers SlurmConfig.from_cluster(); result is forwarded.""" + captured = {} + + def fake_submit(sim_paths, artifact_names, *, slurm, **kwargs): + captured["slurm"] = slurm + return _EMPTY_DF + + with ( + patch("mdfactory.cli._resolve_sim_paths", return_value=fake_sim_paths), + patch("mdfactory.cli.SlurmConfig.from_cluster", return_value=_FAKE_CFG) as mock_fc, + patch("mdfactory.cli.submit_artifacts_slurm", side_effect=fake_submit), + patch("mdfactory.cli.determine_log_dir", return_value=fake_sim_paths[0].parent), + ): + cli.analysis_artifacts_run( + source=fake_sim_paths[0].parent, + slurm=True, + account=None, + ) + + mock_fc.assert_called_once() + assert captured["slurm"] is _FAKE_CFG + + def test_autodiscovery_error_reraised_as_value_error(self, monkeypatch, fake_sim_paths): + """RuntimeError from from_cluster() is re-raised as ValueError with guidance.""" + with ( + patch("mdfactory.cli._resolve_sim_paths", return_value=fake_sim_paths), + patch( + "mdfactory.cli.SlurmConfig.from_cluster", + side_effect=RuntimeError("no account"), + ), + ): + with pytest.raises(ValueError, match="Please specify --account explicitly"): + cli.analysis_artifacts_run( + source=fake_sim_paths[0].parent, + slurm=True, + account=None, + ) + + def test_explicit_account_skips_autodiscovery(self, monkeypatch, fake_sim_paths): + """When account is provided, from_cluster() is never called.""" + captured = {} + + def fake_submit(sim_paths, artifact_names, *, slurm, **kwargs): + captured["slurm"] = slurm + return _EMPTY_DF + + with ( + patch("mdfactory.cli._resolve_sim_paths", return_value=fake_sim_paths), + patch("mdfactory.cli.SlurmConfig.from_cluster") as mock_fc, + patch("mdfactory.cli.submit_artifacts_slurm", side_effect=fake_submit), + patch("mdfactory.cli.determine_log_dir", return_value=fake_sim_paths[0].parent), + ): + cli.analysis_artifacts_run( + source=fake_sim_paths[0].parent, + slurm=True, + account="explicit-account", + ) + + mock_fc.assert_not_called() + assert captured["slurm"].account == "explicit-account" diff --git a/mdfactory/tests/test_cluster.py b/mdfactory/tests/test_cluster.py new file mode 100644 index 0000000..277ce2f --- /dev/null +++ b/mdfactory/tests/test_cluster.py @@ -0,0 +1,541 @@ +# ABOUTME: Unit tests for mdfactory.performance.cluster (SLURM autodiscovery). +# ABOUTME: Uses mocked sinfo/sacctmgr output — no SLURM required to run. +"""Tests for SLURM cluster autodiscovery.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from mdfactory.performance.cluster import ( + ClusterInfo, + NodeType, + Partition, + _parse_accounts, + _parse_gres, + _parse_memory_mb, + _parse_qos, + _parse_sinfo, + discover_cluster, + select_partition, +) + +# --------------------------------------------------------------------------- +# Fixtures: realistic sinfo / sacctmgr output +# --------------------------------------------------------------------------- + +# 9-field format: Partition Node CPUs Mem GRES Features MaxTime DefTime State +SINFO_OUTPUT_MIXED = """\ +cpu* node001 128 512000 (null) epyc9555,avx512 3-00:00:00 1-00:00:00 idle +cpu* node002 128 512000 (null) epyc9555,avx512 3-00:00:00 1-00:00:00 mixed +cpu* node003 128 512000 (null) epyc9555,avx512 3-00:00:00 1-00:00:00 allocated +gpu node010 64 256000 gpu:a100:4 a100,nvlink 1-00:00:00 4:00:00 idle +gpu node011 64 256000 gpu:a100:4 a100,nvlink 1-00:00:00 4:00:00 idle +gpu node012 96 512000 gpu:h100:8 h100,nvlink 1-00:00:00 4:00:00 mixed +bigmem node020 256 2048000 (null) bigmem,epyc 7-00:00:00 1-00:00:00 idle +""" + +SINFO_OUTPUT_SINGLE_PARTITION = """\ +compute node001 64 128000 (null) (null) 2-00:00:00 1-00:00:00 idle +compute node002 64 128000 (null) (null) 2-00:00:00 1-00:00:00 idle +""" + +SINFO_OUTPUT_GPU_ONLY = """\ +gpu-short node001 32 64000 gpu:v100:2 v100 4:00:00 2:00:00 idle +gpu-short node002 32 64000 gpu:v100:2 v100 4:00:00 2:00:00 idle +gpu-long node003 64 128000 gpu:a100:4 a100 2-00:00:00 4:00:00 idle +""" + +SINFO_OUTPUT_NO_TYPE_GPU = """\ +gpu node001 64 256000 gpu:4 (null) 1-00:00:00 4:00:00 idle +""" + +# First node drained, rest healthy → partition should be "up" +SINFO_OUTPUT_MIXED_HEALTH = """\ +cpu node001 64 128000 (null) (null) 2-00:00:00 1-00:00:00 drained +cpu node002 64 128000 (null) (null) 2-00:00:00 1-00:00:00 idle +cpu node003 64 128000 (null) (null) 2-00:00:00 1-00:00:00 idle +""" + +# All nodes unhealthy → partition should report unhealthy state +SINFO_OUTPUT_ALL_DOWN = """\ +cpu node001 64 128000 (null) (null) 2-00:00:00 1-00:00:00 down +cpu node002 64 128000 (null) (null) 2-00:00:00 1-00:00:00 drained +""" + +# Legacy 8-field format (no %L default time) — backward compatibility +SINFO_OUTPUT_LEGACY_8FIELD = """\ +compute node001 64 128000 (null) (null) 2-00:00:00 idle +compute node002 64 128000 (null) (null) 2-00:00:00 idle +""" + +SACCTMGR_ACCOUNTS = """\ +myproject +shared-account +default-account +""" + +SACCTMGR_QOS = """\ +normal|| +high|1-00:00:00|cpu=128,mem=512G +gpu|12:00:00|cpu=64,gres/gpu=4 +""" + + +# --------------------------------------------------------------------------- +# Tests: GRES parsing +# --------------------------------------------------------------------------- + + +class TestParseGres: + """Test GPU GRES string parsing.""" + + def test_gpu_with_type_and_count(self): + assert _parse_gres("gpu:a100:4") == [(4, "a100")] + + def test_gpu_with_count_only(self): + assert _parse_gres("gpu:2") == [(2, None)] + + def test_gpu_with_type_only(self): + assert _parse_gres("gpu:h100") == [(1, "h100")] + + def test_null_gres(self): + assert _parse_gres("(null)") == [] + + def test_empty_string(self): + assert _parse_gres("") == [] + + def test_multi_gres_with_gpu(self): + assert _parse_gres("mps:shared,gpu:a100:4") == [(4, "a100")] + + def test_non_gpu_gres(self): + assert _parse_gres("mps:shared") == [] + + def test_multiple_gpu_types(self): + """Test multiple GPU entries (e.g., MIG slices).""" + result = _parse_gres("gpu:b200:7,gpu:1g.23gb:7") + assert len(result) == 2 + assert (7, "1g.23gb") in result + assert (7, "b200") in result + + def test_socket_binding_stripped(self): + """Test that socket binding suffixes are removed.""" + assert _parse_gres("gpu:l40s:4(S:0-1)") == [(4, "l40s")] + assert _parse_gres("gpu:b200:8(S:0-1),gpu:1g.23gb:7(S:1)") == [(8, "b200"), (7, "1g.23gb")] + + +# --------------------------------------------------------------------------- +# Tests: sinfo parsing +# --------------------------------------------------------------------------- + + +class TestParseSinfo: + """Test sinfo output parsing into Partition objects.""" + + def test_mixed_cluster(self): + partitions = _parse_sinfo(SINFO_OUTPUT_MIXED) + + # Should find 3 partitions + assert len(partitions) == 3 + names = [p.name for p in partitions] + assert "cpu" in names + assert "gpu" in names + assert "bigmem" in names + + def test_default_partition_marker(self): + partitions = _parse_sinfo(SINFO_OUTPUT_MIXED) + + cpu_part = next(p for p in partitions if p.name == "cpu") + assert cpu_part.is_default is True + + gpu_part = next(p for p in partitions if p.name == "gpu") + assert gpu_part.is_default is False + + def test_default_partition_sorted_first(self): + partitions = _parse_sinfo(SINFO_OUTPUT_MIXED) + assert partitions[0].name == "cpu" + assert partitions[0].is_default is True + + def test_cpu_partition_node_types(self): + partitions = _parse_sinfo(SINFO_OUTPUT_MIXED) + cpu_part = next(p for p in partitions if p.name == "cpu") + + # All 3 nodes have same spec → 1 unique node type + assert len(cpu_part.node_types) == 1 + nt = cpu_part.node_types[0] + assert nt.cpus == 128 + assert nt.memory_mb == 512000 + assert nt.gpu_specs == () # No GPUs + assert "epyc9555" in nt.features + assert nt.count == 3 # 3 nodes with this config + + def test_gpu_partition_multiple_node_types(self): + partitions = _parse_sinfo(SINFO_OUTPUT_MIXED) + gpu_part = next(p for p in partitions if p.name == "gpu") + + # 2 distinct node types: a100 (64 core) and h100 (96 core) + assert len(gpu_part.node_types) == 2 + + a100_node = next(n for n in gpu_part.node_types if (4, "a100") in n.gpu_specs) + assert a100_node.cpus == 64 + assert a100_node.gpu_specs == ((4, "a100"),) + assert a100_node.count == 2 # 2 a100 nodes + + h100_node = next(n for n in gpu_part.node_types if (8, "h100") in n.gpu_specs) + assert h100_node.cpus == 96 + assert h100_node.gpu_specs == ((8, "h100"),) + assert h100_node.count == 1 # 1 h100 node + + def test_total_node_count(self): + partitions = _parse_sinfo(SINFO_OUTPUT_MIXED) + + cpu_part = next(p for p in partitions if p.name == "cpu") + assert cpu_part.total_nodes == 3 + + gpu_part = next(p for p in partitions if p.name == "gpu") + assert gpu_part.total_nodes == 3 + + bigmem_part = next(p for p in partitions if p.name == "bigmem") + assert bigmem_part.total_nodes == 1 + + def test_time_limit_parsed(self): + partitions = _parse_sinfo(SINFO_OUTPUT_MIXED) + cpu_part = next(p for p in partitions if p.name == "cpu") + assert cpu_part.max_time == "3-00:00:00" + + def test_single_partition(self): + partitions = _parse_sinfo(SINFO_OUTPUT_SINGLE_PARTITION) + assert len(partitions) == 1 + assert partitions[0].name == "compute" + assert partitions[0].total_nodes == 2 + + def test_gpu_without_type(self): + partitions = _parse_sinfo(SINFO_OUTPUT_NO_TYPE_GPU) + assert len(partitions) == 1 + nt = partitions[0].node_types[0] + assert nt.gpu_specs == ((4, None),) # 4 GPUs, no type specified + + def test_default_time_parsed_separately(self): + partitions = _parse_sinfo(SINFO_OUTPUT_MIXED) + cpu_part = next(p for p in partitions if p.name == "cpu") + assert cpu_part.max_time == "3-00:00:00" + assert cpu_part.default_time == "1-00:00:00" + + gpu_part = next(p for p in partitions if p.name == "gpu") + assert gpu_part.max_time == "1-00:00:00" + assert gpu_part.default_time == "4:00:00" + + def test_legacy_8field_format(self): + """Parser handles legacy 8-field sinfo output (no %L).""" + partitions = _parse_sinfo(SINFO_OUTPUT_LEGACY_8FIELD) + assert len(partitions) == 1 + assert partitions[0].name == "compute" + # default_time falls back to max_time + assert partitions[0].default_time == partitions[0].max_time + + def test_partition_state_up_when_any_node_healthy(self): + """Partition with mixed healthy/unhealthy nodes should be 'up'.""" + partitions = _parse_sinfo(SINFO_OUTPUT_MIXED_HEALTH) + assert len(partitions) == 1 + assert partitions[0].state == "up" + + def test_partition_state_unhealthy_when_all_nodes_down(self): + """Partition with no healthy nodes reports unhealthy state.""" + partitions = _parse_sinfo(SINFO_OUTPUT_ALL_DOWN) + assert len(partitions) == 1 + assert partitions[0].state != "up" + assert partitions[0].state in ("down", "drained") + + def test_empty_output(self): + partitions = _parse_sinfo("") + assert partitions == [] + + def test_malformed_lines_skipped(self): + output = "this is not valid sinfo output\n" + SINFO_OUTPUT_SINGLE_PARTITION + partitions = _parse_sinfo(output) + # Should still parse valid lines + assert len(partitions) == 1 + + def test_memory_trailing_plus_stripped(self): + """SLURM reports '128000+' when memory varies across nodes in a partition.""" + assert _parse_memory_mb("128000+") == 128000 + + +# --------------------------------------------------------------------------- +# Tests: account / QOS parsing +# --------------------------------------------------------------------------- + + +class TestParseAccounts: + """Test sacctmgr account output parsing.""" + + def test_multiple_accounts(self): + accounts = _parse_accounts(SACCTMGR_ACCOUNTS) + assert accounts == ["default-account", "myproject", "shared-account"] + + def test_empty_output(self): + assert _parse_accounts("") == [] + + def test_whitespace_handling(self): + assert _parse_accounts(" acc1 \n acc2 \n") == ["acc1", "acc2"] + + def test_deduplication(self): + assert _parse_accounts("acc1\nacc1\nacc2") == ["acc1", "acc2"] + + +class TestParseQos: + """Test sacctmgr QOS output parsing.""" + + def test_multiple_qos(self): + qos = _parse_qos(SACCTMGR_QOS) + assert qos == ["gpu", "high", "normal"] + + def test_empty_output(self): + assert _parse_qos("") == [] + + def test_pipe_separated_format(self): + qos = _parse_qos("standard|2-00:00:00|cpu=256\n") + assert qos == ["standard"] + + +# --------------------------------------------------------------------------- +# Tests: discover_cluster integration (mocked subprocess) +# --------------------------------------------------------------------------- + + +class TestDiscoverCluster: + """Test discover_cluster with mocked subprocess calls.""" + + def setup_method(self): + """Clear LRU cache between tests.""" + discover_cluster.cache_clear() + + def test_returns_none_without_sinfo(self): + with patch("mdfactory.performance.cluster.shutil.which", return_value=None): + result = discover_cluster() + assert result is None + + def test_returns_cluster_info_with_sinfo(self): + """Test full integration when SLURM commands return data.""" + partitions = _parse_sinfo(SINFO_OUTPUT_MIXED) + accounts = _parse_accounts(SACCTMGR_ACCOUNTS) + qos = _parse_qos(SACCTMGR_QOS) + + with ( + patch("mdfactory.performance.cluster.shutil.which", return_value="/usr/bin/sinfo"), + patch("mdfactory.performance.cluster._discover_partitions", return_value=partitions), + patch("mdfactory.performance.cluster._discover_accounts", return_value=accounts), + patch("mdfactory.performance.cluster._discover_qos", return_value=qos), + patch( + "mdfactory.performance.cluster._discover_default_account", return_value="myproject" + ), + ): + result = discover_cluster() + + assert result is not None + assert isinstance(result, ClusterInfo) + assert len(result.partitions) == 3 + assert len(result.accounts) == 3 + assert len(result.qos_policies) == 3 + assert result.default_account == "myproject" + + def test_default_account_falls_back_to_first(self): + """When default account query fails, fall back to first account.""" + partitions = _parse_sinfo(SINFO_OUTPUT_MIXED) + accounts = _parse_accounts(SACCTMGR_ACCOUNTS) + qos = _parse_qos(SACCTMGR_QOS) + + with ( + patch("mdfactory.performance.cluster.shutil.which", return_value="/usr/bin/sinfo"), + patch("mdfactory.performance.cluster._discover_partitions", return_value=partitions), + patch("mdfactory.performance.cluster._discover_accounts", return_value=accounts), + patch("mdfactory.performance.cluster._discover_qos", return_value=qos), + patch("mdfactory.performance.cluster._discover_default_account", return_value=None), + ): + result = discover_cluster() + + assert result is not None + # Falls back to first alphabetical account + assert result.default_account == "default-account" + + def test_graceful_without_sacctmgr(self): + """When sacctmgr fails, still return partitions.""" + partitions = _parse_sinfo(SINFO_OUTPUT_MIXED) + + with ( + patch("mdfactory.performance.cluster.shutil.which", return_value="/usr/bin/sinfo"), + patch("mdfactory.performance.cluster._discover_partitions", return_value=partitions), + patch("mdfactory.performance.cluster._discover_accounts", return_value=None), + patch("mdfactory.performance.cluster._discover_qos", return_value=None), + patch("mdfactory.performance.cluster._discover_default_account", return_value=None), + ): + result = discover_cluster() + + assert result is not None + assert len(result.partitions) == 3 + assert result.accounts == [] + assert result.qos_policies == [] + assert result.default_account is None + + def test_caching(self): + """Second call returns cached result without re-querying.""" + partitions = _parse_sinfo(SINFO_OUTPUT_SINGLE_PARTITION) + + with ( + patch("mdfactory.performance.cluster.shutil.which", return_value="/usr/bin/sinfo"), + patch( + "mdfactory.performance.cluster._discover_partitions", return_value=partitions + ) as mock_partitions, + patch("mdfactory.performance.cluster._discover_accounts", return_value=None), + patch("mdfactory.performance.cluster._discover_qos", return_value=None), + patch("mdfactory.performance.cluster._discover_default_account", return_value=None), + ): + result1 = discover_cluster() + result2 = discover_cluster() + + assert result1 is result2 + # _discover_partitions called only once due to caching + assert mock_partitions.call_count == 1 + + def test_returns_none_when_sinfo_fails(self): + """If sinfo exists but returns error, return None.""" + with ( + patch("mdfactory.performance.cluster.shutil.which", return_value="/usr/bin/sinfo"), + patch("mdfactory.performance.cluster._discover_partitions", return_value=None), + ): + result = discover_cluster() + assert result is None + + +# --------------------------------------------------------------------------- +# Tests: select_partition +# --------------------------------------------------------------------------- + + +class TestSelectPartition: + """Test heuristic partition selection.""" + + @pytest.fixture() + def cluster(self) -> ClusterInfo: + """Build a ClusterInfo from the mixed sinfo output.""" + partitions = _parse_sinfo(SINFO_OUTPUT_MIXED) + return ClusterInfo( + partitions=partitions, + accounts=["myproject"], + qos_policies=["normal"], + default_account="myproject", + ) + + def test_select_default_cpu_partition(self, cluster: ClusterInfo): + result = select_partition(cluster) + assert result is not None + assert result.name == "cpu" + + def test_select_gpu_partition(self, cluster: ClusterInfo): + result = select_partition(cluster, needs_gpu=True) + assert result is not None + assert result.name == "gpu" + + def test_select_with_high_cpu_requirement(self, cluster: ClusterInfo): + # Need 200+ CPUs → only bigmem (256) qualifies + result = select_partition(cluster, min_cpus=200) + assert result is not None + assert result.name == "bigmem" + + def test_select_with_high_memory_requirement(self, cluster: ClusterInfo): + # Need 1TB+ → only bigmem qualifies + result = select_partition(cluster, min_mem_gb=1500) + assert result is not None + assert result.name == "bigmem" + + def test_returns_none_when_impossible(self, cluster: ClusterInfo): + # Need 1000 CPUs — nobody has that + result = select_partition(cluster, min_cpus=1000) + assert result is None + + def test_returns_none_gpu_when_no_gpu_partition(self): + partitions = _parse_sinfo(SINFO_OUTPUT_SINGLE_PARTITION) + cluster = ClusterInfo(partitions=partitions) + result = select_partition(cluster, needs_gpu=True) + assert result is None + + def test_prefers_default_partition(self, cluster: ClusterInfo): + # Both cpu and bigmem meet min_cpus=1 — prefer cpu (default) + result = select_partition(cluster, min_cpus=1) + assert result is not None + assert result.name == "cpu" + + def test_gpu_with_min_cpus(self, cluster: ClusterInfo): + # Need GPU + 90 CPUs → only h100 node qualifies (96 cpus) + result = select_partition(cluster, needs_gpu=True, min_cpus=90) + assert result is not None + assert result.name == "gpu" + + def test_skips_down_partitions(self): + """Partitions where all nodes are down are not selectable.""" + partitions = _parse_sinfo(SINFO_OUTPUT_ALL_DOWN) + cluster = ClusterInfo(partitions=partitions) + result = select_partition(cluster) + assert result is None + + def test_node_count_tiebreak(self): + """When multiple partitions qualify, prefer the one with more nodes.""" + # SINFO_OUTPUT_GPU_ONLY: gpu-short (2 nodes) vs gpu-long (1 node), neither default. + # sort key (not is_default, -total_nodes, name) should pick gpu-short. + partitions = _parse_sinfo(SINFO_OUTPUT_GPU_ONLY) + cluster = ClusterInfo(partitions=partitions) + result = select_partition(cluster, needs_gpu=True) + assert result is not None + assert result.name == "gpu-short" + + +# --------------------------------------------------------------------------- +# Tests: dataclass properties +# --------------------------------------------------------------------------- + + +class TestDataclasses: + """Test dataclass construction and immutability.""" + + def test_node_type_frozen(self): + from pydantic import ValidationError + + nt = NodeType(cpus=64, memory_mb=256000, gpu_specs=((4, "a100"),), count=1) + with pytest.raises(ValidationError): + nt.cpus = 128 # type: ignore[misc] + + def test_partition_frozen(self): + from pydantic import ValidationError + + p = Partition(name="test", state="up", max_time="1-00:00:00", default_time="1:00:00") + with pytest.raises(ValidationError): + p.name = "other" # type: ignore[misc] + + def test_cluster_info_frozen(self): + from pydantic import ValidationError + + ci = ClusterInfo() + with pytest.raises(ValidationError): + ci.default_account = "hack" # type: ignore[misc] + + def test_node_type_defaults(self): + nt = NodeType(cpus=32, memory_mb=64000) + assert nt.gpu_specs == () # No GPUs by default + assert nt.features == () + assert nt.count == 1 # Default count + + def test_node_type_features_immutable(self): + nt = NodeType( + cpus=64, memory_mb=256000, gpu_specs=((4, "a100"),), features=("a100", "nvlink") + ) + assert nt.features == ("a100", "nvlink") + with pytest.raises(TypeError): + nt.features[0] = "other" # type: ignore[index] + + def test_cluster_info_defaults(self): + ci = ClusterInfo() + assert ci.partitions == [] + assert ci.accounts == [] + assert ci.qos_policies == [] + assert ci.default_account is None diff --git a/mdfactory/tests/test_models.py b/mdfactory/tests/test_models.py index 6657d5d..4ee7773 100644 --- a/mdfactory/tests/test_models.py +++ b/mdfactory/tests/test_models.py @@ -465,3 +465,111 @@ def test_shell_composition_counts_sum_correctly(): assert abs(ratios[0] - 0.33) < 0.05 assert abs(ratios[1] - 0.33) < 0.05 assert abs(ratios[2] - 0.34) < 0.05 + + +def test_build_input_tags_field(): + """Test that BuildInput accepts optional tags field.""" + spec = SingleMoleculeSpecies(smiles="CCC", count=100, resname="ABC") + comp = MixedBoxComposition(species=[spec], total_count=100) + + # Without tags + inp = BuildInput(simulation_type="mixedbox", system=comp) + assert inp.tags is None + + # With tags + inp_tagged = BuildInput( + simulation_type="mixedbox", + system=comp, + tags={"project": "test", "batch": "001"}, + ) + assert inp_tagged.tags == {"project": "test", "batch": "001"} + + +def test_build_input_tags_excluded_from_hash(): + """Test that tags do not affect hash computation.""" + spec = SingleMoleculeSpecies(smiles="CCC", count=100, resname="ABC") + comp = MixedBoxComposition(species=[spec], total_count=100) + + inp_no_tags = BuildInput(simulation_type="mixedbox", system=comp) + inp_none_tags = BuildInput(simulation_type="mixedbox", system=comp, tags=None) + inp_with_tags = BuildInput( + simulation_type="mixedbox", + system=comp, + tags={"project": "test"}, + ) + inp_diff_tags = BuildInput( + simulation_type="mixedbox", + system=comp, + tags={"project": "other", "batch": "002"}, + ) + + assert inp_no_tags.hash == inp_none_tags.hash + assert inp_no_tags.hash == inp_with_tags.hash + assert inp_no_tags.hash == inp_diff_tags.hash + + +def test_build_input_tags_roundtrip(): + """Test that tags survive to_data_row/from_data_row round-trip.""" + spec = SingleMoleculeSpecies(smiles="CCC", count=100, resname="ABC") + comp = MixedBoxComposition(species=[spec], total_count=100) + + inp = BuildInput( + simulation_type="mixedbox", + system=comp, + tags={"formulation_id": "F42", "project": "lnp_screen"}, + ) + + row = inp.to_data_row() + reconstructed = BuildInput.from_data_row(row) + + assert reconstructed.tags == {"formulation_id": "F42", "project": "lnp_screen"} + assert reconstructed.hash == inp.hash + assert reconstructed == inp + + +def test_build_input_tags_in_metadata(): + """Test that tags are included in metadata property.""" + spec = SingleMoleculeSpecies(smiles="CCC", count=100, resname="ABC") + comp = MixedBoxComposition(species=[spec], total_count=100) + + inp = BuildInput( + simulation_type="mixedbox", + system=comp, + tags={"project": "test"}, + ) + meta = inp.metadata + assert meta["tags"] == {"project": "test"} + + # Without tags + inp_no_tags = BuildInput(simulation_type="mixedbox", system=comp) + meta_no_tags = inp_no_tags.metadata + assert meta_no_tags["tags"] is None + + +def test_build_input_tags_yaml_roundtrip(): + """Test that tags survive YAML serialization (model_dump/reconstruct).""" + import yaml + + spec = SingleMoleculeSpecies(smiles="CCC", count=100, resname="ABC") + comp = MixedBoxComposition(species=[spec], total_count=100) + + inp = BuildInput( + simulation_type="mixedbox", + system=comp, + tags={"project": "test", "batch": "001"}, + ) + + # Simulate YAML round-trip (same as cli.py does) + dumped = yaml.safe_dump(inp.model_dump()) + loaded = yaml.safe_load(dumped) + reconstructed = BuildInput(**loaded) + + assert reconstructed.tags == {"project": "test", "batch": "001"} + assert reconstructed.hash == inp.hash + + # Without tags (simulates loading old YAML files) + data = inp.model_dump() + del data["tags"] + reconstructed_no_tags = BuildInput(**data) + assert reconstructed_no_tags.tags is None + assert reconstructed_no_tags.hash == inp.hash diff --git a/mdfactory/tests/test_orchestration_build.py b/mdfactory/tests/test_orchestration_build.py new file mode 100644 index 0000000..fa96c55 --- /dev/null +++ b/mdfactory/tests/test_orchestration_build.py @@ -0,0 +1,474 @@ +# ABOUTME: Tests for orchestration build dispatch and dry-run functions +# ABOUTME: Validates Parsl submission, dry-run output, and error handling +"""Tests for orchestration build dispatch.""" + +from unittest.mock import MagicMock, patch + +import pytest + +parsl = pytest.importorskip("parsl", reason="parsl not installed") + +from mdfactory.orchestration.config import ExecutorConfig # noqa: E402 + + +class FakeBuildInput: + """Fake BuildInput for testing isinstance checks.""" + + def __init__( + self, + hash="TEST", + simulation_type="mixedbox", + parametrization="cgenff", + engine="gromacs", + ): + self.hash = hash + self.simulation_type = simulation_type + self.parametrization = parametrization + self.engine = engine + + def model_dump(self): + """Return dict representation.""" + return { + "simulation_type": self.simulation_type, + "parametrization": self.parametrization, + "engine": self.engine, + } + + +def test_build_systems_dry_run(monkeypatch, tmp_path): + """build_systems with dry_run=True describes planned builds without loading Parsl.""" + import mdfactory.orchestration.build as build_mod + from mdfactory.orchestration.build import build_systems + + mock_model = FakeBuildInput( + hash="ABC123", simulation_type="bilayer", parametrization="smirnoff" + ) + monkeypatch.setattr(build_mod, "BuildInput", FakeBuildInput) + + results = build_systems( + [mock_model], + ExecutorConfig(), + output_dir=tmp_path, + dry_run=True, + ) + + assert len(results) == 1 + assert results[0]["hash"] == "ABC123" + assert results[0]["simulation_type"] == "bilayer" + assert str(tmp_path / "ABC123") in results[0]["output_directory"] + + +def test_build_systems_dry_run_multiple(monkeypatch, tmp_path): + """build_systems with dry_run=True handles multiple inputs.""" + import mdfactory.orchestration.build as build_mod + from mdfactory.orchestration.build import build_systems + + models = [FakeBuildInput(hash=f"HASH{i}") for i in range(3)] + monkeypatch.setattr(build_mod, "BuildInput", FakeBuildInput) + + results = build_systems(models, ExecutorConfig(), output_dir=tmp_path, dry_run=True) + assert len(results) == 3 + assert [r["hash"] for r in results] == ["HASH0", "HASH1", "HASH2"] + + +def test_build_systems_submits_correct_count(monkeypatch, tmp_path): + """build_systems submits one Parsl task per input.""" + import parsl + + mock_app_fn = MagicMock() + mock_future = MagicMock() + mock_future.result.return_value = {"hash": "H1", "status": "success", "directory": "/tmp/H1"} + mock_app_fn.return_value = mock_future + + import mdfactory.orchestration.build as build_mod + + monkeypatch.setattr(build_mod, "get_build_app", lambda: mock_app_fn) + monkeypatch.setattr(parsl, "load", MagicMock()) + monkeypatch.setattr(parsl, "clear", MagicMock()) + monkeypatch.setattr(parsl, "dfk", MagicMock(side_effect=RuntimeError("No DFK"))) + + mock_model = FakeBuildInput(hash="H1") + monkeypatch.setattr(build_mod, "BuildInput", FakeBuildInput) + + cfg = ExecutorConfig() + monkeypatch.setattr(ExecutorConfig, "to_parsl_config", lambda self: MagicMock()) + + results = build_mod.build_systems([mock_model], cfg, output_dir=tmp_path) + + assert mock_app_fn.call_count == 1 + assert len(results) == 1 + assert results[0]["status"] == "success" + + +def test_build_systems_handles_failed_future(monkeypatch, tmp_path): + """build_systems captures exceptions from failed futures.""" + import parsl + + mock_app_fn = MagicMock() + mock_future = MagicMock() + mock_future.result.side_effect = RuntimeError("CUDA OOM") + mock_app_fn.return_value = mock_future + + import mdfactory.orchestration.build as build_mod + + monkeypatch.setattr(build_mod, "get_build_app", lambda: mock_app_fn) + monkeypatch.setattr(parsl, "load", MagicMock()) + monkeypatch.setattr(parsl, "clear", MagicMock()) + monkeypatch.setattr(parsl, "dfk", MagicMock(side_effect=RuntimeError("No DFK"))) + + mock_model = FakeBuildInput(hash="FAIL1", simulation_type="bilayer") + monkeypatch.setattr(build_mod, "BuildInput", FakeBuildInput) + monkeypatch.setattr(ExecutorConfig, "to_parsl_config", lambda self: MagicMock()) + + cfg = ExecutorConfig() + results = build_mod.build_systems([mock_model], cfg, output_dir=tmp_path) + + assert len(results) == 1 + assert results[0]["status"] == "failed" + assert "CUDA OOM" in results[0]["error"] + # Finding 10: failure metadata is surfaced for future retry logic. + assert results[0]["failure_type"] == "RuntimeError" + assert results[0]["error_detail"] == "CUDA OOM" + + +def test_build_systems_no_wait(monkeypatch, tmp_path): + """build_systems with wait=False returns futures directly.""" + import parsl + + mock_app_fn = MagicMock() + mock_future = MagicMock() + mock_app_fn.return_value = mock_future + + import mdfactory.orchestration.build as build_mod + + monkeypatch.setattr(build_mod, "get_build_app", lambda: mock_app_fn) + monkeypatch.setattr(parsl, "load", MagicMock()) + + mock_model = FakeBuildInput(hash="NW1") + monkeypatch.setattr(build_mod, "BuildInput", FakeBuildInput) + monkeypatch.setattr(ExecutorConfig, "to_parsl_config", lambda self: MagicMock()) + + cfg = ExecutorConfig() + futures = build_mod.build_systems([mock_model], cfg, output_dir=tmp_path, wait=False) + + assert futures == [mock_future] + + +def test_build_systems_dry_run_invalid_input_type(tmp_path): + """build_systems with dry_run=True raises TypeError for invalid input.""" + from mdfactory.orchestration.build import build_systems + + with pytest.raises(TypeError, match="Expected BuildInput or dict"): + build_systems([42], ExecutorConfig(), output_dir=tmp_path, dry_run=True) + + +# --- Finding 5: Tests for _build_system_impl --- + + +def test_build_system_impl_strips_internal_keys_and_chdirs(monkeypatch, tmp_path): + """_build_system_impl strips _-prefixed keys and chdirs to build_dir.""" + from mdfactory.orchestration.apps import _build_system_impl + + build_dir = tmp_path / "OUT" + captured_model = {} + + def mock_run_build(model): + import os + + captured_model["hash"] = model.hash + captured_model["cwd"] = os.getcwd() + + monkeypatch.setattr("mdfactory.workflows.run_build_from_dict", mock_run_build) + + # Provide minimal valid BuildInput fields plus internal keys + input_dict = { + "simulation_type": "mixedbox", + "parametrization": "cgenff", + "engine": "gromacs", + "system": {"species": [{"smiles": "O", "count": 100, "resname": "SOL"}]}, + "_build_dir": str(build_dir), + "_internal_flag": "should_be_stripped", + } + result = _build_system_impl(input_dict) + + assert result["status"] == "success" + assert result["directory"] == str(build_dir.resolve()) + assert build_dir.exists() + # Verify we chdir'd to build_dir during execution + assert captured_model["cwd"] == str(build_dir) + + +def test_build_system_impl_restores_cwd_on_failure(monkeypatch, tmp_path): + """_build_system_impl restores original cwd even if build fails.""" + import os + + from mdfactory.orchestration.apps import _build_system_impl + + build_dir = tmp_path / "FAIL" + original_cwd = os.getcwd() + + def mock_run_build_fail(model): + raise RuntimeError("Build exploded") + + monkeypatch.setattr("mdfactory.workflows.run_build_from_dict", mock_run_build_fail) + + input_dict = { + "simulation_type": "mixedbox", + "parametrization": "cgenff", + "engine": "gromacs", + "system": {"species": [{"smiles": "O", "count": 100, "resname": "SOL"}]}, + "_build_dir": str(build_dir), + } + with pytest.raises(RuntimeError, match="Build exploded"): + _build_system_impl(input_dict) + + # cwd should be restored + assert os.getcwd() == original_cwd + + +# --- Finding 9: Tests for SLURM cleanup functions --- + + +def test_shutdown_parsl_calls_clear_and_scancel(monkeypatch): + """_shutdown_parsl calls parsl.clear() and scancels SLURM jobs.""" + import parsl as parsl_mod + + from mdfactory.orchestration.build import _shutdown_parsl + + mock_dfk = MagicMock() + mock_executor = MagicMock() + mock_executor.provider.resources = {"block-1": {"remote_job_id": "12345"}} + mock_dfk.executors = {"htex": mock_executor} + + monkeypatch.setattr(parsl_mod, "dfk", MagicMock(return_value=mock_dfk)) + monkeypatch.setattr(parsl_mod, "clear", MagicMock()) + + with patch("subprocess.run") as mock_subprocess: + mock_subprocess.return_value = MagicMock(returncode=0) + _shutdown_parsl() + + parsl_mod.clear.assert_called_once() + mock_subprocess.assert_called_once() + assert "12345" in mock_subprocess.call_args[0][0] + + +def test_shutdown_parsl_attempts_scancel_even_if_clear_fails(monkeypatch): + """_shutdown_parsl runs scancel even when parsl.clear() raises.""" + import parsl as parsl_mod + + from mdfactory.orchestration.build import _shutdown_parsl + + mock_dfk = MagicMock() + mock_executor = MagicMock() + mock_executor.provider.resources = {"block-1": {"remote_job_id": "99999"}} + mock_dfk.executors = {"htex": mock_executor} + + monkeypatch.setattr(parsl_mod, "dfk", MagicMock(return_value=mock_dfk)) + monkeypatch.setattr(parsl_mod, "clear", MagicMock(side_effect=RuntimeError("timeout"))) + + with patch("subprocess.run") as mock_subprocess: + mock_subprocess.return_value = MagicMock(returncode=0) + _shutdown_parsl() + + # scancel should still be called despite clear() failing + mock_subprocess.assert_called_once() + assert "99999" in mock_subprocess.call_args[0][0] + + +def test_get_slurm_job_ids_handles_missing_resources(monkeypatch): + """_get_slurm_job_ids handles executors without resources attribute.""" + from mdfactory.orchestration.build import _get_slurm_job_ids + + mock_dfk = MagicMock() + mock_executor = MagicMock(spec=[]) # no attributes + mock_dfk.executors = {"htex": mock_executor} + + result = _get_slurm_job_ids(mock_dfk) + assert result == [] + + +def test_keyboard_interrupt_triggers_shutdown(monkeypatch, tmp_path): + """KeyboardInterrupt during _wait_with_progress triggers _shutdown_parsl.""" + import parsl as parsl_mod + + import mdfactory.orchestration.build as build_mod + + mock_app_fn = MagicMock() + # Future that raises KeyboardInterrupt when polled + mock_future = MagicMock() + mock_future.done.side_effect = KeyboardInterrupt() + mock_app_fn.return_value = mock_future + + monkeypatch.setattr(build_mod, "get_build_app", lambda: mock_app_fn) + monkeypatch.setattr(parsl_mod, "load", MagicMock()) + monkeypatch.setattr(parsl_mod, "clear", MagicMock()) + monkeypatch.setattr(parsl_mod, "dfk", MagicMock(side_effect=RuntimeError("No DFK"))) + + mock_model = FakeBuildInput(hash="INT1") + monkeypatch.setattr(build_mod, "BuildInput", FakeBuildInput) + monkeypatch.setattr(ExecutorConfig, "to_parsl_config", lambda self: MagicMock()) + + cfg = ExecutorConfig() + with pytest.raises(KeyboardInterrupt): + build_mod.build_systems([mock_model], cfg, output_dir=tmp_path) + + # parsl.clear should have been called via _shutdown_parsl in the finally block + assert parsl_mod.clear.called + + +# --- Finding 10: Tests for dict input path --- + + +def test_build_systems_dry_run_with_dict_input(monkeypatch, tmp_path): + """build_systems with dry_run=True handles dict input correctly.""" + from mdfactory.orchestration.build import build_systems + + input_dict = { + "simulation_type": "mixedbox", + "parametrization": "cgenff", + "engine": "gromacs", + "system": {"species": [{"smiles": "O", "count": 100, "resname": "SOL"}]}, + } + + results = build_systems([input_dict], ExecutorConfig(), output_dir=tmp_path, dry_run=True) + + assert len(results) == 1 + assert results[0]["simulation_type"] == "mixedbox" + assert results[0]["parametrization"] == "cgenff" + assert results[0]["hash"] # hash should be computed + + +def test_build_systems_with_dict_input(monkeypatch, tmp_path): + """build_systems handles dict input and injects _build_dir.""" + import parsl as parsl_mod + + import mdfactory.orchestration.build as build_mod + + captured_args = [] + mock_app_fn = MagicMock() + mock_future = MagicMock() + mock_future.result.return_value = {"hash": "X", "status": "success", "directory": "/tmp/X"} + + def capture_app_call(input_dict): + captured_args.append(input_dict) + return mock_future + + mock_app_fn.side_effect = capture_app_call + + monkeypatch.setattr(build_mod, "get_build_app", lambda: mock_app_fn) + monkeypatch.setattr(parsl_mod, "load", MagicMock()) + monkeypatch.setattr(parsl_mod, "clear", MagicMock()) + monkeypatch.setattr(parsl_mod, "dfk", MagicMock(side_effect=RuntimeError("No DFK"))) + monkeypatch.setattr(ExecutorConfig, "to_parsl_config", lambda self: MagicMock()) + + input_dict = { + "simulation_type": "mixedbox", + "parametrization": "cgenff", + "engine": "gromacs", + "system": {"species": [{"smiles": "O", "count": 100, "resname": "SOL"}]}, + } + + cfg = ExecutorConfig() + results = build_mod.build_systems([input_dict], cfg, output_dir=tmp_path) + + assert len(captured_args) == 1 + assert "_build_dir" in captured_args[0] + assert results[0]["status"] == "success" + + +# --- Finding 9: Multi-iteration polling test --- + + +def test_wait_with_progress_multi_iteration(monkeypatch): + """_wait_with_progress exercises multi-iteration polling when future is not immediately done.""" + from mdfactory.orchestration.build import _wait_with_progress + + mock_future = MagicMock() + mock_future.done.side_effect = [False, False, True] + mock_future.result.return_value = {"hash": "POLL1", "status": "success", "directory": "/tmp"} + mock_future.task_status.return_value = "running" + + results = _wait_with_progress([mock_future], hashes=["POLL1"], poll_interval=0.01) + + assert len(results) == 1 + assert results[0]["status"] == "success" + # done() should have been called at least twice (False then True) + assert mock_future.done.call_count >= 2 + + +# --- Finding 10: Assert non-cleanup in no_wait --- + + +def test_build_systems_no_wait_does_not_clear(monkeypatch, tmp_path): + """build_systems with wait=False does NOT call parsl.clear().""" + import parsl + + mock_app_fn = MagicMock() + mock_future = MagicMock() + mock_app_fn.return_value = mock_future + + import mdfactory.orchestration.build as build_mod + + monkeypatch.setattr(build_mod, "get_build_app", lambda: mock_app_fn) + monkeypatch.setattr(parsl, "load", MagicMock()) + mock_clear = MagicMock() + monkeypatch.setattr(parsl, "clear", mock_clear) + + mock_model = FakeBuildInput(hash="NW2") + monkeypatch.setattr(build_mod, "BuildInput", FakeBuildInput) + monkeypatch.setattr(ExecutorConfig, "to_parsl_config", lambda self: MagicMock()) + + cfg = ExecutorConfig() + futures = build_mod.build_systems([mock_model], cfg, output_dir=tmp_path, wait=False) + + assert futures == [mock_future] + mock_clear.assert_not_called() + + +# --- Finding 10: failure description helper --- + + +def test_describe_failure_plain_exception(): + """_describe_failure returns the exception type name and message.""" + from mdfactory.orchestration.build import _describe_failure + + failure_type, detail = _describe_failure(RuntimeError("boom")) + assert failure_type == "RuntimeError" + assert detail == "boom" + + +def test_describe_failure_unwraps_legacy_e_value(): + """_describe_failure unwraps a legacy Parsl wrapper exposing .e_value.""" + from mdfactory.orchestration.build import _describe_failure + + class FakeAppFailure(Exception): + """Mimic an older Parsl wrapper carrying the underlying error.""" + + def __init__(self, e_value): + super().__init__("wrapped") + self.e_value = e_value + + underlying = ValueError("real GROMACS crash") + failure_type, detail = _describe_failure(FakeAppFailure(underlying)) + assert failure_type == "ValueError" + assert detail == "real GROMACS crash" + + +# --- Finding 12: completeness guard --- + + +def test_collect_results_returns_complete_list(): + """_collect_results returns all results when every slot is captured.""" + from mdfactory.orchestration.build import _collect_results + + results = [{"hash": "A"}, {"hash": "B"}] + assert _collect_results(results, ["A", "B"]) == results + + +def test_collect_results_raises_on_uncaptured_slot(): + """_collect_results raises rather than silently dropping a None slot.""" + from mdfactory.orchestration.build import _collect_results + + results = [{"hash": "A"}, None] + with pytest.raises(RuntimeError, match="never captured"): + _collect_results(results, ["AAAA", "BBBB"]) diff --git a/mdfactory/tests/test_orchestration_cli.py b/mdfactory/tests/test_orchestration_cli.py new file mode 100644 index 0000000..80a65b6 --- /dev/null +++ b/mdfactory/tests/test_orchestration_cli.py @@ -0,0 +1,301 @@ +# ABOUTME: Integration tests for the CLI build command orchestration paths +# ABOUTME: Tests CSV/YAML/summary-YAML dispatch, dry-run, and error handling +"""Integration tests for CLI build command with orchestration.""" + +from unittest.mock import patch + +import pytest +import yaml + +parsl = pytest.importorskip("parsl", reason="parsl not installed") + + +@pytest.fixture() +def sample_csv(tmp_path): + """Create a minimal CSV input file for testing.""" + csv_content = ( + "simulation_type,system.species.SOL.smiles,system.species.SOL.count,parametrization\n" + "mixedbox,O,100,cgenff\n" + ) + csv_path = tmp_path / "input.csv" + csv_path.write_text(csv_content) + return csv_path + + +@pytest.fixture() +def sample_yaml(tmp_path): + """Create a minimal single-build YAML input file.""" + data = { + "simulation_type": "mixedbox", + "system": {"species": [{"smiles": "O", "count": 100, "resname": "SOL"}]}, + "parametrization": "cgenff", + "engine": "gromacs", + } + yaml_path = tmp_path / "build.yaml" + with open(yaml_path, "w") as f: + yaml.safe_dump(data, f) + return yaml_path + + +@pytest.fixture() +def sample_config(tmp_path): + """Create a minimal executor config YAML.""" + data = {"provider": "local", "max_workers_per_node": 1} + cfg_path = tmp_path / "config.yaml" + with open(cfg_path, "w") as f: + yaml.safe_dump(data, f) + return cfg_path + + +class TestBuildCommandCSV: + """Tests for CSV input dispatch path.""" + + def test_csv_dry_run(self, sample_csv, tmp_path, monkeypatch): + """CSV input with --dry-run calls build_systems with dry_run=True.""" + from mdfactory.cli import _build_from_csv + + with patch("mdfactory.orchestration.build_systems") as mock_build: + mock_build.return_value = [{"hash": "X", "simulation_type": "mixedbox"}] + _build_from_csv(sample_csv, tmp_path, dry_run=True) + + mock_build.assert_called_once() + assert mock_build.call_args[1]["dry_run"] is True + + def test_csv_dry_run_no_filesystem_side_effects(self, sample_csv, tmp_path, monkeypatch): + """CSV dry-run does not create directories or summary YAML.""" + from mdfactory.cli import _build_from_csv + + output_dir = tmp_path / "output" + output_dir.mkdir() + + with patch("mdfactory.orchestration.build_systems") as mock_build: + mock_build.return_value = [] + _build_from_csv(sample_csv, output_dir, dry_run=True) + + # No summary YAML or hash directories should be created in output + assert not (output_dir / "input.yaml").exists() + assert len(list(output_dir.iterdir())) == 0 + + def test_csv_sequential_builds_locally(self, sample_csv, tmp_path, monkeypatch): + """CSV input without --config builds sequentially.""" + from mdfactory.cli import _build_from_csv + + with patch("mdfactory.cli.run_build_from_dict") as mock_build: + _build_from_csv(sample_csv, tmp_path, config=None, dry_run=False) + + mock_build.assert_called_once() + + def test_csv_generates_summary_yaml(self, sample_csv, tmp_path, monkeypatch): + """CSV build generates a summary YAML matching prepare-build format.""" + from mdfactory.cli import _build_from_csv + + with patch("mdfactory.cli.run_build_from_dict"): + _build_from_csv(sample_csv, tmp_path, config=None, dry_run=False) + + summary_path = tmp_path / "input.yaml" + assert summary_path.exists() + with open(summary_path) as f: + summary = yaml.safe_load(f) + assert summary["n_systems"] == 1 + assert str(sample_csv) == summary["input"] + assert len(summary["hash"]) == 1 + assert len(summary["system_directory"]) == 1 + assert "date" in summary + assert "simulation_type" in summary + + def test_csv_with_config_uses_parsl(self, sample_csv, sample_config, tmp_path, monkeypatch): + """CSV input with --config dispatches via build_systems.""" + from mdfactory.cli import _build_from_csv + + with patch("mdfactory.orchestration.build_systems") as mock_build: + mock_build.return_value = [{"hash": "X", "status": "success"}] + _build_from_csv(sample_csv, tmp_path, config=sample_config, dry_run=False) + + mock_build.assert_called_once() + + +class TestBuildCommandYAML: + """Tests for single-YAML input dispatch path.""" + + def test_yaml_sequential_builds_into_hash_dir(self, sample_yaml, tmp_path, monkeypatch): + """Single YAML without --config builds into output/{hash}/.""" + from mdfactory.cli import _build_from_yaml + + with patch("mdfactory.cli.run_build_from_file") as mock_build: + _build_from_yaml(sample_yaml, tmp_path) + + mock_build.assert_called_once() + # Verify the working directory was a hash-based subdirectory + call_args = mock_build.call_args + # run_build_from_file is called with the input path + assert call_args[0][0] == sample_yaml + + def test_yaml_dry_run(self, sample_yaml, tmp_path, monkeypatch): + """Single YAML with --dry-run calls build_systems with dry_run=True.""" + from mdfactory.cli import _build_from_yaml + + with patch("mdfactory.orchestration.build_systems") as mock_build: + mock_build.return_value = [{"hash": "X", "simulation_type": "mixedbox"}] + _build_from_yaml(sample_yaml, tmp_path, dry_run=True) + + mock_build.assert_called_once() + assert mock_build.call_args[1]["dry_run"] is True + + def test_yaml_with_config_uses_parsl(self, sample_yaml, sample_config, tmp_path, monkeypatch): + """Single YAML with --config dispatches via build_systems.""" + from mdfactory.cli import _build_from_yaml + + with patch("mdfactory.orchestration.build_systems") as mock_build: + mock_build.return_value = [{"hash": "X", "status": "success"}] + _build_from_yaml(sample_yaml, tmp_path, config=sample_config, dry_run=False) + + mock_build.assert_called_once() + + +class TestBuildCommandSummaryYAML: + """Tests for summary YAML (prepare-build output) dispatch path.""" + + def test_summary_yaml_empty_dirs_exits(self, tmp_path): + """Summary YAML with no directories causes exit.""" + from mdfactory.cli import _build_from_summary_yaml + + data = {"system_directory": [], "hash": []} + with pytest.raises(SystemExit): + _build_from_summary_yaml(data, tmp_path) + + def test_summary_yaml_missing_build_file_exits(self, tmp_path): + """Summary YAML referencing non-existent build file exits.""" + from mdfactory.cli import _build_from_summary_yaml + + data = {"system_directory": [str(tmp_path / "nonexistent")], "hash": ["ABC123"]} + with pytest.raises(SystemExit): + _build_from_summary_yaml(data, tmp_path) + + def test_summary_yaml_mismatched_lengths_exits(self, tmp_path): + """Summary YAML with mismatched list lengths causes exit.""" + from mdfactory.cli import _build_from_summary_yaml + + data = {"system_directory": [str(tmp_path), str(tmp_path)], "hash": ["A"]} + with pytest.raises(SystemExit): + _build_from_summary_yaml(data, tmp_path) + + def test_summary_yaml_dry_run(self, tmp_path): + """Summary YAML with --dry-run calls build_systems with dry_run=True.""" + from mdfactory.cli import _build_from_summary_yaml + + hash_val = "TESTHASH123" + build_dir = tmp_path / hash_val + build_dir.mkdir() + yaml_data = { + "simulation_type": "mixedbox", + "parametrization": "cgenff", + "engine": "gromacs", + "system": {"species": [{"smiles": "O", "count": 100, "resname": "SOL"}]}, + } + (build_dir / f"{hash_val}.yaml").write_text(yaml.safe_dump(yaml_data)) + data = {"system_directory": [str(build_dir)], "hash": [hash_val]} + + with patch("mdfactory.orchestration.build_systems") as mock_build: + mock_build.return_value = [{"hash": hash_val}] + _build_from_summary_yaml(data, tmp_path, dry_run=True) + + mock_build.assert_called_once() + assert mock_build.call_args[1]["dry_run"] is True + + def test_summary_yaml_sequential(self, tmp_path): + """Summary YAML without --config builds sequentially.""" + from mdfactory.cli import _build_from_summary_yaml + + hash_val = "SEQHASH456" + build_dir = tmp_path / hash_val + build_dir.mkdir() + yaml_data = { + "simulation_type": "mixedbox", + "parametrization": "cgenff", + "engine": "gromacs", + "system": {"species": [{"smiles": "O", "count": 100, "resname": "SOL"}]}, + } + (build_dir / f"{hash_val}.yaml").write_text(yaml.safe_dump(yaml_data)) + data = {"system_directory": [str(build_dir)], "hash": [hash_val]} + + with patch("mdfactory.cli.run_build_from_dict") as mock_build: + _build_from_summary_yaml(data, tmp_path, config=None, dry_run=False) + + mock_build.assert_called_once() + + def test_summary_yaml_parallel(self, tmp_path, sample_config): + """Summary YAML with --config dispatches via build_systems.""" + from mdfactory.cli import _build_from_summary_yaml + + hash_val = "PARHASH789" + build_dir = tmp_path / hash_val + build_dir.mkdir() + yaml_data = { + "simulation_type": "mixedbox", + "parametrization": "cgenff", + "engine": "gromacs", + "system": {"species": [{"smiles": "O", "count": 100, "resname": "SOL"}]}, + } + (build_dir / f"{hash_val}.yaml").write_text(yaml.safe_dump(yaml_data)) + data = {"system_directory": [str(build_dir)], "hash": [hash_val]} + + with patch("mdfactory.orchestration.build_systems") as mock_build: + mock_build.return_value = [{"hash": hash_val, "status": "success"}] + _build_from_summary_yaml(data, tmp_path, config=sample_config, dry_run=False) + + mock_build.assert_called_once() + + +class TestBuildCommandErrors: + """Tests for error handling in build command.""" + + def test_unsupported_extension_exits(self, tmp_path): + """Unsupported file extension causes sys.exit.""" + from mdfactory.cli import build_system + + txt_file = tmp_path / "input.txt" + txt_file.write_text("hello") + with pytest.raises(SystemExit): + build_system(txt_file, output=tmp_path) + + def test_malformed_csv_exits(self, tmp_path): + """CSV with missing required columns causes sys.exit.""" + from mdfactory.cli import _build_from_csv + + # Missing simulation_type column + csv_content = "system.species.SOL.smiles,system.species.SOL.count\nO,100\n" + csv_path = tmp_path / "bad.csv" + csv_path.write_text(csv_content) + with pytest.raises(SystemExit): + _build_from_csv(csv_path, tmp_path, config=None, dry_run=False) + + def test_empty_yaml_exits(self, tmp_path): + """Empty YAML file causes sys.exit with clear message.""" + from mdfactory.cli import _build_from_yaml + + empty_yaml = tmp_path / "empty.yaml" + empty_yaml.write_text("") + with pytest.raises(SystemExit): + _build_from_yaml(empty_yaml, tmp_path) + + +class TestBuildCommandRouting: + """Tests for build_system() top-level routing logic.""" + + def test_csv_routes_to_csv_handler(self, sample_csv, tmp_path): + """build_system() with .csv file routes to CSV handler.""" + from mdfactory.cli import build_system + + with patch("mdfactory.cli.run_build_from_dict") as mock_build: + build_system(sample_csv, output=tmp_path) + + mock_build.assert_called_once() + + def test_yaml_routes_to_yaml_handler(self, sample_yaml, tmp_path): + """build_system() with .yaml file routes to YAML handler.""" + from mdfactory.cli import build_system + + with patch("mdfactory.cli.run_build_from_file") as mock_build: + build_system(sample_yaml, output=tmp_path) + + mock_build.assert_called_once() diff --git a/mdfactory/tests/test_orchestration_config.py b/mdfactory/tests/test_orchestration_config.py new file mode 100644 index 0000000..a22bc16 --- /dev/null +++ b/mdfactory/tests/test_orchestration_config.py @@ -0,0 +1,261 @@ +# ABOUTME: Tests for orchestration executor configuration models +# ABOUTME: Validates config construction, serialization, and Parsl config generation +"""Tests for orchestration executor configuration.""" + +import pytest +import yaml + +pytest.importorskip("parsl", reason="parsl not installed") + +from mdfactory.orchestration.config import ExecutorConfig, SlurmExecutorConfig + + +def test_executor_config_defaults(): + """ExecutorConfig has sensible defaults.""" + cfg = ExecutorConfig() + assert cfg.provider == "local" + assert cfg.worker_init == "" + assert cfg.working_directory is None + assert cfg.max_workers_per_node == 1 + + +def test_slurm_executor_config_requires_account(): + """SlurmExecutorConfig requires account field.""" + with pytest.raises(Exception): + SlurmExecutorConfig() + + +def test_slurm_executor_config_defaults(): + """SlurmExecutorConfig has expected SLURM defaults.""" + cfg = SlurmExecutorConfig(account="my_account") + assert cfg.provider == "slurm" + assert cfg.account == "my_account" + assert cfg.partition == "cpu" + assert cfg.walltime == "02:00:00" + assert cfg.nodes == 1 + assert cfg.cpus_per_node == 12 + assert cfg.gres is None + assert cfg.mem is None + + +def test_executor_config_from_yaml_local(tmp_path): + """from_yaml loads a local executor config.""" + cfg_data = {"provider": "local", "max_workers_per_node": 4} + cfg_path = tmp_path / "config.yaml" + with open(cfg_path, "w") as f: + yaml.safe_dump(cfg_data, f) + + loaded = ExecutorConfig.from_yaml(cfg_path) + assert isinstance(loaded, ExecutorConfig) + assert not isinstance(loaded, SlurmExecutorConfig) + assert loaded.max_workers_per_node == 4 + + +def test_executor_config_from_yaml_slurm(tmp_path): + """from_yaml loads a SLURM executor config.""" + cfg_data = { + "provider": "slurm", + "account": "hpc_team", + "partition": "gpu-large", + "walltime": "4:00:00", + "cpus_per_node": 24, + "gres": "gpu:l40s:2", + } + cfg_path = tmp_path / "slurm.yaml" + with open(cfg_path, "w") as f: + yaml.safe_dump(cfg_data, f) + + loaded = ExecutorConfig.from_yaml(cfg_path) + assert isinstance(loaded, SlurmExecutorConfig) + assert loaded.account == "hpc_team" + assert loaded.partition == "gpu-large" + assert loaded.cpus_per_node == 24 + assert loaded.gres == "gpu:l40s:2" + + +def test_executor_config_to_parsl_config(): + """to_parsl_config produces a valid Parsl Config for local provider.""" + cfg = ExecutorConfig(max_workers_per_node=2, worker_init="module load cuda/12.0") + result = cfg.to_parsl_config() + + import parsl + + assert isinstance(result, parsl.Config) + assert len(result.executors) == 1 + assert result.executors[0].label == "local" + + +def test_slurm_executor_config_to_parsl_config(): + """to_parsl_config produces a valid Parsl Config for SLURM provider.""" + cfg = SlurmExecutorConfig(account="my_account", gres="gpu:l40s:1") + result = cfg.to_parsl_config() + + import parsl + + assert isinstance(result, parsl.Config) + assert len(result.executors) == 1 + assert result.executors[0].label == "slurm" + + +def test_worker_init_propagation(): + """worker_init string is passed to the SLURM provider.""" + cfg = SlurmExecutorConfig(account="acc", worker_init="module load cuda/12.0") + result = cfg.to_parsl_config() + + provider = result.executors[0].provider + assert "module load cuda/12.0" in provider.worker_init + + +def test_gres_in_scheduler_options(): + """gres field becomes --gres in scheduler_options.""" + cfg = SlurmExecutorConfig(account="acc", gres="gpu:l40s:2") + result = cfg.to_parsl_config() + + provider = result.executors[0].provider + assert "#SBATCH --gres=gpu:l40s:2" in provider.scheduler_options + + +def test_mem_and_qos_in_scheduler_options(): + """mem and qos fields are included in scheduler_options.""" + cfg = SlurmExecutorConfig(account="acc", mem="64G", qos="high") + result = cfg.to_parsl_config() + + provider = result.executors[0].provider + assert "#SBATCH --mem=64G" in provider.scheduler_options + assert "#SBATCH --qos=high" in provider.scheduler_options + + +def test_constraint_in_scheduler_options(): + """constraint field becomes --constraint in scheduler_options.""" + cfg = SlurmExecutorConfig(account="acc", constraint="a100") + result = cfg.to_parsl_config() + + provider = result.executors[0].provider + assert "#SBATCH --constraint=a100" in provider.scheduler_options + + +def test_slurm_executor_config_inherits_base_slurm_config(): + """SlurmExecutorConfig unifies with the shared BaseSlurmConfig hierarchy. + + Guards against re-introducing a divergent third copy of the SLURM fields + (issue #20): account/partition/qos/constraint and from_cluster() must come + from BaseSlurmConfig, not be redeclared here. + """ + from mdfactory.performance.slurm_config import BaseSlurmConfig + + assert issubclass(SlurmExecutorConfig, BaseSlurmConfig) + # from_cluster is inherited, not reimplemented. + assert SlurmExecutorConfig.from_cluster.__func__ is BaseSlurmConfig.from_cluster.__func__ + # The shared SLURM fields are not redeclared on the subclass itself. + own_fields = set(vars(SlurmExecutorConfig).get("__annotations__", {})) + assert not ({"account", "qos", "constraint"} & own_fields) + + +def test_slurm_executor_config_is_mutable(): + """Despite BaseSlurmConfig being frozen, the executor config stays mutable.""" + cfg = SlurmExecutorConfig(account="acc") + cfg.partition = "gpu" # must not raise (frozen=False) + assert cfg.partition == "gpu" + + +def test_raw_scheduler_options_appended(): + """Raw scheduler_options are appended after structured fields.""" + cfg = SlurmExecutorConfig(account="acc", gres="gpu:1", scheduler_options="#SBATCH --exclusive") + result = cfg.to_parsl_config() + + provider = result.executors[0].provider + assert "#SBATCH --gres=gpu:1" in provider.scheduler_options + assert "#SBATCH --exclusive" in provider.scheduler_options + + +def test_run_dir_default_and_expanduser(): + """run_dir defaults under the home dir and expands ``~`` in supplied values.""" + cfg = ExecutorConfig() + assert str(cfg.run_dir).endswith("/.parsl/mdfactory") + assert "~" not in str(cfg.run_dir) + + cfg2 = ExecutorConfig(run_dir="~/scratch/parsl") + assert "~" not in str(cfg2.run_dir) + assert str(cfg2.run_dir).endswith("/scratch/parsl") + + # run_dir flows into the Parsl Config + assert str(cfg.run_dir) == cfg.to_parsl_config().run_dir + + +def test_paths_serialize_as_strings(): + """run_dir / working_directory serialize as plain strings for YAML.""" + cfg = ExecutorConfig(working_directory="/tmp/work") + dumped = cfg.model_dump() + assert isinstance(dumped["run_dir"], str) + assert isinstance(dumped["working_directory"], str) + # Round-trips through yaml without RepresenterError + yaml.safe_dump(dumped) + + +def test_available_accelerators_wired_local(): + """available_accelerators is forwarded to the local HighThroughputExecutor.""" + cfg = ExecutorConfig(max_workers_per_node=2, available_accelerators=2) + executor = cfg.to_parsl_config().executors[0] + # Parsl normalises an int count into a list of device IDs. + assert executor.available_accelerators == ["0", "1"] + + +def test_available_accelerators_wired_slurm(): + """available_accelerators is forwarded to the SLURM HighThroughputExecutor.""" + cfg = SlurmExecutorConfig(account="acc", available_accelerators=["0", "1"]) + executor = cfg.to_parsl_config().executors[0] + assert executor.available_accelerators == ["0", "1"] + + +def test_launch_options_sets_srun_launcher(): + """launch_options wires a SrunLauncher with the given overrides.""" + from parsl.launchers import SrunLauncher + + cfg = SlurmExecutorConfig( + account="acc", + launch_options="--cpu-bind=cores --distribution=block:block", + ) + provider = cfg.to_parsl_config().executors[0].provider + assert isinstance(provider.launcher, SrunLauncher) + assert provider.launcher.overrides == "--cpu-bind=cores --distribution=block:block" + + +def test_no_launch_options_keeps_default_launcher(): + """Without launch_options, Parsl's default launcher is left untouched.""" + from parsl.launchers import SrunLauncher + + cfg = SlurmExecutorConfig(account="acc") + provider = cfg.to_parsl_config().executors[0].provider + assert not isinstance(provider.launcher, SrunLauncher) + + +def test_launch_options_roundtrip_yaml(tmp_path): + """launch_options survives a YAML round-trip.""" + original = SlurmExecutorConfig(account="acc", launch_options="--cpu-bind=cores") + cfg_path = tmp_path / "launch.yaml" + with open(cfg_path, "w") as f: + yaml.safe_dump(original.model_dump(), f) + loaded = ExecutorConfig.from_yaml(cfg_path) + assert loaded.launch_options == "--cpu-bind=cores" + + +def test_config_yaml_roundtrip(tmp_path): + """Config can be written to YAML and loaded back identically.""" + original = SlurmExecutorConfig( + account="test_account", + partition="gpu-dev", + walltime="1:00:00", + gres="gpu:l40s:1", + worker_init="source activate env", + ) + cfg_path = tmp_path / "roundtrip.yaml" + with open(cfg_path, "w") as f: + yaml.safe_dump(original.model_dump(), f) + + loaded = ExecutorConfig.from_yaml(cfg_path) + assert isinstance(loaded, SlurmExecutorConfig) + assert loaded.account == original.account + assert loaded.partition == original.partition + assert loaded.walltime == original.walltime + assert loaded.gres == original.gres + assert loaded.worker_init == original.worker_init diff --git a/mdfactory/tests/test_orchestration_from_cluster.py b/mdfactory/tests/test_orchestration_from_cluster.py new file mode 100644 index 0000000..00afa50 --- /dev/null +++ b/mdfactory/tests/test_orchestration_from_cluster.py @@ -0,0 +1,140 @@ +# ABOUTME: Tests for SlurmExecutorConfig.from_cluster() and walltime validation +# ABOUTME: Verifies 3-tier autodiscovery precedence and time normalization +"""Tests for SlurmExecutorConfig cluster autodiscovery and walltime.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from mdfactory.orchestration.config import SlurmExecutorConfig + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _mock_settings(**overrides): + s = MagicMock() + s.slurm_account = overrides.get("slurm_account", None) + s.slurm_partition_cpu = overrides.get("slurm_partition_cpu", None) + s.slurm_partition_gpu = overrides.get("slurm_partition_gpu", None) + s.slurm_qos = overrides.get("slurm_qos", None) + return s + + +def _mock_cluster(default_account="test_acc", accounts=None): + c = MagicMock() + c.default_account = default_account + c.accounts = accounts or [default_account] + return c + + +# --------------------------------------------------------------------------- +# Walltime normalisation +# --------------------------------------------------------------------------- + + +class TestWalltimeNormalization: + """Walltime shorthand is expanded on construction.""" + + def test_walltime_normalization(self): + assert SlurmExecutorConfig(account="x", walltime="2h").walltime == "02:00:00" + assert SlurmExecutorConfig(account="x", walltime="30m").walltime == "00:30:00" + assert SlurmExecutorConfig(account="x", walltime="1d").walltime == "1-00:00:00" + assert SlurmExecutorConfig(account="x", walltime="01:30:00").walltime == "01:30:00" + + def test_walltime_default_normalized(self): + cfg = SlurmExecutorConfig(account="x") + assert cfg.walltime == "02:00:00" + + +# --------------------------------------------------------------------------- +# from_cluster() tests +# --------------------------------------------------------------------------- + +# Patch targets — from_cluster imports these inside the method body, so we +# patch the canonical module locations. +_DISCOVER = "mdfactory.performance.cluster.discover_cluster" +_SELECT = "mdfactory.performance.cluster.select_partition" +_SETTINGS = "mdfactory.settings.settings" + + +class TestFromCluster: + """Tests for the three-tier autodiscovery in from_cluster().""" + + @patch(_DISCOVER, return_value=None) + @patch(_SETTINGS, _mock_settings()) + def test_explicit_kwargs(self, _discover): + cfg = SlurmExecutorConfig.from_cluster(account="myacc", partition="gpu") + assert cfg.account == "myacc" + assert cfg.partition == "gpu" + + @patch(_DISCOVER, return_value=None) + @patch( + _SETTINGS, + _mock_settings(slurm_account="cfg_account", slurm_partition_cpu="cfg_partition"), + ) + def test_config_ini_fallback(self, _discover): + cfg = SlurmExecutorConfig.from_cluster() + assert cfg.account == "cfg_account" + assert cfg.partition == "cfg_partition" + + @patch(_SELECT) + @patch(_DISCOVER) + @patch(_SETTINGS, _mock_settings()) + def test_autodiscovery(self, mock_discover, mock_select): + mock_discover.return_value = _mock_cluster(default_account="discovered_acc") + part = MagicMock() + part.name = "auto_part" + mock_select.return_value = part + + cfg = SlurmExecutorConfig.from_cluster() + assert cfg.account == "discovered_acc" + assert cfg.partition == "auto_part" + + @patch(_SELECT) + @patch(_DISCOVER) + @patch(_SETTINGS, _mock_settings(slurm_account="cfg_acc")) + def test_precedence_explicit_wins(self, mock_discover, mock_select): + mock_discover.return_value = _mock_cluster(default_account="disc_acc") + part = MagicMock() + part.name = "disc_part" + mock_select.return_value = part + + cfg = SlurmExecutorConfig.from_cluster(account="explicit") + assert cfg.account == "explicit" + + @patch(_DISCOVER, return_value=None) + @patch(_SETTINGS, _mock_settings()) + def test_no_account_raises(self, _discover): + with pytest.raises(RuntimeError, match="account"): + SlurmExecutorConfig.from_cluster() + + @patch(_DISCOVER, return_value=None) + @patch(_SETTINGS, _mock_settings(slurm_account="has_acc")) + def test_no_partition_raises(self, _discover): + with pytest.raises(RuntimeError, match="partition"): + SlurmExecutorConfig.from_cluster() + + @patch(_SELECT) + @patch(_DISCOVER) + @patch(_SETTINGS, _mock_settings()) + def test_extra_fields_forwarded(self, mock_discover, mock_select): + mock_discover.return_value = _mock_cluster() + part = MagicMock() + part.name = "cpu" + mock_select.return_value = part + + cfg = SlurmExecutorConfig.from_cluster(walltime="4h", cpus_per_node=24, gres="gpu:a100:1") + assert cfg.walltime == "04:00:00" + assert cfg.cpus_per_node == 24 + assert cfg.gres == "gpu:a100:1" + + @patch(_DISCOVER, return_value=None) + @patch( + _SETTINGS, + _mock_settings(slurm_account="acc", slurm_partition_gpu="gpu-big"), + ) + def test_needs_gpu_selects_gpu_partition(self, _discover): + cfg = SlurmExecutorConfig.from_cluster(needs_gpu=True) + assert cfg.partition == "gpu-big" diff --git a/mdfactory/tests/test_orchestration_session.py b/mdfactory/tests/test_orchestration_session.py new file mode 100644 index 0000000..25a1fb2 --- /dev/null +++ b/mdfactory/tests/test_orchestration_session.py @@ -0,0 +1,71 @@ +# ABOUTME: Tests for the reusable Parsl session context manager +# ABOUTME: Validates DFK guard, load, shutdown, and detach semantics +"""Tests for orchestration Parsl session management.""" + +from unittest.mock import MagicMock + +import pytest + +parsl = pytest.importorskip("parsl", reason="parsl not installed") + +from mdfactory.orchestration.config import ExecutorConfig # noqa: E402 +from mdfactory.orchestration.session import ( # noqa: E402 + ParslSession, + parsl_session, +) + + +def _patch_parsl(monkeypatch, *, active_dfk=False): + """Patch parsl.load/clear/dfk for session tests.""" + monkeypatch.setattr(parsl, "load", MagicMock()) + monkeypatch.setattr(parsl, "clear", MagicMock()) + if active_dfk: + monkeypatch.setattr(parsl, "dfk", MagicMock(return_value=MagicMock(executors={}))) + else: + monkeypatch.setattr(parsl, "dfk", MagicMock(side_effect=RuntimeError("No DFK"))) + monkeypatch.setattr(ExecutorConfig, "to_parsl_config", lambda self: MagicMock()) + + +def test_parsl_session_loads_and_shuts_down(monkeypatch): + """parsl_session loads the config and clears the DFK on exit.""" + _patch_parsl(monkeypatch) + + with parsl_session(ExecutorConfig()) as session: + assert isinstance(session, ParslSession) + assert session.detached is False + + parsl.load.assert_called_once() + parsl.clear.assert_called_once() + + +def test_parsl_session_detach_skips_shutdown(monkeypatch): + """A detached session does not shut down the DFK on exit.""" + _patch_parsl(monkeypatch) + + with parsl_session(ExecutorConfig()) as session: + session.detach() + + parsl.load.assert_called_once() + parsl.clear.assert_not_called() + + +def test_parsl_session_guards_active_dfk(monkeypatch): + """parsl_session raises if a DFK is already active and does not load.""" + _patch_parsl(monkeypatch, active_dfk=True) + + with pytest.raises(RuntimeError, match="already active"): + with parsl_session(ExecutorConfig()): + pass + + parsl.load.assert_not_called() + + +def test_parsl_session_shuts_down_on_exception(monkeypatch): + """parsl_session clears the DFK even when the body raises.""" + _patch_parsl(monkeypatch) + + with pytest.raises(ValueError, match="boom"): + with parsl_session(ExecutorConfig()): + raise ValueError("boom") + + parsl.clear.assert_called_once() diff --git a/mdfactory/tests/test_orchestration_tui.py b/mdfactory/tests/test_orchestration_tui.py new file mode 100644 index 0000000..b43a997 --- /dev/null +++ b/mdfactory/tests/test_orchestration_tui.py @@ -0,0 +1,175 @@ +# ABOUTME: Tests for the interactive SLURM configuration TUI wizard +# ABOUTME: Uses mocked questionary to verify prompt flow and config generation +"""Tests for orchestration TUI wizard.""" + +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +from mdfactory.orchestration.config import SlurmExecutorConfig +from mdfactory.orchestration.tui import ( + UserCancelledError, + _require, + configure_slurm_interactive, + save_slurm_config_yaml, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_cluster(): + """Create a mock ClusterInfo for TUI tests.""" + nt = MagicMock() + nt.cpus = 96 + nt.memory_mb = 512 * 1024 + nt.gpu_specs = ((4, "a100"),) + nt.features = ("avx512",) + nt.count = 10 + + partition = MagicMock() + partition.name = "gpu" + partition.state = "up" + partition.total_nodes = 10 + partition.node_types = [nt] + partition.is_default = True + partition.max_time = "24:00:00" + + cluster = MagicMock() + cluster.partitions = [partition] + cluster.accounts = ["hpc_chem", "hpc_bio"] + cluster.qos_policies = ["normal", "high"] + cluster.default_account = "hpc_chem" + return cluster + + +# --------------------------------------------------------------------------- +# _require tests +# --------------------------------------------------------------------------- + + +class TestRequire: + def test_returns_value(self): + assert _require("hello", "test") == "hello" + + def test_raises_on_none(self): + with pytest.raises(UserCancelledError): + _require(None, "test") + + +# --------------------------------------------------------------------------- +# configure_slurm_interactive tests +# --------------------------------------------------------------------------- + + +class TestConfigureManualFallback: + """When discover_cluster returns None, wizard uses manual entry.""" + + @patch("mdfactory.orchestration.tui.discover_cluster", return_value=None) + @patch("mdfactory.orchestration.tui._import_questionary") + def test_configure_manual_fallback(self, mock_iq, _discover): + mock_q = mock_iq.return_value + # confirm → proceed with manual config + mock_q.confirm.return_value.ask.return_value = True + # text prompts in order: account, partition, walltime, cpus, + # gres, mem, qos, max_blocks, worker_init + mock_q.text.return_value.ask.side_effect = [ + "manual_acc", + "gpu", + "4:00:00", + "24", + "gpu:a100:1", + "64G", + "", + "4", + "", + ] + + cfg = configure_slurm_interactive() + assert cfg.account == "manual_acc" + assert cfg.partition == "gpu" + assert cfg.walltime == "4:00:00" + assert cfg.cpus_per_node == 24 + assert cfg.gres == "gpu:a100:1" + assert cfg.mem == "64G" + + +class TestConfigureWithCluster: + """When cluster is discovered, wizard uses select menus.""" + + @patch("mdfactory.orchestration.tui.discover_cluster") + @patch("mdfactory.orchestration.tui._import_questionary") + def test_configure_with_cluster(self, mock_iq, mock_discover): + mock_q = mock_iq.return_value + mock_discover.return_value = _make_cluster() + + # select prompts: account, partition, walltime, cpus, mem, max_blocks + mock_q.select.return_value.ask.side_effect = [ + "hpc_chem", + "gpu", + "2h", + "16", + "50G", + "4", + ] + # text prompts: gres, worker_init, constraint + mock_q.text.return_value.ask.side_effect = [ + "gpu:a100:1", + "", + "a100", + ] + # confirm for QOS + mock_q.confirm.return_value.ask.return_value = False + + cfg = configure_slurm_interactive() + assert cfg.account == "hpc_chem" + assert cfg.partition == "gpu" + assert cfg.walltime == "02:00:00" + assert cfg.cpus_per_node == 16 + assert cfg.mem == "50G" + assert cfg.max_blocks == 4 + assert cfg.constraint == "a100" + + +class TestUserCancellation: + """If a questionary prompt returns None, UserCancelledError is raised.""" + + @patch("mdfactory.orchestration.tui.discover_cluster", return_value=None) + @patch("mdfactory.orchestration.tui._import_questionary") + def test_user_cancellation(self, mock_iq, _discover): + mock_q = mock_iq.return_value + # confirm manual? → None (cancelled) + mock_q.confirm.return_value.ask.return_value = None + + with pytest.raises(UserCancelledError): + configure_slurm_interactive() + + +# --------------------------------------------------------------------------- +# save_slurm_config_yaml tests +# --------------------------------------------------------------------------- + + +class TestSaveYaml: + def test_save_slurm_config_yaml(self, tmp_path): + cfg = SlurmExecutorConfig( + account="test_acc", + partition="cpu", + walltime="1h", + cpus_per_node=16, + gres="gpu:v100:1", + max_blocks=3, + ) + out = tmp_path / "slurm.yaml" + save_slurm_config_yaml(cfg, out) + + data = yaml.safe_load(out.read_text()) + assert data["account"] == "test_acc" + assert data["partition"] == "cpu" + assert data["walltime"] == "01:00:00" + assert data["cpus_per_node"] == 16 + assert data["gres"] == "gpu:v100:1" + assert data["max_blocks"] == 3 + assert data["provider"] == "slurm" diff --git a/mdfactory/tests/test_parametrization.py b/mdfactory/tests/test_parametrization.py index 6eceab1..47cbfd6 100644 --- a/mdfactory/tests/test_parametrization.py +++ b/mdfactory/tests/test_parametrization.py @@ -262,6 +262,28 @@ def test_smirnoff_parametrization_water(tmp_path): assert param.parametrization == "smirnoff" +@pytest.mark.skipif(not is_openff_available(), reason="OpenFF not available") +def test_smirnoff_parametrization_water_wat_resname(tmp_path): + """Test SMIRNOFF parametrization of water with non-SOL resname (regression). + + Verifies that water parametrization with resname="WAT" still produces + consistent SOL-based atom types and ITP files. Before the fix, this + caused a KeyError due to mismatched atom type prefixes (WAT_0 vs SOL_0). + """ + config.parameter_store = tmp_path / "parameters" + + spec = SingleMoleculeSpecies(smiles="O", count=1, resname="WAT") + param = parametrize_smirnoff_gromacs(spec) + + assert param.itp.is_file() + # Molecule type should always be SOL regardless of input resname + assert param.moleculetype == "SOL" + assert param.parametrization == "smirnoff" + # Verify the parameter ITP was generated correctly + assert param.parameter_itp is not None + assert param.parameter_itp.is_file() + + @pytest.mark.skipif(not is_openff_available(), reason="OpenFF not available") def test_smirnoff_parametrization_ions(tmp_path): """Test SMIRNOFF parametrization of ions.""" diff --git a/mdfactory/tests/test_prepare.py b/mdfactory/tests/test_prepare.py index b8db411..86b712e 100644 --- a/mdfactory/tests/test_prepare.py +++ b/mdfactory/tests/test_prepare.py @@ -120,3 +120,58 @@ def test_df_to_models(): assert 1 in errors assert 3 in errors # df.to_csv("test_errors.csv") + + +def test_tags_from_csv_dot_notation(): + """Test that tags.key columns in CSV produce BuildInput with tags dict.""" + row = { + "simulation_type": "mixedbox", + "engine": "gromacs", + "parametrization": "cgenff", + "system.total_count": 1000, + "system.species.ABC.smiles": "CCC", + "system.species.ABC.fraction": 0.5, + "system.species.DEF.smiles": "CCO", + "system.species.DEF.fraction": 0.5, + "tags.formulation_id": "F42", + "tags.project": "lnp_screen", + } + nested = dict_to_nested_dict_with_species_prefix(row) + assert nested["tags"] == {"formulation_id": "F42", "project": "lnp_screen"} + + inp = BuildInput(**nested) + assert inp.tags == {"formulation_id": "F42", "project": "lnp_screen"} + + +def test_tags_from_csv_df(): + """Test tags columns flow through df_to_build_input_models.""" + rows = [ + { + "simulation_type": "mixedbox", + "engine": "gromacs", + "parametrization": "cgenff", + "system.total_count": 1000, + "system.species.ABC.smiles": "CCC", + "system.species.ABC.fraction": 0.5, + "system.species.DEF.smiles": "CCO", + "system.species.DEF.fraction": 0.5, + "tags.project": "test", + }, + { + "simulation_type": "mixedbox", + "engine": "gromacs", + "parametrization": "cgenff", + "system.total_count": 1500, + "system.species.ABC.smiles": "CCC", + "system.species.ABC.fraction": 0.5, + "system.species.DEF.smiles": "CCO", + "system.species.DEF.fraction": 0.5, + "tags.project": "test", + }, + ] + df = pd.DataFrame(rows) + models, errors = df_to_build_input_models(df) + assert not errors + assert len(models) == 2 + assert models[0].tags == {"project": "test"} + assert models[1].tags == {"project": "test"} diff --git a/mdfactory/tests/test_search_cli.py b/mdfactory/tests/test_search_cli.py new file mode 100644 index 0000000..774b31d --- /dev/null +++ b/mdfactory/tests/test_search_cli.py @@ -0,0 +1,76 @@ +# ABOUTME: Tests for the CLI search command +# ABOUTME: Validates search flag parsing and output formatting +"""Tests for CLI search command.""" + +from pathlib import Path +from unittest.mock import patch + +import pandas as pd +import pytest + +from mdfactory import cli + + +def test_search_no_results(tmp_path, capsys): + """Test search command with no results prints message.""" + with patch.object(cli, "SimulationStore") as MockStore: + mock_store = MockStore.return_value + mock_store.search.return_value = pd.DataFrame( + columns=["hash", "path", "simulation_type", "status", "tags"] + ) + + cli.search_simulations(tmp_path) + + captured = capsys.readouterr() + assert "No simulations found" in captured.out + + +def test_search_with_results(tmp_path, capsys): + """Test search command prints results table.""" + with patch.object(cli, "SimulationStore") as MockStore: + mock_store = MockStore.return_value + mock_store.search.return_value = pd.DataFrame( + { + "hash": ["ABC123DEF456"], + "path": [Path("/tmp/sim1")], + "simulation_type": ["mixedbox"], + "status": ["production"], + "tags": [{"project": "test"}], + } + ) + + cli.search_simulations(tmp_path) + + captured = capsys.readouterr() + assert "ABC123DEF456"[:12] in captured.out + assert "mixedbox" in captured.out + assert "project=test" in captured.out + + +def test_search_tag_parsing(tmp_path, capsys): + """Test that --tag key=value is parsed correctly.""" + with patch.object(cli, "SimulationStore") as MockStore: + mock_store = MockStore.return_value + mock_store.search.return_value = pd.DataFrame( + columns=["hash", "path", "simulation_type", "status", "tags"] + ) + + cli.search_simulations( + tmp_path, + tag=["project=alpha", "batch=001"], + ) + + mock_store.search.assert_called_once_with( + simulation_type=None, + status=None, + hash_prefix=None, + tags={"project": "alpha", "batch": "001"}, + smiles=None, + ) + + +def test_search_invalid_tag_format(tmp_path): + """Test that invalid tag format raises SystemExit.""" + with patch.object(cli, "SimulationStore"): + with pytest.raises(SystemExit): + cli.search_simulations(tmp_path, tag=["invalid_no_equals"]) diff --git a/mdfactory/tests/test_simulation_store.py b/mdfactory/tests/test_simulation_store.py index 2bdbb79..faea7f1 100644 --- a/mdfactory/tests/test_simulation_store.py +++ b/mdfactory/tests/test_simulation_store.py @@ -21,6 +21,7 @@ def mock_build_input(): mock.simulation_type = "bilayer" mock.engine = "gromacs" mock.parametrization = "cgenff" + mock.tags = None mock.system = Mock() mock.system.total_count = 600 return mock @@ -716,3 +717,675 @@ def test_ensure_discovered_auto_runs(mock_discover, tmp_path, mock_discovery_df) # Should have auto-discovered mock_discover.assert_called_once() assert len(result) == 2 + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_min_status_passthrough(mock_discover, tmp_path, mock_discovery_df): + """Test that SimulationStore passes min_status to discover_simulations.""" + mock_discover.return_value = mock_discovery_df + + store = SimulationStore(tmp_path, min_status="build") + store.discover() + + # Check min_status was passed through + call_kwargs = mock_discover.call_args[1] + assert call_kwargs.get("min_status") == "build" + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_min_status_default(mock_discover, tmp_path, mock_discovery_df): + """Test that SimulationStore defaults to production min_status.""" + mock_discover.return_value = mock_discovery_df + + store = SimulationStore(tmp_path) + store.discover() + + call_kwargs = mock_discover.call_args[1] + assert call_kwargs.get("min_status") == "production" + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_metadata_table_includes_tags(mock_discover, tmp_path): + """Test that build_metadata_table includes tag columns.""" + from mdfactory.analysis.simulation import Simulation + + # Create mock with tags + mock_bi = Mock(spec=BuildInput) + mock_bi.hash = "HASH1" + mock_bi.simulation_type = "mixedbox" + mock_bi.tags = {"project": "test", "batch": "001"} + mock_bi.system = Mock() + mock_bi.system.total_count = 1000 + + sim_dir = tmp_path / "sim1" + sim_dir.mkdir() + (sim_dir / "system.pdb").touch() + (sim_dir / "prod.xtc").touch() + sim = Simulation(sim_dir, build_input=mock_bi) + + mock_discover.return_value = pd.DataFrame( + { + "hash": ["HASH1"], + "path": [sim_dir], + "simulation": [sim], + } + ) + + store = SimulationStore(tmp_path) + store.discover() + + def simple_flatten(bi): + return {"sim_type": bi.simulation_type} + + table = store.build_metadata_table(simple_flatten) + + assert "tag_project" in table.columns + assert "tag_batch" in table.columns + assert table.iloc[0]["tag_project"] == "test" + assert table.iloc[0]["tag_batch"] == "001" + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_metadata_table_no_tags(mock_discover, tmp_path): + """Test that build_metadata_table works when tags are None.""" + from mdfactory.analysis.simulation import Simulation + + mock_bi = Mock(spec=BuildInput) + mock_bi.hash = "HASH1" + mock_bi.simulation_type = "mixedbox" + mock_bi.tags = None + mock_bi.system = Mock() + mock_bi.system.total_count = 1000 + + sim_dir = tmp_path / "sim1" + sim_dir.mkdir() + (sim_dir / "system.pdb").touch() + (sim_dir / "prod.xtc").touch() + sim = Simulation(sim_dir, build_input=mock_bi) + + mock_discover.return_value = pd.DataFrame( + { + "hash": ["HASH1"], + "path": [sim_dir], + "simulation": [sim], + } + ) + + store = SimulationStore(tmp_path) + store.discover() + + def simple_flatten(bi): + return {"sim_type": bi.simulation_type} + + table = store.build_metadata_table(simple_flatten) + + assert "hash" in table.columns + assert "sim_type" in table.columns + # No tag_ columns should be present + tag_cols = [c for c in table.columns if c.startswith("tag_")] + assert len(tag_cols) == 0 + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_search_no_filters(mock_discover, tmp_path, mock_discovery_df): + """Test search with no filters returns all simulations.""" + mock_discover.return_value = mock_discovery_df + + store = SimulationStore(tmp_path) + store.discover() + results = store.search() + + assert len(results) == 2 + assert "hash" in results.columns + assert "path" in results.columns + assert "simulation_type" in results.columns + assert "status" in results.columns + assert "tags" in results.columns + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_search_by_simulation_type(mock_discover, tmp_path, mock_discovery_df): + """Test search filters by simulation_type.""" + mock_discover.return_value = mock_discovery_df + + store = SimulationStore(tmp_path) + store.discover() + + # mock_build_input has simulation_type="bilayer" + results = store.search(simulation_type="bilayer") + assert len(results) == 2 + + results = store.search(simulation_type="mixedbox") + assert len(results) == 0 + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_search_by_hash_prefix(mock_discover, tmp_path, mock_discovery_df): + """Test search filters by hash prefix.""" + mock_discover.return_value = mock_discovery_df + + store = SimulationStore(tmp_path) + store.discover() + + results = store.search(hash_prefix="HASH1") + assert len(results) == 1 + assert results.iloc[0]["hash"] == "HASH1" + + results = store.search(hash_prefix="HASH") + assert len(results) == 2 + + results = store.search(hash_prefix="NONEXISTENT") + assert len(results) == 0 + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_search_by_tags(mock_discover, tmp_path): + """Test search filters by tags.""" + from mdfactory.analysis.simulation import Simulation + + # Create two sims with different tags + mock_bi1 = Mock(spec=BuildInput) + mock_bi1.hash = "HASH1" + mock_bi1.simulation_type = "mixedbox" + mock_bi1.tags = {"project": "alpha", "batch": "001"} + mock_bi1.system = Mock() + mock_bi1.system.species = [] + + mock_bi2 = Mock(spec=BuildInput) + mock_bi2.hash = "HASH2" + mock_bi2.simulation_type = "mixedbox" + mock_bi2.tags = {"project": "beta"} + mock_bi2.system = Mock() + mock_bi2.system.species = [] + + sim1_dir = tmp_path / "sim1" + sim1_dir.mkdir() + (sim1_dir / "system.pdb").touch() + (sim1_dir / "prod.xtc").touch() + sim2_dir = tmp_path / "sim2" + sim2_dir.mkdir() + (sim2_dir / "system.pdb").touch() + (sim2_dir / "prod.xtc").touch() + + sim1 = Simulation(sim1_dir, build_input=mock_bi1) + sim2 = Simulation(sim2_dir, build_input=mock_bi2) + + mock_discover.return_value = pd.DataFrame( + { + "hash": ["HASH1", "HASH2"], + "path": [sim1_dir, sim2_dir], + "simulation": [sim1, sim2], + } + ) + + store = SimulationStore(tmp_path) + store.discover() + + results = store.search(tags={"project": "alpha"}) + assert len(results) == 1 + assert results.iloc[0]["hash"] == "HASH1" + + results = store.search(tags={"project": "beta"}) + assert len(results) == 1 + assert results.iloc[0]["hash"] == "HASH2" + + # Multiple tag filter (AND) + results = store.search(tags={"project": "alpha", "batch": "001"}) + assert len(results) == 1 + + results = store.search(tags={"project": "alpha", "batch": "999"}) + assert len(results) == 0 + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_search_no_tags_skipped(mock_discover, tmp_path): + """Test that simulations without tags are excluded when filtering by tags.""" + from mdfactory.analysis.simulation import Simulation + + mock_bi = Mock(spec=BuildInput) + mock_bi.hash = "HASH1" + mock_bi.simulation_type = "mixedbox" + mock_bi.tags = None + mock_bi.system = Mock() + mock_bi.system.species = [] + + sim_dir = tmp_path / "sim1" + sim_dir.mkdir() + (sim_dir / "system.pdb").touch() + (sim_dir / "prod.xtc").touch() + sim = Simulation(sim_dir, build_input=mock_bi) + + mock_discover.return_value = pd.DataFrame( + { + "hash": ["HASH1"], + "path": [sim_dir], + "simulation": [sim], + } + ) + + store = SimulationStore(tmp_path) + store.discover() + + results = store.search(tags={"project": "alpha"}) + assert len(results) == 0 + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_search_combined_filters(mock_discover, tmp_path): + """Test search with multiple filters applied together.""" + from mdfactory.analysis.simulation import Simulation + + mock_bi1 = Mock(spec=BuildInput) + mock_bi1.hash = "HASH1" + mock_bi1.simulation_type = "bilayer" + mock_bi1.tags = {"project": "alpha"} + mock_bi1.system = Mock() + mock_bi1.system.species = [] + + mock_bi2 = Mock(spec=BuildInput) + mock_bi2.hash = "HASH2" + mock_bi2.simulation_type = "mixedbox" + mock_bi2.tags = {"project": "alpha"} + mock_bi2.system = Mock() + mock_bi2.system.species = [] + + sim1_dir = tmp_path / "sim1" + sim1_dir.mkdir() + (sim1_dir / "system.pdb").touch() + (sim1_dir / "prod.xtc").touch() + sim2_dir = tmp_path / "sim2" + sim2_dir.mkdir() + (sim2_dir / "system.pdb").touch() + (sim2_dir / "prod.xtc").touch() + + sim1 = Simulation(sim1_dir, build_input=mock_bi1) + sim2 = Simulation(sim2_dir, build_input=mock_bi2) + + mock_discover.return_value = pd.DataFrame( + { + "hash": ["HASH1", "HASH2"], + "path": [sim1_dir, sim2_dir], + "simulation": [sim1, sim2], + } + ) + + store = SimulationStore(tmp_path) + store.discover() + + # Both match project=alpha + results = store.search(tags={"project": "alpha"}) + assert len(results) == 2 + + # Only HASH1 is bilayer + results = store.search(tags={"project": "alpha"}, simulation_type="bilayer") + assert len(results) == 1 + assert results.iloc[0]["hash"] == "HASH1" + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_search_empty_store(mock_discover, tmp_path): + """Test search on empty store returns empty DataFrame with correct columns.""" + mock_discover.return_value = pd.DataFrame(columns=["hash", "path", "simulation"]) + + store = SimulationStore(tmp_path) + store.discover() + + results = store.search() + assert len(results) == 0 + assert list(results.columns) == ["hash", "path", "simulation_type", "status", "tags"] + + +# --------------------------------------------------------------------------- +# Finding 3: search(smiles=...) integration tests +# --------------------------------------------------------------------------- + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_search_smiles_match(mock_discover, tmp_path): + """Test search(smiles=...) returns simulation whose species match the substructure.""" + from mdfactory.analysis.simulation import Simulation + + # Create species with smiles attributes + sp1 = Mock() + sp1.smiles = "CCO" + sp1.resname = "ETH" + + mock_bi = Mock(spec=BuildInput) + mock_bi.hash = "HASH1" + mock_bi.simulation_type = "bilayer" + mock_bi.tags = None + mock_bi.system = Mock() + mock_bi.system.species = [sp1] + + sim_dir = tmp_path / "sim1" + sim_dir.mkdir() + (sim_dir / "system.pdb").touch() + (sim_dir / "prod.xtc").touch() + sim = Simulation(sim_dir, build_input=mock_bi) + + mock_discover.return_value = pd.DataFrame( + { + "hash": ["HASH1"], + "path": [sim_dir], + "simulation": [sim], + } + ) + + store = SimulationStore(tmp_path) + store.discover() + + with patch( + "mdfactory.utils.chemistry_utilities.smiles_substructure_match", + return_value=True, + ): + results = store.search(smiles="CC") + + assert len(results) == 1 + assert results.iloc[0]["hash"] == "HASH1" + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_search_smiles_no_match(mock_discover, tmp_path): + """Test search(smiles=...) excludes simulation whose species do NOT match.""" + from mdfactory.analysis.simulation import Simulation + + sp1 = Mock() + sp1.smiles = "CCO" + sp1.resname = "ETH" + + mock_bi = Mock(spec=BuildInput) + mock_bi.hash = "HASH1" + mock_bi.simulation_type = "bilayer" + mock_bi.tags = None + mock_bi.system = Mock() + mock_bi.system.species = [sp1] + + sim_dir = tmp_path / "sim1" + sim_dir.mkdir() + (sim_dir / "system.pdb").touch() + (sim_dir / "prod.xtc").touch() + sim = Simulation(sim_dir, build_input=mock_bi) + + mock_discover.return_value = pd.DataFrame( + { + "hash": ["HASH1"], + "path": [sim_dir], + "simulation": [sim], + } + ) + + store = SimulationStore(tmp_path) + store.discover() + + with patch( + "mdfactory.utils.chemistry_utilities.smiles_substructure_match", + return_value=False, + ): + results = store.search(smiles="c1ccccc1") + + assert len(results) == 0 + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_search_smiles_filters_correctly(mock_discover, tmp_path): + """Test search(smiles=...) returns only matching simulations out of multiple.""" + from mdfactory.analysis.simulation import Simulation + + # Sim 1: species with matching SMILES + sp_match = Mock() + sp_match.smiles = "CCCCCCCC" + sp_match.resname = "OCT" + + mock_bi1 = Mock(spec=BuildInput) + mock_bi1.hash = "HASH1" + mock_bi1.simulation_type = "bilayer" + mock_bi1.tags = None + mock_bi1.system = Mock() + mock_bi1.system.species = [sp_match] + + # Sim 2: species with non-matching SMILES + sp_nomatch = Mock() + sp_nomatch.smiles = "O" + sp_nomatch.resname = "WAT" + + mock_bi2 = Mock(spec=BuildInput) + mock_bi2.hash = "HASH2" + mock_bi2.simulation_type = "bilayer" + mock_bi2.tags = None + mock_bi2.system = Mock() + mock_bi2.system.species = [sp_nomatch] + + sim1_dir = tmp_path / "sim1" + sim1_dir.mkdir() + (sim1_dir / "system.pdb").touch() + (sim1_dir / "prod.xtc").touch() + sim2_dir = tmp_path / "sim2" + sim2_dir.mkdir() + (sim2_dir / "system.pdb").touch() + (sim2_dir / "prod.xtc").touch() + + sim1 = Simulation(sim1_dir, build_input=mock_bi1) + sim2 = Simulation(sim2_dir, build_input=mock_bi2) + + mock_discover.return_value = pd.DataFrame( + { + "hash": ["HASH1", "HASH2"], + "path": [sim1_dir, sim2_dir], + "simulation": [sim1, sim2], + } + ) + + store = SimulationStore(tmp_path) + store.discover() + + def _mock_substructure(query, target): + """Return True only for CCCCCCCC (the octane species).""" + return target == "CCCCCCCC" + + with patch( + "mdfactory.utils.chemistry_utilities.smiles_substructure_match", + side_effect=_mock_substructure, + ): + results = store.search(smiles="CCCC") + + assert len(results) == 1 + assert results.iloc[0]["hash"] == "HASH1" + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_search_smiles_import_error(mock_discover, tmp_path, mock_discovery_df): + """Test search(smiles=...) raises ImportError when RDKit is unavailable.""" + mock_discover.return_value = mock_discovery_df + + store = SimulationStore(tmp_path) + store.discover() + + with patch( + "mdfactory.utils.chemistry_utilities.smiles_substructure_match", + side_effect=ImportError("No module named 'rdkit'"), + ): + with patch.dict( + "sys.modules", + {"mdfactory.utils.chemistry_utilities": None}, + ): + with pytest.raises(ImportError, match="RDKit is required"): + store.search(smiles="CCO") + + +# --------------------------------------------------------------------------- +# Finding 4: search(status=...) threshold logic tests +# --------------------------------------------------------------------------- + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_search_status_threshold_includes(mock_discover, tmp_path): + """Test search(status=...) includes simulations at or above threshold.""" + from mdfactory.analysis.simulation import Simulation + + mock_bi1 = Mock(spec=BuildInput) + mock_bi1.hash = "HASH1" + mock_bi1.simulation_type = "bilayer" + mock_bi1.tags = None + mock_bi1.system = Mock() + mock_bi1.system.species = [] + + mock_bi2 = Mock(spec=BuildInput) + mock_bi2.hash = "HASH2" + mock_bi2.simulation_type = "bilayer" + mock_bi2.tags = None + mock_bi2.system = Mock() + mock_bi2.system.species = [] + + sim1_dir = tmp_path / "sim1" + sim1_dir.mkdir() + (sim1_dir / "system.pdb").touch() + (sim1_dir / "prod.xtc").touch() + # sim1: has prod.xtc but no prod.gro → "production" + + sim2_dir = tmp_path / "sim2" + sim2_dir.mkdir() + (sim2_dir / "system.pdb").touch() + (sim2_dir / "prod.xtc").touch() + (sim2_dir / "prod.gro").touch() + # sim2: has prod.gro → "completed" + + sim1 = Simulation(sim1_dir, build_input=mock_bi1) + sim2 = Simulation(sim2_dir, build_input=mock_bi2) + + assert sim1.status == "production" + assert sim2.status == "completed" + + mock_discover.return_value = pd.DataFrame( + { + "hash": ["HASH1", "HASH2"], + "path": [sim1_dir, sim2_dir], + "simulation": [sim1, sim2], + } + ) + + store = SimulationStore(tmp_path) + store.discover() + + # status="production" → both should be included (production >= production) + results = store.search(status="production") + assert len(results) == 2 + + # status="completed" → only sim2 + results = store.search(status="completed") + assert len(results) == 1 + assert results.iloc[0]["hash"] == "HASH2" + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_search_status_threshold_excludes_build(mock_discover, tmp_path): + """Test search(status='production') excludes build-only simulations.""" + from mdfactory.analysis.simulation import Simulation + + mock_bi = Mock(spec=BuildInput) + mock_bi.hash = "HASH1" + mock_bi.simulation_type = "bilayer" + mock_bi.tags = None + mock_bi.system = Mock() + mock_bi.system.species = [] + + sim_dir = tmp_path / "sim1" + sim_dir.mkdir() + (sim_dir / "system.pdb").touch() + # No prod.xtc, no prod.gro, no equilibration files → "build" + + sim = Simulation(sim_dir, build_input=mock_bi, trajectory_file=None) + + assert sim.status == "build" + + mock_discover.return_value = pd.DataFrame( + { + "hash": ["HASH1"], + "path": [sim_dir], + "simulation": [sim], + } + ) + + store = SimulationStore(tmp_path) + store.discover() + + results = store.search(status="production") + assert len(results) == 0 + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_search_status_invalid_raises(mock_discover, tmp_path, mock_discovery_df): + """Test search(status=...) raises ValueError for an invalid status string.""" + mock_discover.return_value = mock_discovery_df + + store = SimulationStore(tmp_path) + store.discover() + + with pytest.raises(ValueError, match="Invalid status"): + store.search(status="nonexistent_status") + + +# --------------------------------------------------------------------------- +# Finding 18: remove_all_analyses(simulation_type=...) filter test +# --------------------------------------------------------------------------- + + +@patch("mdfactory.analysis.store.discover_simulations") +def test_remove_all_analyses_filtered(mock_discover, tmp_path): + """Test remove_all_analyses respects simulation_type filter.""" + from mdfactory.analysis.simulation import Simulation + + # Create two simulations of different types + mock_bi1 = Mock(spec=BuildInput) + mock_bi1.hash = "HASH1" + mock_bi1.simulation_type = "bilayer" + mock_bi1.tags = None + mock_bi1.system = Mock() + mock_bi1.system.total_count = 600 + + mock_bi2 = Mock(spec=BuildInput) + mock_bi2.hash = "HASH2" + mock_bi2.simulation_type = "mixedbox" + mock_bi2.tags = None + mock_bi2.system = Mock() + mock_bi2.system.total_count = 600 + + sim1_dir = tmp_path / "sim1" + sim1_dir.mkdir() + (sim1_dir / "system.pdb").touch() + (sim1_dir / "prod.xtc").touch() + sim2_dir = tmp_path / "sim2" + sim2_dir.mkdir() + (sim2_dir / "system.pdb").touch() + (sim2_dir / "prod.xtc").touch() + + sim1 = Simulation(sim1_dir, build_input=mock_bi1) + sim2 = Simulation(sim2_dir, build_input=mock_bi2) + + mock_discover.return_value = pd.DataFrame( + { + "hash": ["HASH1", "HASH2"], + "path": [sim1_dir, sim2_dir], + "simulation": [sim1, sim2], + } + ) + + store = SimulationStore(tmp_path) + store.discover() + + # Filter to only bilayer — should only affect sim1 + summary = store.remove_all_analyses(simulation_type="bilayer") + assert len(summary) == 1 + assert summary.iloc[0]["hash"] == "HASH1" + assert summary.iloc[0]["simulation_type"] == "bilayer" + assert summary.iloc[0]["status"] == "success" + + # Filter to mixedbox — should only affect sim2 + summary = store.remove_all_analyses(simulation_type="mixedbox") + assert len(summary) == 1 + assert summary.iloc[0]["hash"] == "HASH2" + assert summary.iloc[0]["simulation_type"] == "mixedbox" + assert summary.iloc[0]["status"] == "success" + + # Filter to nonexistent type — empty result + summary = store.remove_all_analyses(simulation_type="unknown") + assert summary.empty diff --git a/mdfactory/tests/test_slurm_config.py b/mdfactory/tests/test_slurm_config.py new file mode 100644 index 0000000..20a0e9e --- /dev/null +++ b/mdfactory/tests/test_slurm_config.py @@ -0,0 +1,386 @@ +# ABOUTME: Unit tests for BaseSlurmConfig, SlurmConfig, and normalize_slurm_time. +# ABOUTME: Uses mocked discover_cluster() so tests run on non-SLURM machines. +"""Unit tests for mdfactory.performance.slurm_config.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from mdfactory.performance import cluster as cluster_mod +from mdfactory.performance.slurm_config import ( + BaseSlurmConfig, + SlurmConfig, + normalize_slurm_time, +) + +# --------------------------------------------------------------------------- +# normalize_slurm_time +# --------------------------------------------------------------------------- + + +class TestNormalizeSlurmTime: + """Edge-case coverage for the time-string normaliser.""" + + def test_hours_shorthand(self): + assert normalize_slurm_time("2h") == "02:00:00" + + def test_hours_single_digit(self): + assert normalize_slurm_time("1h") == "01:00:00" + + def test_hours_large(self): + assert normalize_slurm_time("24h") == "24:00:00" + + def test_minutes_shorthand(self): + assert normalize_slurm_time("30m") == "00:30:00" + + def test_minutes_overflow(self): + assert normalize_slurm_time("90m") == "01:30:00" + + def test_minutes_as_integer(self): + assert normalize_slurm_time("90") == "01:30:00" + assert normalize_slurm_time("120") == "02:00:00" + + def test_days_shorthand(self): + assert normalize_slurm_time("1d") == "1-00:00:00" + assert normalize_slurm_time("3d") == "3-00:00:00" + + def test_hms_passthrough(self): + assert normalize_slurm_time("01:00:00") == "01:00:00" + assert normalize_slurm_time("3-00:00:00") == "3-00:00:00" + + def test_strips_whitespace(self): + assert normalize_slurm_time(" 2h ") == "02:00:00" + + +# --------------------------------------------------------------------------- +# SlurmConfig — Pydantic model behaviour +# --------------------------------------------------------------------------- + + +class TestSlurmConfigModel: + """Tests for the SlurmConfig Pydantic model (no SLURM required).""" + + def test_minimal_construction(self): + cfg = SlurmConfig(account="mygroup") + assert cfg.account == "mygroup" + assert cfg.partition == "cpu" + assert cfg.time == "02:00:00" # "2h" normalised on construction + assert cfg.cpus_per_task == 4 + assert cfg.mem_gb == 8 + assert cfg.job_name_prefix == "mdfactory-analysis" + assert cfg.qos is None + assert cfg.constraint is None + + def test_time_normalised_on_construction(self): + cfg = SlurmConfig(account="grp", time="4h") + assert cfg.time == "04:00:00" + + def test_time_hms_passthrough(self): + cfg = SlurmConfig(account="grp", time="02:00:00") + assert cfg.time == "02:00:00" + + def test_time_minutes_shorthand(self): + cfg = SlurmConfig(account="grp", time="30m") + assert cfg.time == "00:30:00" + + def test_time_integer_minutes(self): + cfg = SlurmConfig(account="grp", time="120") + assert cfg.time == "02:00:00" + + def test_frozen(self): + cfg = SlurmConfig(account="grp") + with pytest.raises(Exception): # ValidationError or AttributeError + cfg.account = "other" # type: ignore[misc] + + def test_inherits_base_slurm_config(self): + assert issubclass(SlurmConfig, BaseSlurmConfig) + + def test_optional_fields(self): + cfg = SlurmConfig( + account="grp", + partition="gpu", + qos="high", + constraint="a100", + time="1d", + cpus_per_task=16, + mem_gb=64, + job_name_prefix="my-job", + ) + assert cfg.partition == "gpu" + assert cfg.qos == "high" + assert cfg.constraint == "a100" + assert cfg.time == "1-00:00:00" + assert cfg.cpus_per_task == 16 + assert cfg.mem_gb == 64 + assert cfg.job_name_prefix == "my-job" + + +# --------------------------------------------------------------------------- +# SlurmConfig.from_yaml +# --------------------------------------------------------------------------- + + +class TestSlurmConfigFromYaml: + def test_round_trip(self, tmp_path: Path): + data = { + "account": "mygroup", + "partition": "compute", + "time": "4h", + "cpus_per_task": 8, + "mem_gb": 32, + } + yaml_file = tmp_path / "slurm.yaml" + yaml_file.write_text(yaml.dump(data)) + + cfg = SlurmConfig.from_yaml(yaml_file) + + assert cfg.account == "mygroup" + assert cfg.partition == "compute" + assert cfg.time == "04:00:00" # normalised + assert cfg.cpus_per_task == 8 + assert cfg.mem_gb == 32 + + def test_missing_file_raises(self, tmp_path: Path): + with pytest.raises(FileNotFoundError): + SlurmConfig.from_yaml(tmp_path / "nonexistent.yaml") + + def test_defaults_applied(self, tmp_path: Path): + yaml_file = tmp_path / "minimal.yaml" + yaml_file.write_text("account: grp\n") + + cfg = SlurmConfig.from_yaml(yaml_file) + assert cfg.time == "02:00:00" + assert cfg.cpus_per_task == 4 + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def no_slurm_settings(monkeypatch): + """Return None for all [slurm] settings — simulates an unconfigured machine. + + Without this fixture, tests that expect autodiscovery to be the sole source + of truth would behave differently on machines that have ACCOUNT / PARTITION_CPU + set in their config.ini. + """ + from mdfactory.settings import Settings + + monkeypatch.setattr(Settings, "slurm_account", property(lambda self: None)) + monkeypatch.setattr(Settings, "slurm_partition_cpu", property(lambda self: None)) + monkeypatch.setattr(Settings, "slurm_partition_gpu", property(lambda self: None)) + monkeypatch.setattr(Settings, "slurm_qos", property(lambda self: None)) + + +# --------------------------------------------------------------------------- +# Helpers to build mock cluster objects +# --------------------------------------------------------------------------- + + +def _make_cpu_partition(name: str = "compute", cpus: int = 32) -> cluster_mod.Partition: + return cluster_mod.Partition( + name=name, + state="up", + max_time="3-00:00:00", + default_time="1:00:00", + node_types=[cluster_mod.NodeType(cpus=cpus, memory_mb=128 * 1024, gpu_specs=(), count=10)], + total_nodes=10, + is_default=True, + ) + + +def _make_gpu_partition(name: str = "gpu") -> cluster_mod.Partition: + return cluster_mod.Partition( + name=name, + state="up", + max_time="2-00:00:00", + default_time="1:00:00", + node_types=[ + cluster_mod.NodeType( + cpus=32, + memory_mb=128 * 1024, + gpu_specs=((4, "a100"),), + count=20, + ) + ], + total_nodes=20, + is_default=False, + ) + + +def _make_cluster( + partitions: list[cluster_mod.Partition] | None = None, + default_account: str | None = "myaccount", +) -> cluster_mod.ClusterInfo: + if partitions is None: + partitions = [_make_cpu_partition()] + return cluster_mod.ClusterInfo( + partitions=partitions, + accounts=[default_account] if default_account else [], + qos_policies=["normal"], + default_account=default_account, + ) + + +# --------------------------------------------------------------------------- +# BaseSlurmConfig.from_cluster — 3-tier precedence +# --------------------------------------------------------------------------- + + +class TestBaseSlurmConfigFromCluster: + """Tests run without a real SLURM cluster (discover_cluster mocked).""" + + def test_autodiscovery_populates_account_and_partition(self, monkeypatch, no_slurm_settings): + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: _make_cluster()) + cfg = SlurmConfig.from_cluster() + assert cfg.account == "myaccount" + assert cfg.partition == "compute" + + def test_no_slurm_raises(self, monkeypatch, no_slurm_settings): + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: None) + with pytest.raises(RuntimeError, match="SLURM autodiscovery failed and no account"): + SlurmConfig.from_cluster() + + def test_no_default_account_raises(self, monkeypatch, no_slurm_settings): + cluster = _make_cluster(default_account=None) + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: cluster) + with pytest.raises(RuntimeError, match="No SLURM account available"): + SlurmConfig.from_cluster() + + def test_no_suitable_partition_raises(self, monkeypatch, no_slurm_settings): + # Tiny partition: 4 CPUs — won't satisfy min_cpus=32 + cluster = _make_cluster(partitions=[_make_cpu_partition(cpus=4)]) + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: cluster) + with pytest.raises(RuntimeError, match="No suitable partition found"): + SlurmConfig.from_cluster(min_cpus=32) + + def test_autodiscovery_fails_no_partition_in_config_raises( + self, monkeypatch, no_slurm_settings + ): + """cluster=None with account supplied but no partition in config raises RuntimeError.""" + from mdfactory.settings import Settings + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: None) + # Provide account so resolution reaches the partition step + monkeypatch.setattr(Settings, "slurm_account", property(lambda self: "myaccount")) + with pytest.raises(RuntimeError, match="no partition configured"): + SlurmConfig.from_cluster() + + def test_needs_gpu_selects_gpu_partition(self, monkeypatch, no_slurm_settings): + cluster = _make_cluster(partitions=[_make_cpu_partition(), _make_gpu_partition()]) + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: cluster) + cfg = SlurmConfig.from_cluster(needs_gpu=True) + assert cfg.partition == "gpu" + + # --- tier 2: config.ini overrides autodiscovery --- + + def test_config_account_overrides_autodiscovery(self, monkeypatch): + from mdfactory.settings import Settings + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: _make_cluster()) + monkeypatch.setattr(Settings, "slurm_account", property(lambda self: "config-account")) + cfg = SlurmConfig.from_cluster() + assert cfg.account == "config-account" + + def test_config_cpu_partition_overrides_autodiscovery(self, monkeypatch): + from mdfactory.settings import Settings + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: _make_cluster()) + monkeypatch.setattr(Settings, "slurm_partition_cpu", property(lambda self: "config-cpu")) + cfg = SlurmConfig.from_cluster(needs_gpu=False) + assert cfg.partition == "config-cpu" + + def test_config_gpu_partition_overrides_autodiscovery(self, monkeypatch): + from mdfactory.settings import Settings + + cluster = _make_cluster(partitions=[_make_cpu_partition(), _make_gpu_partition()]) + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: cluster) + monkeypatch.setattr(Settings, "slurm_partition_gpu", property(lambda self: "config-gpu")) + cfg = SlurmConfig.from_cluster(needs_gpu=True) + assert cfg.partition == "config-gpu" + + def test_config_qos_propagated(self, monkeypatch): + from mdfactory.settings import Settings + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: _make_cluster()) + monkeypatch.setattr(Settings, "slurm_qos", property(lambda self: "high")) + cfg = SlurmConfig.from_cluster() + assert cfg.qos == "high" + + # --- tier 1: explicit kwargs override everything --- + + def test_explicit_account_overrides_config_and_autodiscovery(self, monkeypatch): + from mdfactory.settings import Settings + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: _make_cluster()) + monkeypatch.setattr(Settings, "slurm_account", property(lambda self: "config-account")) + cfg = SlurmConfig.from_cluster(account="explicit-account") + assert cfg.account == "explicit-account" + + def test_explicit_partition_overrides_config_and_autodiscovery(self, monkeypatch): + from mdfactory.settings import Settings + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: _make_cluster()) + monkeypatch.setattr( + Settings, "slurm_partition_cpu", property(lambda self: "config-partition") + ) + cfg = SlurmConfig.from_cluster(partition="explicit-partition") + assert cfg.partition == "explicit-partition" + + def test_explicit_qos_overrides_config(self, monkeypatch): + from mdfactory.settings import Settings + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: _make_cluster()) + monkeypatch.setattr(Settings, "slurm_qos", property(lambda self: "high")) + cfg = SlurmConfig.from_cluster(qos="debug") + assert cfg.qos == "debug" + + def test_explicit_constraint_forwarded(self, monkeypatch): + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: _make_cluster()) + cfg = SlurmConfig.from_cluster(constraint="epyc") + assert cfg.constraint == "epyc" + + # --- submitit-specific extra fields forwarded --- + + def test_submitit_fields_forwarded(self, monkeypatch): + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: _make_cluster()) + cfg = SlurmConfig.from_cluster( + time="4h", + cpus_per_task=8, + mem_gb=32, + job_name_prefix="my-prefix", + ) + assert cfg.time == "04:00:00" + assert cfg.cpus_per_task == 8 + assert cfg.mem_gb == 32 + assert cfg.job_name_prefix == "my-prefix" + + def test_returns_slurm_config_type(self, monkeypatch): + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: _make_cluster()) + result = SlurmConfig.from_cluster() + assert isinstance(result, SlurmConfig) + + +# --------------------------------------------------------------------------- +# Backward-compat import from mdfactory.analysis.submit +# --------------------------------------------------------------------------- + + +def test_backward_compat_import_slurm_config(): + """from mdfactory.analysis.submit import SlurmConfig must still work.""" + from mdfactory.analysis.submit import SlurmConfig as SubmitSlurmConfig + + cfg = SubmitSlurmConfig(account="grp") + assert cfg.account == "grp" + + +def test_backward_compat_import_normalize_slurm_time(): + """from mdfactory.analysis.submit import normalize_slurm_time must still work.""" + from mdfactory.analysis.submit import normalize_slurm_time as nslt + + assert nslt("2h") == "02:00:00" diff --git a/mdfactory/tests/test_submit.py b/mdfactory/tests/test_submit.py index 3d6524a..92017e7 100644 --- a/mdfactory/tests/test_submit.py +++ b/mdfactory/tests/test_submit.py @@ -23,6 +23,8 @@ def test_resolve_simulation_paths_from_yaml(tmp_path): def test_normalize_slurm_time(): + # normalize_slurm_time is re-exported from mdfactory.analysis.submit for + # backward compatibility — the canonical version lives in performance.slurm_config. assert submit_mod.normalize_slurm_time("2h") == "02:00:00" assert submit_mod.normalize_slurm_time("30m") == "00:30:00" assert submit_mod.normalize_slurm_time("90") == "01:30:00" @@ -385,3 +387,258 @@ def submit(self, func, sim_path, analysis_names, **kwargs): assert len(submitted_kwargs) == 1 assert submitted_kwargs[0]["analysis_kwargs"] == {"start_ns": 50.0, "stride": 2} + + +# --- SlurmConfig.from_cluster tests --- + + +class TestSlurmConfigFromCluster: + """Tests for SlurmConfig.from_cluster() autodiscovery method.""" + + def test_from_cluster_success(self, monkeypatch): + """Test successful autodiscovery creates correct config.""" + from mdfactory.performance import cluster as cluster_mod + + # Create mock cluster info + mock_partition = cluster_mod.Partition( + name="compute", + state="up", + max_time="3-00:00:00", + default_time="1:00:00", + node_types=[ + cluster_mod.NodeType(cpus=32, memory_mb=128 * 1024, gpu_specs=(), count=10), + ], + total_nodes=10, + is_default=True, + ) + mock_cluster = cluster_mod.ClusterInfo( + partitions=[mock_partition], + accounts=["myaccount", "otheraccount"], + qos_policies=["normal", "high"], + default_account="myaccount", + ) + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: mock_cluster) + + config = submit_mod.SlurmConfig.from_cluster( + time="4h", + cpus_per_task=8, + mem_gb=16, + ) + + assert config.account == "myaccount" + assert config.partition == "compute" + assert config.time == "04:00:00" + assert config.cpus_per_task == 8 + assert config.mem_gb == 16 + + def test_from_cluster_no_slurm_raises(self, monkeypatch): + """Test that missing SLURM raises RuntimeError.""" + from mdfactory.performance import cluster as cluster_mod + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: None) + + import pytest + + with pytest.raises( + RuntimeError, match="SLURM autodiscovery failed and no account configured" + ): + submit_mod.SlurmConfig.from_cluster() + + def test_from_cluster_no_account_raises(self, monkeypatch): + """Test that missing default account raises RuntimeError.""" + from mdfactory.performance import cluster as cluster_mod + + mock_partition = cluster_mod.Partition( + name="compute", + state="up", + max_time="1-00:00:00", + default_time="1:00:00", + node_types=[cluster_mod.NodeType(cpus=16, memory_mb=64 * 1024, count=5)], + total_nodes=5, + is_default=True, + ) + mock_cluster = cluster_mod.ClusterInfo( + partitions=[mock_partition], + accounts=[], + qos_policies=[], + default_account=None, + ) + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: mock_cluster) + + import pytest + + with pytest.raises(RuntimeError, match="No SLURM account available"): + submit_mod.SlurmConfig.from_cluster() + + def test_from_cluster_no_suitable_partition_raises(self, monkeypatch): + """Test that no matching partition raises RuntimeError.""" + from mdfactory.performance import cluster as cluster_mod + + # Partition with only 4 CPUs - won't match request for 32 CPUs + mock_partition = cluster_mod.Partition( + name="tiny", + state="up", + max_time="1:00:00", + default_time="0:30:00", + node_types=[cluster_mod.NodeType(cpus=4, memory_mb=8 * 1024, count=2)], + total_nodes=2, + is_default=True, + ) + mock_cluster = cluster_mod.ClusterInfo( + partitions=[mock_partition], + accounts=["myaccount"], + qos_policies=[], + default_account="myaccount", + ) + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: mock_cluster) + + import pytest + + with pytest.raises(RuntimeError, match="No suitable partition found"): + submit_mod.SlurmConfig.from_cluster(min_cpus=32) + + def test_from_cluster_selects_gpu_partition(self, monkeypatch): + """Test that needs_gpu=True selects GPU partition.""" + from mdfactory.performance import cluster as cluster_mod + + cpu_partition = cluster_mod.Partition( + name="cpu", + state="up", + max_time="7-00:00:00", + default_time="1:00:00", + node_types=[ + cluster_mod.NodeType(cpus=64, memory_mb=256 * 1024, gpu_specs=(), count=100) + ], + total_nodes=100, + is_default=True, + ) + gpu_partition = cluster_mod.Partition( + name="gpu", + state="up", + max_time="2-00:00:00", + default_time="1:00:00", + node_types=[ + cluster_mod.NodeType( + cpus=32, memory_mb=128 * 1024, gpu_specs=((4, "a100"),), count=20 + ) + ], + total_nodes=20, + is_default=False, + ) + mock_cluster = cluster_mod.ClusterInfo( + partitions=[cpu_partition, gpu_partition], + accounts=["myaccount"], + qos_policies=[], + default_account="myaccount", + ) + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: mock_cluster) + + config = submit_mod.SlurmConfig.from_cluster(needs_gpu=True) + + assert config.partition == "gpu" + assert config.account == "myaccount" + + def test_from_cluster_uses_config_account(self, monkeypatch): + """Test that config account takes precedence over autodiscovery.""" + from mdfactory.performance import cluster as cluster_mod + from mdfactory.settings import Settings + + mock_partition = cluster_mod.Partition( + name="compute", + state="up", + max_time="3-00:00:00", + default_time="1:00:00", + node_types=[ + cluster_mod.NodeType(cpus=32, memory_mb=128 * 1024, gpu_specs=(), count=10), + ], + total_nodes=10, + is_default=True, + ) + mock_cluster = cluster_mod.ClusterInfo( + partitions=[mock_partition], + accounts=["autodiscovered-account"], + qos_policies=[], + default_account="autodiscovered-account", + ) + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: mock_cluster) + monkeypatch.setattr(Settings, "slurm_account", property(lambda self: "config-account")) + + config = submit_mod.SlurmConfig.from_cluster() + + # Should use config account, not autodiscovered + assert config.account == "config-account" + + def test_from_cluster_uses_config_partition(self, monkeypatch): + """Test that config partition takes precedence over autodiscovery.""" + from mdfactory.performance import cluster as cluster_mod + from mdfactory.settings import Settings + + mock_partition = cluster_mod.Partition( + name="autodiscovered-partition", + state="up", + max_time="3-00:00:00", + default_time="1:00:00", + node_types=[ + cluster_mod.NodeType(cpus=32, memory_mb=128 * 1024, gpu_specs=(), count=10), + ], + total_nodes=10, + is_default=True, + ) + mock_cluster = cluster_mod.ClusterInfo( + partitions=[mock_partition], + accounts=["myaccount"], + qos_policies=[], + default_account="myaccount", + ) + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: mock_cluster) + monkeypatch.setattr( + Settings, "slurm_partition_cpu", property(lambda self: "config-partition") + ) + + config = submit_mod.SlurmConfig.from_cluster(needs_gpu=False) + + # Should use config partition, not autodiscovered + assert config.partition == "config-partition" + + def test_from_cluster_explicit_overrides_config(self, monkeypatch): + """Test that explicit parameters override config values.""" + from mdfactory.performance import cluster as cluster_mod + from mdfactory.settings import Settings + + mock_partition = cluster_mod.Partition( + name="any-partition", + state="up", + max_time="3-00:00:00", + default_time="1:00:00", + node_types=[ + cluster_mod.NodeType(cpus=32, memory_mb=128 * 1024, gpu_specs=(), count=10), + ], + total_nodes=10, + is_default=True, + ) + mock_cluster = cluster_mod.ClusterInfo( + partitions=[mock_partition], + accounts=["myaccount"], + qos_policies=[], + default_account="myaccount", + ) + + monkeypatch.setattr(cluster_mod, "discover_cluster", lambda: mock_cluster) + monkeypatch.setattr(Settings, "slurm_account", property(lambda self: "config-account")) + monkeypatch.setattr( + Settings, "slurm_partition_cpu", property(lambda self: "config-partition") + ) + + config = submit_mod.SlurmConfig.from_cluster( + account="explicit-account", partition="explicit-partition" + ) + + # Should use explicit values + assert config.account == "explicit-account" + assert config.partition == "explicit-partition" diff --git a/mdfactory/tests/test_tui.py b/mdfactory/tests/test_tui.py new file mode 100644 index 0000000..328870a --- /dev/null +++ b/mdfactory/tests/test_tui.py @@ -0,0 +1,96 @@ +# ABOUTME: Tests for the TUI browse command and Textual app +# ABOUTME: Validates app composition, filter parsing, and browse CLI integration +"""Tests for TUI browse command and Textual app.""" + +import pytest + +from mdfactory import cli +from mdfactory.analysis.store import SimulationStore + +# Check if textual is available for TUI tests +try: + import textual # noqa: F401 + + HAS_TEXTUAL = True +except ImportError: + HAS_TEXTUAL = False + + +def test_browse_without_textual(tmp_path, monkeypatch): + """Test browse command gives clear error when textual not installed.""" + import mdfactory.tui as tui_module + + def mock_check(): + raise ImportError( + "The TUI requires the 'textual' package. Install it with: pip install mdfactory[tui]" + ) + + monkeypatch.setattr(tui_module, "_check_textual_available", mock_check) + + with pytest.raises(SystemExit): + cli.browse_simulations(tmp_path) + + +def test_tui_check_textual_available(): + """Test _check_textual_available function.""" + from mdfactory.tui import _check_textual_available + + if HAS_TEXTUAL: + _check_textual_available() + else: + with pytest.raises(ImportError, match="textual"): + _check_textual_available() + + +@pytest.mark.skipif(not HAS_TEXTUAL, reason="textual not installed") +class TestSimulationBrowser: + """Tests for the Textual SimulationBrowser app.""" + + def test_app_creation(self, tmp_path): + """Test SimulationBrowser can be instantiated.""" + from mdfactory.tui.app import SimulationBrowser + + store = SimulationStore(tmp_path, min_status="build") + app = SimulationBrowser(store=store) + assert app.store is store + + @pytest.mark.asyncio + async def test_app_compose(self, tmp_path): + """Test app composes correctly with all widgets.""" + from textual.widgets import DataTable, Input, Select + + from mdfactory.tui.app import SimulationBrowser, SimulationDetail + + store = SimulationStore(tmp_path, min_status="build") + app = SimulationBrowser(store=store) + + async with app.run_test(): + assert app.query_one("#results-table", DataTable) + assert app.query_one("#detail-panel", SimulationDetail) + assert app.query_one("#filter-type", Select) + assert app.query_one("#filter-status", Select) + assert len(app.query(Input)) == 3 # hash, tags, smiles + + @pytest.mark.asyncio + async def test_app_clear_filters(self, tmp_path): + """Test clear action resets all filters.""" + from textual.widgets import Input, Select + + from mdfactory.tui.app import SimulationBrowser + + store = SimulationStore(tmp_path, min_status="build") + app = SimulationBrowser(store=store) + + async with app.run_test() as pilot: + # Set some filters + app.query_one("#filter-hash", Input).value = "ABC" + await pilot.pause() + + # Clear + app.action_clear_filters() + await pilot.pause() + + for inp in app.query(Input): + assert inp.value == "" + assert app.query_one("#filter-type", Select).value is None + assert app.query_one("#filter-status", Select).value is None diff --git a/mdfactory/tests/test_utilities.py b/mdfactory/tests/test_utilities.py index 41e3fb0..5665f0f 100644 --- a/mdfactory/tests/test_utilities.py +++ b/mdfactory/tests/test_utilities.py @@ -2,14 +2,17 @@ # ABOUTME: and file locking behavior under concurrent access. """Tests for general utility functions including YAML file loading.""" +import subprocess import time from pathlib import Path +from unittest.mock import patch import pytest import yaml from mdfactory.utils.utilities import ( load_yaml_file, + run_command, ) from .utils import ProcessWithException @@ -131,3 +134,76 @@ def lock_stuff(i): if proc.exception: raise Exception(f"{proc.exception[1]}") from proc.exception[0] assert not lockfile.is_file() + + +# --------------------------------------------------------------------------- +# Tests: run_command +# --------------------------------------------------------------------------- + + +class TestRunCommand: + """Test run_command with various failure modes and options.""" + + def test_graceful_returns_none_on_timeout(self): + """Test that graceful=True returns None on timeout.""" + with patch( + "mdfactory.utils.utilities.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd=["test_cmd"], timeout=30), + ): + result = run_command(["test_cmd"], timeout=30, graceful=True) + assert result is None + + def test_graceful_returns_none_on_file_not_found(self): + """Test that graceful=True returns None when binary not found.""" + with patch( + "mdfactory.utils.utilities.subprocess.run", + side_effect=FileNotFoundError("No such file"), + ): + result = run_command(["nonexistent_binary"], graceful=True) + assert result is None + + def test_graceful_returns_none_on_nonzero_exit(self): + """Test that graceful=True returns None on non-zero exit.""" + with patch( + "mdfactory.utils.utilities.subprocess.run", + return_value=subprocess.CompletedProcess( + args=["test"], returncode=1, stdout="", stderr="error" + ), + ): + result = run_command(["test", "--flag"], graceful=True) + assert result is None + + def test_graceful_returns_stdout_on_success(self): + """Test that graceful=True returns stdout on success.""" + with patch( + "mdfactory.utils.utilities.subprocess.run", + return_value=subprocess.CompletedProcess( + args=["echo"], returncode=0, stdout="hello\n", stderr="" + ), + ): + result = run_command(["echo", "hello"], graceful=True) + assert result == "hello" + + def test_check_raises_on_nonzero_exit(self): + """Test that check=True raises on non-zero exit.""" + with patch( + "mdfactory.utils.utilities.subprocess.run", + side_effect=subprocess.CalledProcessError( + returncode=1, cmd=["false"], output="", stderr="error" + ), + ): + with pytest.raises(subprocess.CalledProcessError): + run_command(["false"], check=True) + + def test_returns_completed_process_when_no_capture(self): + """Test that result is CompletedProcess when not capturing output.""" + with patch( + "mdfactory.utils.utilities.subprocess.run", + return_value=subprocess.CompletedProcess( + args=["echo"], returncode=0, stdout=None, stderr=None + ), + ) as mock_run: + result = run_command(["echo", "test"], capture_output=False) + assert isinstance(result, subprocess.CompletedProcess) + assert result.returncode == 0 + mock_run.assert_called_once() diff --git a/mdfactory/tui/__init__.py b/mdfactory/tui/__init__.py new file mode 100644 index 0000000..521226e --- /dev/null +++ b/mdfactory/tui/__init__.py @@ -0,0 +1,13 @@ +# ABOUTME: TUI package for interactive simulation browsing +# ABOUTME: Provides a Textual-based interface for filtering and exploring simulations +"""TUI package for interactive simulation browsing.""" + + +def _check_textual_available(): + """Check if textual is installed, raise helpful error if not.""" + try: + import textual # noqa: F401 + except ImportError: + raise ImportError( + "The TUI requires the 'textual' package. Install it with: pip install mdfactory[tui]" + ) diff --git a/mdfactory/tui/app.py b/mdfactory/tui/app.py new file mode 100644 index 0000000..cab90e7 --- /dev/null +++ b/mdfactory/tui/app.py @@ -0,0 +1,303 @@ +# ABOUTME: Main Textual application for interactive simulation browsing +# ABOUTME: Provides DataTable with live filtering by type, status, tags, hash, and SMILES +"""Main Textual application for interactive simulation browsing.""" + +from __future__ import annotations + +from textual import on, work +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.widgets import DataTable, Footer, Header, Input, Label, Select, Static + +from mdfactory.analysis.constants import STATUS_ORDER +from mdfactory.analysis.store import SimulationStore +from mdfactory.models.input import type_mapping + +# Options for Select widgets (include "all" sentinel) +TYPE_OPTIONS: list[tuple[str, str | None]] = [("All", None)] + [(t, t) for t in type_mapping.keys()] +STATUS_OPTIONS: list[tuple[str, str | None]] = [("All", None)] + [(s, s) for s in STATUS_ORDER] + + +class SimulationDetail(Static): + """Widget showing details of the selected simulation.""" + + def update_detail(self, row_data: dict | None, store: SimulationStore | None = None) -> None: + """Update the detail view with simulation data. + + Parameters + ---------- + row_data : dict | None + Dictionary with simulation details, or None to clear. + store : SimulationStore | None + Store to fetch full BuildInput metadata from. + + """ + if row_data is None: + self.update("") + return + + lines = [] + lines.append(f"[bold cyan]Hash:[/] {row_data.get('hash', 'N/A')}") + lines.append(f"[bold green]Type:[/] {row_data.get('simulation_type', 'N/A')}") + lines.append(f"[bold magenta]Status:[/] {row_data.get('status', 'N/A')}") + lines.append(f"[bold]Path:[/] {row_data.get('path', 'N/A')}") + + tags = row_data.get("tags") + if tags: + tags_str = ", ".join(f"{k}={v}" for k, v in tags.items()) + lines.append(f"[bold yellow]Tags:[/] {tags_str}") + else: + lines.append("[bold yellow]Tags:[/] (none)") + + # Fetch full metadata from store + sim_hash = row_data.get("hash") + if store and sim_hash: + try: + sim = store.get_simulation(sim_hash) + bi = sim.build_input + meta = bi.metadata + + lines.append("") + lines.append("[bold underline]Composition[/]") + lines.append(f" Total count: {meta.get('total_count', 'N/A')}") + lines.append(f" Parametrization: {meta.get('parametrization', 'N/A')}") + lines.append(f" Engine: {meta.get('engine', 'N/A')}") + + species = meta.get("species_composition", []) + if species: + lines.append("") + lines.append(" [bold]Species:[/]") + for sp in species: + resname = sp.get("resname", "?") + count = sp.get("count", "?") + fraction = sp.get("fraction", 0) + smiles = getattr( + next( + (s for s in bi.system.species if s.resname == resname), + None, + ), + "smiles", + None, + ) + smiles_str = f" {smiles}" if smiles else "" + lines.append(f" {resname}: {count} ({fraction:.1%}){smiles_str}") + + system_specific = meta.get("system_specific", {}) + if system_specific: + lines.append("") + lines.append(" [bold]Parameters:[/]") + for key, val in system_specific.items(): + if isinstance(val, dict): + lines.append(f" {key}:") + for k, v in val.items(): + lines.append(f" {k}: {v}") + else: + lines.append(f" {key}: {val}") + + except (ValueError, KeyError): + pass + + self.update("\n".join(lines)) + + +class SimulationBrowser(App): + """Interactive TUI for browsing and filtering simulations.""" + + TITLE = "MDFactory Simulation Browser" + + BINDINGS = [ + Binding("ctrl+q", "quit", "Quit"), + Binding("ctrl+r", "refresh", "Refresh"), + Binding("escape", "clear_filters", "Clear filters"), + ] + + CSS = """ + #filters { + height: 6; + dock: top; + padding: 0 1; + } + + #filter-row-1, #filter-row-2 { + height: 3; + width: 1fr; + } + + #filter-row-1 Select { + width: 1fr; + margin: 0 1; + } + + #filter-row-2 Input { + width: 1fr; + margin: 0 1; + } + + Label { + width: auto; + padding: 0 1; + content-align: center middle; + } + + #main-content { + height: 1fr; + } + + #table-container { + width: 3fr; + height: 1fr; + } + + #detail-panel { + width: 1fr; + min-width: 40; + height: 1fr; + border-left: solid $accent; + padding: 1; + } + + DataTable { + height: 1fr; + } + """ + + def __init__( + self, + store: SimulationStore, + **kwargs, + ) -> None: + """Initialize the browser with a SimulationStore. + + Parameters + ---------- + store : SimulationStore + Pre-configured store instance for simulation discovery. + + """ + super().__init__(**kwargs) + self.store = store + self._results_cache: list[dict] = [] + + def compose(self) -> ComposeResult: + """Compose the application layout.""" + yield Header() + with Vertical(id="filters"): + with Horizontal(id="filter-row-1"): + yield Label("Type:") + yield Select(TYPE_OPTIONS, value=None, id="filter-type", allow_blank=False) + yield Label("Status:") + yield Select(STATUS_OPTIONS, value=None, id="filter-status", allow_blank=False) + with Horizontal(id="filter-row-2"): + yield Label("Hash:") + yield Input(placeholder="Hash prefix", id="filter-hash") + yield Label("Tags:") + yield Input(placeholder="k=v, k=v", id="filter-tags") + yield Label("SMILES:") + yield Input(placeholder="Substructure", id="filter-smiles") + with Horizontal(id="main-content"): + with Vertical(id="table-container"): + yield DataTable(id="results-table") + yield SimulationDetail(id="detail-panel") + yield Footer() + + def on_mount(self) -> None: + """Set up the table and run initial search.""" + table = self.query_one("#results-table", DataTable) + table.add_columns("Hash", "Type", "Status", "Tags", "Path") + table.cursor_type = "row" + self._run_search() + + @on(Select.Changed) + def on_select_changed(self, event: Select.Changed) -> None: + """Re-run search when a select widget changes.""" + self._run_search() + + @on(Input.Changed) + def on_filter_changed(self, event: Input.Changed) -> None: + """Re-run search when any filter input changes.""" + self._run_search() + + @on(DataTable.RowHighlighted) + def on_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + """Update detail panel when a row is highlighted.""" + detail = self.query_one("#detail-panel", SimulationDetail) + if event.cursor_row is not None and event.cursor_row < len(self._results_cache): + detail.update_detail(self._results_cache[event.cursor_row], store=self.store) + else: + detail.update_detail(None) + + def action_refresh(self) -> None: + """Refresh discovery and re-run search.""" + self.store.discover(refresh=True) + self._run_search() + + def action_clear_filters(self) -> None: + """Clear all filter inputs and reset selects.""" + for input_widget in self.query(Input): + input_widget.value = "" + self.query_one("#filter-type", Select).value = None + self.query_one("#filter-status", Select).value = None + + @work(thread=True, exclusive=True) + def _run_search(self) -> None: + """Run search with current filter values and update table.""" + # Gather filter values from selects + type_val = self.query_one("#filter-type", Select).value + status_val = self.query_one("#filter-status", Select).value + + # Gather filter values from inputs + hash_val = self.query_one("#filter-hash", Input).value.strip() or None + tags_str = self.query_one("#filter-tags", Input).value.strip() + smiles_val = self.query_one("#filter-smiles", Input).value.strip() or None + + # Parse tags + tags_filter = None + if tags_str: + tags_filter = {} + for raw_part in tags_str.split(","): + token = raw_part.strip() + if "=" in token: + k, v = token.split("=", 1) + tags_filter[k.strip()] = v.strip() + + # Run search (Select value is None for "All") + try: + results = self.store.search( + simulation_type=type_val, + status=status_val, + hash_prefix=hash_val, + tags=tags_filter, + smiles=smiles_val, + ) + except (ValueError, ImportError) as e: + self.notify(str(e), severity="error") + return + + # Convert to list of dicts for caching + self._results_cache = results.to_dict("records") + + # Update table on main thread + self.call_from_thread(self._update_table) + + def _update_table(self) -> None: + """Update the DataTable with cached results.""" + table = self.query_one("#results-table", DataTable) + table.clear() + + for row_data in self._results_cache: + tags = row_data.get("tags") + tags_str = "" + if tags: + tags_str = ", ".join(f"{k}={v}" for k, v in tags.items()) + + table.add_row( + str(row_data["hash"])[:12], + str(row_data.get("simulation_type", "")), + str(row_data.get("status", "")), + tags_str, + str(row_data.get("path", "")), + ) + + # Update title with count + self.sub_title = f"{len(self._results_cache)} simulation(s)" diff --git a/mdfactory/utils/chemistry_utilities.py b/mdfactory/utils/chemistry_utilities.py index dc63059..6ebeb73 100644 --- a/mdfactory/utils/chemistry_utilities.py +++ b/mdfactory/utils/chemistry_utilities.py @@ -762,3 +762,40 @@ def create_lipid_assignment( print(f"Lipid assignment visualization saved to {output_file}") return segments + + +def smiles_substructure_match(query_smiles: str, target_smiles: str) -> bool: + """Check if target molecule contains query as a substructure. + + Parameters + ---------- + query_smiles : str + SMILES of the query substructure. + target_smiles : str + SMILES of the target molecule. + + Returns + ------- + bool + True if target contains query substructure. + + Raises + ------ + ValueError + If either SMILES string is invalid. + + """ + from rdkit import RDLogger + + # Suppress RDKit stderr spam for invalid SMILES (expected input case) + RDLogger.DisableLog("rdApp.*") + try: + query_mol = Chem.MolFromSmiles(query_smiles) + if query_mol is None: + raise ValueError(f"Invalid query SMILES: {query_smiles}") + target_mol = Chem.MolFromSmiles(target_smiles) + if target_mol is None: + raise ValueError(f"Invalid target SMILES: {target_smiles}") + return target_mol.HasSubstructMatch(query_mol) + finally: + RDLogger.EnableLog("rdApp.*") diff --git a/mdfactory/utils/utilities.py b/mdfactory/utils/utilities.py index 5303cb3..17da789 100644 --- a/mdfactory/utils/utilities.py +++ b/mdfactory/utils/utilities.py @@ -1,10 +1,12 @@ # ABOUTME: General-purpose utility functions for mdfactory # ABOUTME: Provides working directory management, YAML loading, and file locking +# ABOUTME: Includes subprocess execution wrapper with graceful failure modes """General-purpose utility functions for mdfactory.""" import contextlib import os import shutil +import subprocess import tempfile import time from pathlib import Path @@ -13,6 +15,105 @@ import yaml +def run_command( + cmd: list[str], + *, + timeout: int | None = None, + capture_output: bool = True, + check: bool = False, + text: bool = True, + graceful: bool = False, + **kwargs, +) -> subprocess.CompletedProcess | str | None: + """Run a shell command with flexible error handling. + + Provides a unified interface for subprocess execution with common + patterns: graceful failure for optional commands, strict checking + for critical operations, and timeout support. + + Parameters + ---------- + cmd : list of str + Command and arguments to execute. + timeout : int or None + Timeout in seconds. None for no timeout (default). + capture_output : bool + Capture stdout/stderr. Default True. + check : bool + Raise CalledProcessError on non-zero exit. Default False. + Ignored when graceful=True. + text : bool + Return output as text (str) rather than bytes. Default True. + graceful : bool + Return None on any failure (timeout, non-zero exit, OSError) + instead of raising. Default False. When True, overrides check=True. + **kwargs + Additional arguments passed to subprocess.run (e.g., cwd, env, + stdout, stderr, stdin). + + Returns + ------- + subprocess.CompletedProcess, str, or None + - If graceful=True: Returns stdout string on success, None on any failure. + - If capture_output=True and not graceful: Returns stripped stdout string. + - Otherwise: Returns CompletedProcess object. + + Raises + ------ + subprocess.CalledProcessError + If check=True and command exits with non-zero status. + subprocess.TimeoutExpired + If timeout is exceeded and graceful=False. + FileNotFoundError + If command binary not found and graceful=False. + OSError + On other OS-level failures and graceful=False. + + Examples + -------- + >>> # Graceful failure for optional commands + >>> output = run_command(["sinfo", "--version"], timeout=30, graceful=True) + >>> if output is None: + ... print("SLURM not available") + + >>> # Strict checking for critical operations + >>> run_command(["vmd", "-e", "script.tcl"], check=True) + + >>> # Manual error handling + >>> result = run_command(["fdt", "config"], capture_output=False) + >>> if result.returncode != 0: + ... print("Validation failed") + """ + if graceful: + try: + result = subprocess.run( + cmd, + capture_output=capture_output, + text=text, + timeout=timeout, + check=False, # Handle manually in graceful mode + **kwargs, + ) + if result.returncode != 0: + return None + return result.stdout.strip() if capture_output and text else result.stdout + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + return None + else: + result = subprocess.run( + cmd, + capture_output=capture_output, + text=text, + timeout=timeout, + check=check, + **kwargs, + ) + # If caller wants just stdout (most common case) + if capture_output and text and not check: + return result.stdout.strip() + return result + + @contextlib.contextmanager def working_directory(path, create=False, cleanup=False, exists_ok=True): """Change working directory and return to previous on exit. diff --git a/pixi.lock b/pixi.lock index c426628..ed3d0aa 100644 --- a/pixi.lock +++ b/pixi.lock @@ -5,8 +5,6 @@ environments: - url: https://conda.anaconda.org/conda-forge/ indexes: - https://pypi.org/simple - options: - pypi-prerelease-mode: if-necessary-or-explicit packages: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-7_kmp_llvm.conda @@ -322,6 +320,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.3-hceb46e0_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - pypi: https://files.pythonhosted.org/packages/87/01/6ec7210775ea5e4989a10d89eda6c5ea7ff06caa614231ad533d74fecac8/cyclopts-4.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5e/ed/139ce29a650d9d4d35ce78b3fc4741da3d2a6d0f4db7b88d5f4ffcff8c77/griddataformats-1.1.0-py3-none-any.whl @@ -330,10 +329,15 @@ environments: - pypi: https://files.pythonhosted.org/packages/3c/f1/efea3da858043ed9c078f507ab744b6d00933c7bc8a75a24821937600178/mmtf_python-1.1.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/cb/3f4ee8233f30c7926f1ed4885ff32b79ec7ce3210370f43e1cb2b385bed6/mrcfile-1.5.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/65/1e/0f58a02b4310e495159421946abd298ae53897ea8f5ebb6b82fb7fd0f71d/parsl-2026.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl - pypi: ./ osx-64: - conda: https://conda.anaconda.org/conda-forge/osx-64/_openmp_mutex-4.5-7_kmp_llvm.conda @@ -639,6 +643,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/zlib-ng-2.3.3-h8bce59a_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - pypi: https://files.pythonhosted.org/packages/87/01/6ec7210775ea5e4989a10d89eda6c5ea7ff06caa614231ad533d74fecac8/cyclopts-4.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5e/ed/139ce29a650d9d4d35ce78b3fc4741da3d2a6d0f4db7b88d5f4ffcff8c77/griddataformats-1.1.0-py3-none-any.whl @@ -647,10 +652,15 @@ environments: - pypi: https://files.pythonhosted.org/packages/3c/f1/efea3da858043ed9c078f507ab744b6d00933c7bc8a75a24821937600178/mmtf_python-1.1.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/cb/3f4ee8233f30c7926f1ed4885ff32b79ec7ce3210370f43e1cb2b385bed6/mrcfile-1.5.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/65/1e/0f58a02b4310e495159421946abd298ae53897ea8f5ebb6b82fb7fd0f71d/parsl-2026.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl - pypi: ./ osx-arm64: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-7_kmp_llvm.conda @@ -956,6 +966,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-ng-2.3.3-hed4e4f5_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: https://files.pythonhosted.org/packages/87/01/6ec7210775ea5e4989a10d89eda6c5ea7ff06caa614231ad533d74fecac8/cyclopts-4.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5e/ed/139ce29a650d9d4d35ce78b3fc4741da3d2a6d0f4db7b88d5f4ffcff8c77/griddataformats-1.1.0-py3-none-any.whl @@ -964,18 +975,21 @@ environments: - pypi: https://files.pythonhosted.org/packages/3c/f1/efea3da858043ed9c078f507ab744b6d00933c7bc8a75a24821937600178/mmtf_python-1.1.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/cb/3f4ee8233f30c7926f1ed4885ff32b79ec7ce3210370f43e1cb2b385bed6/mrcfile-1.5.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/65/1e/0f58a02b4310e495159421946abd298ae53897ea8f5ebb6b82fb7fd0f71d/parsl-2026.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl - pypi: ./ dev: channels: - url: https://conda.anaconda.org/conda-forge/ indexes: - https://pypi.org/simple - options: - pypi-prerelease-mode: if-necessary-or-explicit packages: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-7_kmp_llvm.conda @@ -1291,57 +1305,67 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.3-hceb46e0_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - pypi: https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/46/ed/ad1755f82cd5a0baafe342e7154696a93e57f04f86515402f14e5beceb36/bump_my_version-1.2.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/36/01/b168791bfbfb0322ef6d38d236f6f17a02e41fb7753e23e4cdb0f19ac969/bump_my_version-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/87/01/6ec7210775ea5e4989a10d89eda6c5ea7ff06caa614231ad533d74fecac8/cyclopts-4.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/25/18/3497c4fa83a76dcb154923fd2075522e8dd6995ecee4093c00ae18160046/distlib-0.4.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5e/ed/139ce29a650d9d4d35ce78b3fc4741da3d2a6d0f4db7b88d5f4ffcff8c77/griddataformats-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/8a/11ae7e271870f0ad8fa0012e4265982bebe0fdc21766b161fb8b8fc3aefc/hatch-1.16.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d3/8a/44032265776062a89171285ede55a0bdaadc8ac00f27f0512a71a9e3e1c8/hatchling-1.29.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/bd/0a7f877ec78e6910868e39a68c2a1964fa0737a4008ee1f3fd7e2fd8915f/hatch-1.17.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/56/49/2797ec0ef88008a653a8867bb8d1e5c223cd2df8e40390dd5c6a0279cbc5/hatchling-1.30.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f4/49/c152890d49102b280ecf86ba5f80a8c111c3a155dafa3bd24aeb64fde9e1/jaraco_context-6.1.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4c/16/ad7fc0f8948075b9ab7f6957519492e861b2f16469f1ab0a916f7c3c4243/mdanalysis-2.10.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3c/f1/efea3da858043ed9c078f507ab744b6d00933c7bc8a75a24821937600178/mmtf_python-1.1.3-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/cb/3f4ee8233f30c7926f1ed4885ff32b79ec7ce3210370f43e1cb2b385bed6/mrcfile-1.5.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/65/1e/0f58a02b4310e495159421946abd298ae53897ea8f5ebb6b82fb7fd0f71d/parsl-2026.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/91/4d/2ca3ca9906ce6e05070f431c54d54ccbaf57a980cfa58032d35b0b0ac1f8/pyinstrument-5.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6d/97/a87901aef6b7e7e4a34c6dd6cc17dca8594a592ef9d9dd765fca2b7facf7/rich_click-1.9.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bb/4a/2e5583e544bc437d5e8e54b47db87430df9031b29b48d17f26d129fa60c0/trove_classifiers-2026.1.14.14-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/99/3ec6335ded5b88c2f7ed25c56ffd952546f7ed007ffb1e1539dc3b57015a/userpath-1.9.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/79/34/b104c413079874493eed7bf11838b47b697cf1f0ed7e9de374ea37b4e4e0/uv-0.10.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/c4/8112b3c95db60a39c98db0641dd49bd4228f2fedb9d0ef5e4f3f48b52a0d/uv-0.11.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl - pypi: ./ osx-64: @@ -1648,54 +1672,63 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/zlib-ng-2.3.3-h8bce59a_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - pypi: https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/46/ed/ad1755f82cd5a0baafe342e7154696a93e57f04f86515402f14e5beceb36/bump_my_version-1.2.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/36/01/b168791bfbfb0322ef6d38d236f6f17a02e41fb7753e23e4cdb0f19ac969/bump_my_version-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/87/01/6ec7210775ea5e4989a10d89eda6c5ea7ff06caa614231ad533d74fecac8/cyclopts-4.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/25/18/3497c4fa83a76dcb154923fd2075522e8dd6995ecee4093c00ae18160046/distlib-0.4.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5e/ed/139ce29a650d9d4d35ce78b3fc4741da3d2a6d0f4db7b88d5f4ffcff8c77/griddataformats-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/8a/11ae7e271870f0ad8fa0012e4265982bebe0fdc21766b161fb8b8fc3aefc/hatch-1.16.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d3/8a/44032265776062a89171285ede55a0bdaadc8ac00f27f0512a71a9e3e1c8/hatchling-1.29.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/bd/0a7f877ec78e6910868e39a68c2a1964fa0737a4008ee1f3fd7e2fd8915f/hatch-1.17.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/56/49/2797ec0ef88008a653a8867bb8d1e5c223cd2df8e40390dd5c6a0279cbc5/hatchling-1.30.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f4/49/c152890d49102b280ecf86ba5f80a8c111c3a155dafa3bd24aeb64fde9e1/jaraco_context-6.1.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/57/8c/8d037a8010e92959631fa05811df43e4710b8e828bd18ea3da73189d5ce8/mdanalysis-2.10.0-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3c/f1/efea3da858043ed9c078f507ab744b6d00933c7bc8a75a24821937600178/mmtf_python-1.1.3-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/cb/3f4ee8233f30c7926f1ed4885ff32b79ec7ce3210370f43e1cb2b385bed6/mrcfile-1.5.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/65/1e/0f58a02b4310e495159421946abd298ae53897ea8f5ebb6b82fb7fd0f71d/parsl-2026.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/26/d9/8fa5571ddd21b2b7189bd8b0bb4e90be1659a54dda5af51c7f6bf2b5666f/pyinstrument-5.1.2-cp312-cp312-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6d/97/a87901aef6b7e7e4a34c6dd6cc17dca8594a592ef9d9dd765fca2b7facf7/rich_click-1.9.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bb/4a/2e5583e544bc437d5e8e54b47db87430df9031b29b48d17f26d129fa60c0/trove_classifiers-2026.1.14.14-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/99/3ec6335ded5b88c2f7ed25c56ffd952546f7ed007ffb1e1539dc3b57015a/userpath-1.9.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6f/34/2e5cd576d312eb1131b615f49ee95ff6efb740965324843617adae729cf2/uv-0.10.9-py3-none-macosx_10_12_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/38/ad/57a31ea9ffc53a2fb5cd9a60b5edb9e4df7c526ba80be4517c6d73cf4fa7/uv-0.11.18-py3-none-macosx_10_12_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl - pypi: ./ osx-arm64: @@ -2002,54 +2035,63 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-ng-2.3.3-hed4e4f5_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/46/ed/ad1755f82cd5a0baafe342e7154696a93e57f04f86515402f14e5beceb36/bump_my_version-1.2.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/36/01/b168791bfbfb0322ef6d38d236f6f17a02e41fb7753e23e4cdb0f19ac969/bump_my_version-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/87/01/6ec7210775ea5e4989a10d89eda6c5ea7ff06caa614231ad533d74fecac8/cyclopts-4.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/25/18/3497c4fa83a76dcb154923fd2075522e8dd6995ecee4093c00ae18160046/distlib-0.4.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5e/ed/139ce29a650d9d4d35ce78b3fc4741da3d2a6d0f4db7b88d5f4ffcff8c77/griddataformats-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e4/8a/11ae7e271870f0ad8fa0012e4265982bebe0fdc21766b161fb8b8fc3aefc/hatch-1.16.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d3/8a/44032265776062a89171285ede55a0bdaadc8ac00f27f0512a71a9e3e1c8/hatchling-1.29.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/bd/0a7f877ec78e6910868e39a68c2a1964fa0737a4008ee1f3fd7e2fd8915f/hatch-1.17.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/56/49/2797ec0ef88008a653a8867bb8d1e5c223cd2df8e40390dd5c6a0279cbc5/hatchling-1.30.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f4/49/c152890d49102b280ecf86ba5f80a8c111c3a155dafa3bd24aeb64fde9e1/jaraco_context-6.1.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/08/e8/baae856d6764901cd13fe928cd4086ed755ae22f66928301a9327a4a2a3e/mdanalysis-2.10.0-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/3c/f1/efea3da858043ed9c078f507ab744b6d00933c7bc8a75a24821937600178/mmtf_python-1.1.3-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/cb/3f4ee8233f30c7926f1ed4885ff32b79ec7ce3210370f43e1cb2b385bed6/mrcfile-1.5.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/65/1e/0f58a02b4310e495159421946abd298ae53897ea8f5ebb6b82fb7fd0f71d/parsl-2026.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/50/0512adb83cadfeaa1a215dc9784defff5043c5aa052d15015e3d8013af75/pyinstrument-5.1.2-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6d/97/a87901aef6b7e7e4a34c6dd6cc17dca8594a592ef9d9dd765fca2b7facf7/rich_click-1.9.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bb/4a/2e5583e544bc437d5e8e54b47db87430df9031b29b48d17f26d129fa60c0/trove_classifiers-2026.1.14.14-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/99/3ec6335ded5b88c2f7ed25c56ffd952546f7ed007ffb1e1539dc3b57015a/userpath-1.9.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/89/35/684f641de4de2b20db7d2163c735b2bb211e3b3c84c241706d6448e5e868/uv-0.10.9-py3-none-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/af/3b/130515418bdd4be1aec5941ca2fa53dc0281750434e835ff633e6f8cd944/uv-0.11.18-py3-none-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl - pypi: ./ packages: @@ -2726,10 +2768,10 @@ packages: - pkg:pypi/bson?source=hash-mapping size: 16836 timestamp: 1736456262819 -- pypi: https://files.pythonhosted.org/packages/46/ed/ad1755f82cd5a0baafe342e7154696a93e57f04f86515402f14e5beceb36/bump_my_version-1.2.7-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/36/01/b168791bfbfb0322ef6d38d236f6f17a02e41fb7753e23e4cdb0f19ac969/bump_my_version-1.3.0-py3-none-any.whl name: bump-my-version - version: 1.2.7 - sha256: 16f89360f979c0a8eb3249ebe3e13ae4f0cb5481d7bb58e12a9f66996922acfd + version: 1.3.0 + sha256: 3cdaa54588d2443a29303b77e7539417187952c3d22f87bfdd32c0fe6af2f570 requires_dist: - click<8.4 - httpx>=0.28.1 @@ -2740,7 +2782,7 @@ packages: - rich-click - tomlkit - wcmatch>=8.5.1 - requires_python: '>=3.8' + requires_python: '>=3.9' - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda sha256: 0b75d45f0bba3e95dc693336fa51f40ea28c980131fec438afb7ce6118ed05f6 md5: d2ffd7602c02f2b316fd921d39876885 @@ -3109,24 +3151,24 @@ packages: - pkg:pypi/contourpy?source=compressed-mapping size: 286084 timestamp: 1769156157865 -- pypi: https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl +- pypi: https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl name: coverage - version: 7.13.4 - sha256: 40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3 + version: 7.14.1 + sha256: 6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6 requires_dist: - tomli ; python_full_version <= '3.11' and extra == 'toml' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl +- pypi: https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl name: coverage - version: 7.13.4 - sha256: 2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3 + version: 7.14.1 + sha256: a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c requires_dist: - tomli ; python_full_version <= '3.11' and extra == 'toml' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl +- pypi: https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl name: coverage - version: 7.13.4 - sha256: 02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459 + version: 7.14.1 + sha256: fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c requires_dist: - tomli ; python_full_version <= '3.11' and extra == 'toml' requires_python: '>=3.10' @@ -3141,36 +3183,15 @@ packages: purls: [] size: 46463 timestamp: 1772728929620 -- pypi: https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl +- pypi: https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl name: cryptography - version: 46.0.5 - sha256: 3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed + version: 48.0.0 + sha256: a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f requires_dist: - - cffi>=1.14 ; python_full_version == '3.8.*' and platform_python_implementation != 'PyPy' - - cffi>=2.0.0 ; python_full_version >= '3.9' and platform_python_implementation != 'PyPy' + - cffi>=2.0.0 ; platform_python_implementation != 'PyPy' - typing-extensions>=4.13.2 ; python_full_version < '3.11' - bcrypt>=3.1.5 ; extra == 'ssh' - - nox[uv]>=2024.4.15 ; extra == 'nox' - - cryptography-vectors==46.0.5 ; extra == 'test' - - pytest>=7.4.0 ; extra == 'test' - - pytest-benchmark>=4.0 ; extra == 'test' - - pytest-cov>=2.10.1 ; extra == 'test' - - pytest-xdist>=3.5.0 ; extra == 'test' - - pretend>=0.7 ; extra == 'test' - - certifi>=2024 ; extra == 'test' - - pytest-randomly ; extra == 'test-randomorder' - - sphinx>=5.3.0 ; extra == 'docs' - - sphinx-rtd-theme>=3.0.0 ; extra == 'docs' - - sphinx-inline-tabs ; extra == 'docs' - - pyenchant>=3 ; extra == 'docstest' - - readme-renderer>=30.0 ; extra == 'docstest' - - sphinxcontrib-spelling>=7.3.1 ; extra == 'docstest' - - build>=1.0.0 ; extra == 'sdist' - - ruff>=0.11.11 ; extra == 'pep8test' - - mypy>=1.14 ; extra == 'pep8test' - - check-sdist ; extra == 'pep8test' - - click>=8.0.1 ; extra == 'pep8test' - requires_python: '>=3.8,!=3.9.0,!=3.9.1' + requires_python: '>=3.9,!=3.9.0,!=3.9.1' - conda: https://conda.anaconda.org/conda-forge/linux-64/cuda-nvrtc-13.2.51-hecca717_0.conda sha256: 9de235d328b7124f715805715e9918eb7f8aa5b9c56a2afa62b84f84f98077a5 md5: 0413baaa73be1a39d5d8e442184acc78 @@ -3353,10 +3374,23 @@ packages: - pkg:pypi/defusedxml?source=hash-mapping size: 24062 timestamp: 1615232388757 -- pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl + name: dill + version: 0.4.1 + sha256: 1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d + requires_dist: + - objgraph>=1.7.2 ; extra == 'graph' + - gprof2dot>=2022.7.29 ; extra == 'profile' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/25/18/3497c4fa83a76dcb154923fd2075522e8dd6995ecee4093c00ae18160046/distlib-0.4.1-py2.py3-none-any.whl name: distlib - version: 0.4.0 - sha256: 9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 + version: 0.4.1 + sha256: 9c2c552c68cbadc619f2d0ed3a69e27c351a3f4c9baa9ffb7df9e9cdc3d19a97 +- pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl + name: distro + version: 1.9.0 + sha256: 7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2 + requires_python: '>=3.6' - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl name: docstring-parser version: 0.17.0 @@ -3848,15 +3882,16 @@ packages: - pkg:pypi/h2?source=hash-mapping size: 95967 timestamp: 1756364871835 -- pypi: https://files.pythonhosted.org/packages/e4/8a/11ae7e271870f0ad8fa0012e4265982bebe0fdc21766b161fb8b8fc3aefc/hatch-1.16.5-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/e4/bd/0a7f877ec78e6910868e39a68c2a1964fa0737a4008ee1f3fd7e2fd8915f/hatch-1.17.0-py3-none-any.whl name: hatch - version: 1.16.5 - sha256: d9b8047f2cd10d3349eb6e8f278ad728a04f91495aace305c257d5c2747188fb + version: 1.17.0 + sha256: cb742cc9113085c7dc88fde5a54e594846d8395102ae86e32179d5e72ff3c589 requires_dist: - backports-zstd>=1.0.0 ; python_full_version < '3.14' - click>=8.0.6 + - distro>=1.0.0 ; sys_platform == 'linux' - hatchling>=1.27.0 - - httpx>=0.22.0 + - httpx2>=0.22.0 - hyperlink>=21.0.0 - keyring>=23.5.0 - packaging>=24.2 @@ -3872,10 +3907,10 @@ packages: - uv>=0.5.23 - virtualenv>=21 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/d3/8a/44032265776062a89171285ede55a0bdaadc8ac00f27f0512a71a9e3e1c8/hatchling-1.29.0-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/56/49/2797ec0ef88008a653a8867bb8d1e5c223cd2df8e40390dd5c6a0279cbc5/hatchling-1.30.1-py3-none-any.whl name: hatchling - version: 1.29.0 - sha256: 50af9343281f34785fab12da82e445ed987a6efb34fd8c2fc0f6e6630dbcc1b0 + version: 1.30.1 + sha256: 161eacafb3c6f91526e92116d21426369f2c36e98c36a864f11a96345ad4ee31 requires_dist: - packaging>=24.2 - pathspec>=0.10.1 @@ -4000,6 +4035,18 @@ packages: - pkg:pypi/httpcore?source=hash-mapping size: 49483 timestamp: 1745602916758 +- pypi: https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl + name: httpcore2 + version: 2.3.0 + sha256: 477e9e334f74e5240dcac002e890580f36a57d40ff0fb14cc9655731d23b8415 + requires_dist: + - h11>=0.16 + - truststore>=0.10 + - anyio>=4.5.0,<5.0 ; extra == 'asyncio' + - h2>=3,<5 ; extra == 'http2' + - socksio==1.* ; extra == 'socks' + - trio>=0.22.0,<1.0 ; extra == 'trio' + requires_python: '>=3.10' - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda sha256: cd0f1de3697b252df95f98383e9edb1d00386bfdd03fdf607fa42fe5fcb09950 md5: d6989ead454181f4f9bc987d3dc4e285 @@ -4015,6 +4062,24 @@ packages: - pkg:pypi/httpx?source=hash-mapping size: 63082 timestamp: 1733663449209 +- pypi: https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl + name: httpx2 + version: 2.3.0 + sha256: 6f393663bdf6dbe7fe90118e3eb5b2bd024a675cae0390ac08cec9198812d8b7 + requires_dist: + - anyio + - httpcore2==2.3.0 + - idna + - truststore>=0.10 + - brotli ; platform_python_implementation == 'CPython' and extra == 'brotli' + - brotlicffi ; platform_python_implementation != 'CPython' and extra == 'brotli' + - click==8.* ; extra == 'cli' + - pygments==2.* ; extra == 'cli' + - rich>=10,<15 ; extra == 'cli' + - h2>=3,<5 ; extra == 'http2' + - socksio==1.* ; extra == 'socks' + - zstandard>=0.18.0 ; python_full_version < '3.14' and extra == 'zstd' + requires_python: '>=3.10' - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda sha256: 77af6f5fe8b62ca07d09ac60127a30d9069fdc3c68d6b256754d0ffb1f7779f8 md5: 8e6923fc12f1fe8f8c4e5c9f343256ac @@ -4066,10 +4131,10 @@ packages: purls: [] size: 11857802 timestamp: 1720853997952 -- pypi: https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl name: identify - version: 2.6.18 - sha256: 8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737 + version: 2.6.19 + sha256: 20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a requires_dist: - ukkonen ; extra == 'license' requires_python: '>=3.10' @@ -4252,10 +4317,10 @@ packages: - pytest-enabler>=2.2 ; extra == 'testing' - pytest-ruff>=0.2.1 ; extra == 'testing' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/f4/49/c152890d49102b280ecf86ba5f80a8c111c3a155dafa3bd24aeb64fde9e1/jaraco_context-6.1.1-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl name: jaraco-context - version: 6.1.1 - sha256: 0df6a0287258f3e364072c3e40d5411b20cafa30cb28c4839d24319cecf9f808 + version: 6.1.2 + sha256: bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535 requires_dist: - backports-tarfile ; python_full_version < '3.12' - pytest>=6,!=8.1.* ; extra == 'test' @@ -4267,17 +4332,16 @@ packages: - furo ; extra == 'doc' - sphinx-lint ; extra == 'doc' - jaraco-tidelift>=1.4 ; extra == 'doc' - - pytest-checkdocs>=2.4 ; extra == 'check' + - pytest-checkdocs>=2.14 ; extra == 'check' - pytest-ruff>=0.2.1 ; sys_platform != 'cygwin' and extra == 'check' - pytest-cov ; extra == 'cover' - pytest-enabler>=3.4 ; extra == 'enabler' - - pytest-mypy>=1.0.1 ; extra == 'type' - - mypy<1.19 ; platform_python_implementation == 'PyPy' and extra == 'type' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl + - pytest-mypy>=1.0.1 ; platform_python_implementation != 'PyPy' and extra == 'type' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl name: jaraco-functools - version: 4.4.0 - sha256: 9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176 + version: 4.5.0 + sha256: 79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4 requires_dist: - more-itertools - pytest>=6,!=8.1.* ; extra == 'test' @@ -4288,13 +4352,12 @@ packages: - furo ; extra == 'doc' - sphinx-lint ; extra == 'doc' - jaraco-tidelift>=1.4 ; extra == 'doc' - - pytest-checkdocs>=2.4 ; extra == 'check' + - pytest-checkdocs>=2.14 ; extra == 'check' - pytest-ruff>=0.2.1 ; sys_platform != 'cygwin' and extra == 'check' - pytest-cov ; extra == 'cover' - pytest-enabler>=3.4 ; extra == 'enabler' - - pytest-mypy>=1.0.1 ; extra == 'type' - - mypy<1.19 ; platform_python_implementation == 'PyPy' and extra == 'type' - requires_python: '>=3.9' + - pytest-mypy>=1.0.1 ; platform_python_implementation != 'PyPy' and extra == 'type' + requires_python: '>=3.10' - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.2-pyhd8ed1ab_1.conda sha256: 92c4d217e2dc68983f724aa983cca5464dcb929c566627b26a2511159667dba8 md5: a4f4c5dc9b80bc50e0d3dc4e6e8f1bd9 @@ -7160,7 +7223,7 @@ packages: - pypi: ./ name: mdfactory version: 0.1.0 - sha256: af8931e0eb980b726a9ea87c3ac824699c1e83493882853bb4fff006fed99425 + sha256: 93b4b57f5ba28fbf3f493c44623d0ca6a4fd42618369c890a1912389aee16a80 requires_dist: - cyclopts - loguru @@ -7185,8 +7248,10 @@ packages: - ruff ; extra == 'dev' - tqdm ; extra == 'dev' - foundry-dev-tools ; extra == 'foundry' + - parsl>=2024.1.1 ; extra == 'parsl' - submitit>=1.5.1 ; extra == 'submitit' requires_python: '>=3.11' + editable: true - conda: https://conda.anaconda.org/conda-forge/linux-64/mdtraj-1.11.1-np2py312h8baca0b_1.conda sha256: 66f32f08a8769482f1fae72051dccd74beaf4916cc33bffc9ad5b13db2d49066 md5: a4c60c0060137d103145b33cdd8001c2 @@ -7315,11 +7380,11 @@ packages: - msgpack>=1.0.0 - check-manifest ; extra == 'dev' - coverage ; extra == 'test' -- pypi: https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl name: more-itertools - version: 10.8.0 - sha256: 52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b - requires_python: '>=3.9' + version: 11.1.0 + sha256: 4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192 + requires_python: '>=3.10' - conda: https://conda.anaconda.org/conda-forge/linux-64/mpc-1.3.1-h24ddda3_1.conda sha256: 1bf794ddf2c8b3a3e14ae182577c624fa92dea975537accff4bc7e5fea085212 md5: aa14b9a5196a6d8dd364164b7ce56acf @@ -8484,6 +8549,78 @@ packages: - pkg:pypi/parmed?source=hash-mapping size: 19381423 timestamp: 1769768478779 +- pypi: https://files.pythonhosted.org/packages/65/1e/0f58a02b4310e495159421946abd298ae53897ea8f5ebb6b82fb7fd0f71d/parsl-2026.6.1-py3-none-any.whl + name: parsl + version: 2026.6.1 + sha256: cba922c33d2a3df214700ec6477d72254d20f6664b7366d3ce853524e27731b9 + requires_dist: + - pyzmq>=17.1.2 + - typeguard>=2.10,!=3.*,<5 + - typing-extensions>=4.6,<5 + - dill + - tblib + - requests + - sortedcontainers + - psutil>=5.5.1 + - setproctitle + - filelock>=3.13,<4 + - sqlalchemy>=2,<2.1 ; extra == 'monitoring' + - pydot>=1.4.2 ; extra == 'visualization' + - networkx>=3.2,<3.3 ; extra == 'visualization' + - flask>=1.0.2 ; extra == 'visualization' + - flask-sqlalchemy ; extra == 'visualization' + - pandas>=2.2,<3 ; extra == 'visualization' + - plotly ; extra == 'visualization' + - python-daemon ; extra == 'visualization' + - boto3 ; extra == 'aws' + - kubernetes ; extra == 'kubernetes' + - ipython<=8.6.0 ; extra == 'docs' + - nbsphinx ; extra == 'docs' + - sphinx>=7.4,<8 ; extra == 'docs' + - sphinx-rtd-theme ; extra == 'docs' + - google-auth ; extra == 'google-cloud' + - google-api-python-client ; extra == 'google-cloud' + - python-gssapi ; extra == 'gssapi' + - azure<=4 ; extra == 'azure' + - msrestazure ; extra == 'azure' + - work-queue ; extra == 'workqueue' + - pyyaml ; extra == 'flux' + - cffi ; extra == 'flux' + - jsonschema ; extra == 'flux' + - proxystore ; extra == 'proxystore' + - radical-pilot==1.90 ; extra == 'radical-pilot' + - radical-utils==1.90 ; extra == 'radical-pilot' + - globus-compute-sdk>=2.34.0 ; extra == 'globus-compute' + - globus-sdk ; extra == 'globus-transfer' + - sqlalchemy>=2,<2.1 ; extra == 'all' + - pydot>=1.4.2 ; extra == 'all' + - networkx>=3.2,<3.3 ; extra == 'all' + - flask>=1.0.2 ; extra == 'all' + - flask-sqlalchemy ; extra == 'all' + - pandas>=2.2,<3 ; extra == 'all' + - plotly ; extra == 'all' + - python-daemon ; extra == 'all' + - boto3 ; extra == 'all' + - kubernetes ; extra == 'all' + - ipython<=8.6.0 ; extra == 'all' + - nbsphinx ; extra == 'all' + - sphinx>=7.4,<8 ; extra == 'all' + - sphinx-rtd-theme ; extra == 'all' + - google-auth ; extra == 'all' + - google-api-python-client ; extra == 'all' + - python-gssapi ; extra == 'all' + - azure<=4 ; extra == 'all' + - msrestazure ; extra == 'all' + - work-queue ; extra == 'all' + - pyyaml ; extra == 'all' + - cffi ; extra == 'all' + - jsonschema ; extra == 'all' + - proxystore ; extra == 'all' + - radical-pilot==1.90 ; extra == 'all' + - radical-utils==1.90 ; extra == 'all' + - globus-compute-sdk>=2.34.0 ; extra == 'all' + - globus-sdk ; extra == 'all' + requires_python: '>=3.10.0' - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.6-pyhcf101f3_0.conda sha256: 42b2d77ccea60752f3aa929a6413a7835aaacdbbde679f2f5870a744fa836b94 md5: 97c1ce2fffa1209e7afb432810ec6e12 @@ -8496,16 +8633,14 @@ packages: - pkg:pypi/parso?source=hash-mapping size: 82287 timestamp: 1770676243987 -- pypi: https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl name: pathspec - version: 1.0.4 - sha256: fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723 + version: 1.1.1 + sha256: a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 requires_dist: - hyperscan>=0.7 ; extra == 'hyperscan' - typing-extensions>=4 ; extra == 'optional' - google-re2>=1.1 ; extra == 're2' - - pytest>=9 ; extra == 'tests' - - typing-extensions>=4.15 ; extra == 'tests' requires_python: '>=3.9' - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda sha256: 5e6f7d161356fefd981948bea5139c5aa0436767751a6930cb1ca801ebb113ff @@ -8726,10 +8861,10 @@ packages: - pytest-benchmark ; extra == 'testing' - coverage ; extra == 'testing' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl name: pre-commit - version: 4.5.1 - sha256: 3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77 + version: 4.6.0 + sha256: e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b requires_dist: - cfgv>=2.0.0 - identify>=1.0.0 @@ -9042,16 +9177,16 @@ packages: - pkg:pypi/pydantic-core?source=hash-mapping size: 1715338 timestamp: 1746625327204 -- pypi: https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl name: pydantic-settings - version: 2.13.1 - sha256: d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237 + version: 2.14.1 + sha256: 6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de requires_dist: - pydantic>=2.7.0 - python-dotenv>=0.21.0 - typing-inspection>=0.4.0 - - boto3-stubs[secretsmanager] ; extra == 'aws-secrets-manager' - boto3>=1.35.0 ; extra == 'aws-secrets-manager' + - types-boto3[secretsmanager] ; extra == 'aws-secrets-manager' - azure-identity>=1.16.0 ; extra == 'azure-key-vault' - azure-keyvault-secrets>=4.8.0 ; extra == 'azure-key-vault' - google-cloud-secret-manager>=2.23.1 ; extra == 'gcp-secret-manager' @@ -9322,10 +9457,10 @@ packages: - pkg:pypi/tables?source=hash-mapping size: 1725636 timestamp: 1772372138444 -- pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl name: pytest - version: 9.0.2 - sha256: 711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b + version: 9.0.3 + sha256: 2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 requires_dist: - colorama>=0.4 ; sys_platform == 'win32' - exceptiongroup>=1 ; python_full_version < '3.11' @@ -9342,10 +9477,10 @@ packages: - setuptools ; extra == 'dev' - xmlschema ; extra == 'dev' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl name: pytest-cov - version: 7.0.0 - sha256: 3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861 + version: 7.1.0 + sha256: a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678 requires_dist: - coverage[toml]>=7.10.6 - pluggy>=1.2 @@ -9459,10 +9594,10 @@ packages: - pkg:pypi/python-dateutil?source=hash-mapping size: 233310 timestamp: 1751104122689 -- pypi: https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl name: python-discovery - version: 1.1.3 - sha256: 90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e + version: 1.4.0 + sha256: 26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da requires_dist: - filelock>=3.15.4 - platformdirs>=4.3.6,<5 @@ -9470,6 +9605,8 @@ packages: - sphinx-autodoc-typehints>=3.6.3 ; extra == 'docs' - sphinx>=9.1 ; extra == 'docs' - sphinxcontrib-mermaid>=2 ; extra == 'docs' + - sphinxcontrib-towncrier>=0.4 ; extra == 'docs' + - towncrier>=25.8 ; extra == 'docs' - covdefaults>=2.3 ; extra == 'testing' - coverage>=7.5.4 ; extra == 'testing' - pytest-mock>=3.14 ; extra == 'testing' @@ -10044,10 +10181,10 @@ packages: - pkg:pypi/rich?source=compressed-mapping size: 208472 timestamp: 1771572730357 -- pypi: https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/6d/97/a87901aef6b7e7e4a34c6dd6cc17dca8594a592ef9d9dd765fca2b7facf7/rich_click-1.9.8-py3-none-any.whl name: rich-click - version: 1.9.7 - sha256: 2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b + version: 1.9.8 + sha256: 12873865396e6927835d4eabb1cc3996edcd65b7ac9b2391a29eca4f335a2f93 requires_dist: - click>=8 - colorama ; sys_platform == 'win32' @@ -10063,7 +10200,7 @@ packages: - pytest-cov>=5 ; extra == 'dev' - rich-codex>=1.2.11 ; extra == 'dev' - ruff>=0.12.4 ; extra == 'dev' - - typer>=0.15 ; extra == 'dev' + - typer>=0.15,<0.26 ; extra == 'dev' - types-setuptools>=75.8.0.20250110 ; extra == 'dev' - markdown-include>=0.8.1 ; extra == 'docs' - mike>=2.1.3 ; extra == 'docs' @@ -10077,7 +10214,7 @@ packages: - mkdocs-rss-plugin>=1.15 ; extra == 'docs' - mkdocstrings[python]>=0.26.1 ; extra == 'docs' - rich-codex>=1.2.11 ; extra == 'docs' - - typer>=0.15 ; extra == 'docs' + - typer>=0.15,<0.26 ; extra == 'docs' requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl name: rich-rst @@ -10160,20 +10297,20 @@ packages: - pkg:pypi/rpds-py?source=hash-mapping size: 358853 timestamp: 1764543161524 -- pypi: https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl +- pypi: https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl name: ruff - version: 0.15.5 - sha256: 6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080 + version: 0.15.15 + sha256: ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl +- pypi: https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl name: ruff - version: 0.15.5 - sha256: 89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010 + version: 0.15.15 + sha256: 77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530 requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl +- pypi: https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: ruff - version: 0.15.5 - sha256: c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a + version: 0.15.15 + sha256: 48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4 requires_python: '>=3.7' - conda: https://conda.anaconda.org/conda-forge/linux-64/scipy-1.17.1-py312h54fa4ab_0.conda sha256: e3ad577361d67f6c078a6a7a3898bf0617b937d44dc4ccd57aa3336f2b5778dd @@ -10278,6 +10415,27 @@ packages: - pkg:pypi/send2trash?source=hash-mapping size: 24108 timestamp: 1770937597662 +- pypi: https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + name: setproctitle + version: 1.3.7 + sha256: 2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629 + requires_dist: + - pytest ; extra == 'test' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl + name: setproctitle + version: 1.3.7 + sha256: cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798 + requires_dist: + - pytest ; extra == 'test' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl + name: setproctitle + version: 1.3.7 + sha256: 2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e + requires_dist: + - pytest ; extra == 'test' + requires_python: '>=3.8' - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda sha256: 82088a6e4daa33329a30bc26dc19a98c7c1d3f05c0f73ce9845d4eab4924e9e1 md5: 8e194e7b992f99a5015edbd4ebd38efd @@ -10386,6 +10544,10 @@ packages: - pkg:pypi/sniffio?source=hash-mapping size: 15698 timestamp: 1762941572482 +- pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl + name: sortedcontainers + version: 2.4.0 + sha256: a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.3-pyhd8ed1ab_0.conda sha256: 23b71ecf089967d2900126920e7f9ff18cdcef82dbff3e2f54ffa360243a17ac md5: 18de09b20462742fe093ba39185d9bac @@ -10486,6 +10648,11 @@ packages: purls: [] size: 181262 timestamp: 1762509955687 +- pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl + name: tblib + version: 3.2.2 + sha256: 26bdccf339bcce6a88b2b5432c988b266ebbe63a4e593f6b578b1d2e723d2b76 + requires_python: '>=3.9' - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyhc90fa1f_1.conda sha256: 6b6727a13d1ca6a23de5e6686500d0669081a117736a87c8abf444d60c1e40eb md5: 17b43cee5cc84969529d5d0b0309b2cb @@ -10571,10 +10738,10 @@ packages: version: 1.2.0 sha256: 188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl name: tomlkit - version: 0.14.0 - sha256: 592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680 + version: 0.15.0 + sha256: 4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738 requires_python: '>=3.9' - conda: https://conda.anaconda.org/conda-forge/noarch/torchmetrics-1.8.2-pyhd8ed1ab_0.conda sha256: d3171f8d6689db5208e36f1acab7bcaa38fe4ce0cbbd0cfa59e100813754ceab @@ -10657,10 +10824,23 @@ packages: - pkg:pypi/traitlets?source=hash-mapping size: 110051 timestamp: 1733367480074 -- pypi: https://files.pythonhosted.org/packages/bb/4a/2e5583e544bc437d5e8e54b47db87430df9031b29b48d17f26d129fa60c0/trove_classifiers-2026.1.14.14-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl name: trove-classifiers - version: 2026.1.14.14 - sha256: 1f9553927f18d0513d8e5ff80ab8980b8202ce37ecae0e3274ed2ef11880e74d + version: 2026.6.1.19 + sha256: ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3 +- pypi: https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl + name: truststore + version: 0.10.4 + sha256: adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl + name: typeguard + version: 4.5.2 + sha256: fcf9de18bd945cdb4c7b996e12b4c51ce83f92f191314a6d7cf1739586ec98cf + requires_dist: + - importlib-metadata>=3.6 ; python_full_version < '3.10' + - typing-extensions>=4.14.0 + requires_python: '>=3.9' - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda sha256: 7c2df5721c742c2a47b2c8f960e718c930031663ac1174da67c1ed5999f7938c md5: edd329d7d3a4ab45dcf905899a7a6115 @@ -10787,32 +10967,32 @@ packages: requires_dist: - click requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/6f/34/2e5cd576d312eb1131b615f49ee95ff6efb740965324843617adae729cf2/uv-0.10.9-py3-none-macosx_10_12_x86_64.whl +- pypi: https://files.pythonhosted.org/packages/38/ad/57a31ea9ffc53a2fb5cd9a60b5edb9e4df7c526ba80be4517c6d73cf4fa7/uv-0.11.18-py3-none-macosx_10_12_x86_64.whl name: uv - version: 0.10.9 - sha256: 880dd4cffe4bd184e8871ddf4c7d3c3b042e1f16d2682310644aa8d61eaea3e6 + version: 0.11.18 + sha256: 90685bda9e15600ae9a2a10c326008a69f28d8652555a2e760e1493e4b1ae7b5 requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/79/34/b104c413079874493eed7bf11838b47b697cf1f0ed7e9de374ea37b4e4e0/uv-0.10.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl +- pypi: https://files.pythonhosted.org/packages/af/3b/130515418bdd4be1aec5941ca2fa53dc0281750434e835ff633e6f8cd944/uv-0.11.18-py3-none-macosx_11_0_arm64.whl name: uv - version: 0.10.9 - sha256: 7c9d6deb30edbc22123be75479f99fb476613eaf38a8034c0e98bba24a344179 + version: 0.11.18 + sha256: 0571ec649f25e2cca9eb994637aa1ae27ab3db58f87c5a9b5f9776d5d7cd2298 requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/89/35/684f641de4de2b20db7d2163c735b2bb211e3b3c84c241706d6448e5e868/uv-0.10.9-py3-none-macosx_11_0_arm64.whl +- pypi: https://files.pythonhosted.org/packages/ca/c4/8112b3c95db60a39c98db0641dd49bd4228f2fedb9d0ef5e4f3f48b52a0d/uv-0.11.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: uv - version: 0.10.9 - sha256: a7a784254380552398a6baf4149faf5b31a4003275f685c28421cf8197178a08 + version: 0.11.18 + sha256: 9a4ee93dd0cc86046eb234ff8f3a5090c6cbb1466a825c5201aaf4ee4b45158d requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl name: virtualenv - version: 21.2.0 - sha256: 1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f + version: 21.4.2 + sha256: 854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae requires_dist: - distlib>=0.3.7,<1 - filelock>=3.24.2,<4 ; python_full_version >= '3.10' - filelock>=3.16.1,<=3.19.1 ; python_full_version < '3.10' - importlib-metadata>=6.6 ; python_full_version < '3.8' - platformdirs>=3.9.1,<5 - - python-discovery>=1 + - python-discovery>=1.4 - typing-extensions>=4.13.2 ; python_full_version < '3.11' requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl diff --git a/pyproject.toml b/pyproject.toml index 2322bad..5c6c580 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,12 @@ foundry = [ submitit = [ "submitit>=1.5.1", ] +parsl = [ + "parsl>=2024.1.1", +] +tui = [ + "textual>=0.50", +] dev = [ "bump-my-version", "hatch", @@ -97,6 +103,8 @@ openmm = "*" [tool.pixi.pypi-dependencies] mdfactory = { path = ".", editable = true } +parsl = ">=2024.1.1" +textual = ">=0.50" [tool.pixi.tasks] test = "pytest -k 'not build' mdfactory" @@ -164,6 +172,7 @@ select = [ ignore = [ "D105", # Missing docstring in magic functions "D107", # Missing docstring in __init__ + "PLC0415", # Import not at top of file (lazy imports for optional deps) "PLR0912", # Too many branches "PLR0913", # Too many arguments in function definition "PLR0915", # Too many statements @@ -209,3 +218,4 @@ addopts = "-m 'not foundry_live and not slow' -v --cov mdfactory --cov-report xm [tool.coverage.run] omit = ["mdfactory/tests/*"] +