diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index d2f84fb..64c6248 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -7,11 +7,6 @@ on: pull_request: branches: - "main" - #schedule: - # # Nightly tests run on master by default: - # # Scheduled workflows run on the latest commit on the default or base branch. - # # (from https://help.github.com/en/actions/reference/events-that-trigger-workflows#scheduled-events-schedule) - # - cron: "0 0 * * *" jobs: test: @@ -19,8 +14,8 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest] #[ubuntu-latest, macOS-latest] - python-version: ['3.10'] #[3.7, 3.8, 3.9, 3.10, 3.11, 3.12] + os: [ubuntu-latest] + python-version: ['3.10'] steps: - uses: actions/checkout@v4 @@ -32,10 +27,8 @@ jobs: df -h ulimit -a - - uses: mamba-org/setup-micromamba@v1 with: - #python-version: ${{ matrix.python-version }} create-args: >- python=${{ matrix.python-version }} init-shell: bash @@ -47,12 +40,10 @@ jobs: pip install . --no-deps conda list - - name: Run tests shell: bash -l {0} run: | - pip install . --no-deps - pytest -v --color=yes --cov=serenityff --cov-report=xml --run-slow serenityff/charge/tests/ tests/ + pytest -v --color=yes --cov=serenityff --cov-report=xml --run-slow --tier max serenityff/charge/tests/ tests/ analyze: name: Analyze diff --git a/.github/workflows/scheduled.yaml b/.github/workflows/scheduled.yaml new file mode 100644 index 0000000..f3e85e6 --- /dev/null +++ b/.github/workflows/scheduled.yaml @@ -0,0 +1,43 @@ +name: scheduled-tests + +on: + schedule: + - cron: "0 2 * * 6" + +jobs: + test: + name: Run ${{ matrix.env-name }} environment + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - env-name: "min" + file: "tree_only_env.yml" + mark: "min" + - env-name: "med" + file: "min_environment.yml" + mark: "med" + - env-name: "max" + file: "environment.yml" + mark: "max" + + steps: + - uses: actions/checkout@v4 + + - name: Setup Conda + uses: conda-incubator/setup-miniconda@v3 + with: + activate-environment: test-env + environment-file: ${{ matrix.file }} + + - name: End-to-End Install + shell: bash -l {0} + run: | + conda install -y pytest pytest-cov + pip install git+https://github.com/rinikerlab/DASH-tree.git@${{ github.sha }}" + + - name: Run Tests + shell: bash -l {0} + run: | + pytest -v --color=yes --cov=serenityff --cov-report=xml --run-slow --tier "${{ matrix.mark }}" serenityff/charge/tests/ tests/ diff --git a/serenityff/charge/tests/test_gnn_extraction.py b/serenityff/charge/tests/test_gnn_extraction.py index 24438ef..9fc4f69 100644 --- a/serenityff/charge/tests/test_gnn_extraction.py +++ b/serenityff/charge/tests/test_gnn_extraction.py @@ -104,6 +104,7 @@ def graph(cwd) -> CustomData: ) +@pytest.mark.max def test_getter_setter(explainer) -> None: with pytest.raises(TypeError): explainer.gnn_explainer = "asdf" @@ -111,11 +112,13 @@ def test_getter_setter(explainer) -> None: return +@pytest.mark.max def test_load(model, statedict) -> None: assert all(a == b for a, b in zip(model.state_dict(), statedict)) return +@pytest.mark.max def test_explain_atom(explainer, graph) -> None: explainer.gnn_explainer.explain_node( node_idx=0, @@ -150,6 +153,7 @@ def test_explain_atom(explainer, graph) -> None: return +@pytest.mark.max def test_extractor_properties(extractor, model, model_path, statedict_path, explainer) -> None: extractor._set_model(model) assert isinstance(extractor.model, ChargeCorrectedNodeWiseAttentiveFP) @@ -166,6 +170,7 @@ def test_extractor_properties(extractor, model, model_path, statedict_path, expl assert isinstance(extractor.model, NodeWiseAttentiveFP) +@pytest.mark.max def test_split_sdf(cwd, sdf_path) -> None: Extractor._split_sdf( sdf_file=sdf_path, @@ -178,6 +183,7 @@ def test_split_sdf(cwd, sdf_path) -> None: return +@pytest.mark.max def test_job_id(cwd) -> None: with open(f"{cwd}/id.txt", "w") as f: f.write("sdcep ab ein \n sdf <12345> saoeb ") @@ -187,11 +193,13 @@ def test_job_id(cwd) -> None: return +@pytest.mark.max def test_mol_from_sdf(sdf_path): mol = mols_from_sdf(sdf_file=sdf_path)[0] assert mol.GetNumBonds() == 19 +@pytest.mark.max def test_graph_from_mol(mol, num_atoms, num_bonds, formal_charge, smiles) -> None: with pytest.raises(ValueError): get_graph_from_mol(mol=mol, index=0, sdf_property_name=None) @@ -209,6 +217,7 @@ def test_graph_from_mol(mol, num_atoms, num_bonds, formal_charge, smiles) -> Non return +@pytest.mark.max @pytest.mark.parametrize("sdf_prop", [(None), ("MBIScharge")]) def test_graph_from_mol_no_y(mol, num_atoms, num_bonds, formal_charge, smiles, sdf_prop) -> None: graph = get_graph_from_mol(mol=mol, index=0, sdf_property_name=sdf_prop, no_y=True) @@ -223,6 +232,7 @@ def test_graph_from_mol_no_y(mol, num_atoms, num_bonds, formal_charge, smiles, s return +@pytest.mark.max def test_arg_parser(args, sdf_path, statedict_path) -> None: args = Extractor._parse_filenames(args) assert args.sdffile == sdf_path @@ -230,6 +240,7 @@ def test_arg_parser(args, sdf_path, statedict_path) -> None: return +@pytest.mark.max def test_script_writing(cwd) -> None: Extractor._write_worker(directory=cwd) Extractor._write_cleaner(directory=cwd) @@ -239,11 +250,13 @@ def test_script_writing(cwd) -> None: return +@pytest.mark.max def test_explainer_initialization(extractor, model) -> None: extractor._initialize_expaliner(model=model, epochs=1) return +@pytest.mark.max def test_command_to_shell_file(cwd) -> None: command_to_shell_file("echo Hello World", f"{cwd}/test.sh") os.path.isfile(f"{cwd}/test.sh") @@ -253,6 +266,7 @@ def test_command_to_shell_file(cwd) -> None: os.remove(f"{cwd}/test.sh") +@pytest.mark.max def test_csv_handling(cwd, sdf_path, extractor, model): extractor._initialize_expaliner(model=model, epochs=1) outfile = f"{cwd}/sdftest/combined.csv" @@ -268,6 +282,7 @@ def test_csv_handling(cwd, sdf_path, extractor, model): rmtree(f"{cwd}/sdftest") +@pytest.mark.max def test_run_extraction_local(extractor, statedict_path, cwd, sdf_path) -> None: extractor.run_extraction_local( sdf_file=sdf_path, diff --git a/serenityff/charge/tests/test_gnn_training.py b/serenityff/charge/tests/test_gnn_training.py index e25177f..e98292d 100644 --- a/serenityff/charge/tests/test_gnn_training.py +++ b/serenityff/charge/tests/test_gnn_training.py @@ -85,6 +85,7 @@ def trainer(model, optimizer): return trainer +@pytest.mark.max def test_init_and_forward_model(model, graph) -> None: model = model model.train() @@ -99,6 +100,7 @@ def test_init_and_forward_model(model, graph) -> None: return +@pytest.mark.max def test_initialize_trainer(trainer, model, sdf_path, pt_path, statedict_path, model_path, statedict) -> None: # test init assert trainer.device == device("cuda") if is_available() else device("cpu") @@ -147,6 +149,7 @@ def test_initialize_trainer(trainer, model, sdf_path, pt_path, statedict_path, m return +@pytest.mark.max def test_prepare_train_data(trainer, sdf_path): with pytest.warns(Warning): trainer.prepare_training_data() @@ -158,6 +161,7 @@ def test_prepare_train_data(trainer, sdf_path): return +@pytest.mark.max def test_train_model(trainer, sdf_path) -> None: trainer.gen_graphs_from_sdf(sdf_path) trainer.prepare_training_data(train_ratio=0.5) @@ -173,6 +177,7 @@ def test_train_model(trainer, sdf_path) -> None: return +@pytest.mark.max def test_prediction(trainer, graph, molecule) -> None: a = trainer.predict(graph) @@ -198,6 +203,7 @@ def test_prediction(trainer, graph, molecule) -> None: return +@pytest.mark.max def test_on_gpu(trainer) -> None: assert trainer._on_gpu == is_available() diff --git a/serenityff/charge/tests/test_gnn_utils.py b/serenityff/charge/tests/test_gnn_utils.py index 20bfe41..fc5d89b 100644 --- a/serenityff/charge/tests/test_gnn_utils.py +++ b/serenityff/charge/tests/test_gnn_utils.py @@ -126,6 +126,7 @@ def node_features() -> np.ndarray: ) +@pytest.mark.max def test_get_split_numbers() -> None: assert [1, 0] == get_split_numbers(N=1, train_ratio=0.5) assert [1, 1] == get_split_numbers(N=2, train_ratio=0.5) @@ -136,6 +137,7 @@ def test_get_split_numbers() -> None: return +@pytest.mark.max def test_random_split(data) -> None: train, test = split_data_random(data_list=data, train_ratio=0.5) assert len(train) == 6 @@ -143,6 +145,7 @@ def test_random_split(data) -> None: return +@pytest.mark.max def test_kfold_split(data) -> None: train1, test1 = split_data_Kfold(data, n_splits=2, split=0) train2, test2 = split_data_Kfold(data, n_splits=2, split=1) @@ -151,6 +154,7 @@ def test_kfold_split(data) -> None: return +@pytest.mark.max def test_initialization() -> None: featurizer = Featurizer() featurizer = MolecularFeaturizer() @@ -159,6 +163,7 @@ def test_initialization() -> None: return +@pytest.mark.max def test_one_hot_encode(atoms, allowable_set) -> None: assert one_hot_encode(atoms[0].GetSymbol(), allowable_set) == [1.0, 0.0, 0.0] assert one_hot_encode(atoms[0].GetSymbol(), allowable_set, include_unknown_set=True) == [1.0, 0.0, 0.0, 0.0] @@ -166,6 +171,7 @@ def test_one_hot_encode(atoms, allowable_set) -> None: return +@pytest.mark.max def test_hbond_constructor(mol) -> None: factory = _ChemicalFeaturesFactory.get_instance() import os @@ -180,12 +186,14 @@ def test_hbond_constructor(mol) -> None: return +@pytest.mark.max def test_H_bonding(mol, atoms) -> None: hbond_infos = construct_hydrogen_bonding_info(mol) assert get_atom_hydrogen_bonding_one_hot(atoms[11], hbond_infos) == [1.0, 1.0] return +@pytest.mark.max def test_degree(atoms) -> None: assert np.where(get_atom_total_degree_one_hot(atoms[20])) == np.array([[1]]) assert np.where(get_atom_total_degree_one_hot(atoms[11])) == np.array([[2]]) @@ -194,6 +202,7 @@ def test_degree(atoms) -> None: return +@pytest.mark.max def test_atom_feature(mol, atoms, allowable_set) -> None: hbond_infos = construct_hydrogen_bonding_info(mol) feat = _construct_atom_feature( @@ -206,6 +215,7 @@ def test_atom_feature(mol, atoms, allowable_set) -> None: return +@pytest.mark.max def test_bond_feat(bonds) -> None: np.testing.assert_array_equal(np.where(_construct_bond_feature(bonds[0])), np.array([[3, 4, 5, 6]])) np.testing.assert_array_equal(np.where(_construct_bond_feature(bonds[6])), np.array([[0, 4, 6]])) @@ -214,6 +224,7 @@ def test_bond_feat(bonds) -> None: return +@pytest.mark.max def test_feature_vector_generation(smiles, mol, allowable_set, empty_set) -> None: featurizer = MolGraphConvFeaturizer(use_edges=True) @@ -231,6 +242,7 @@ def test_feature_vector_generation(smiles, mol, allowable_set, empty_set) -> Non return +@pytest.mark.max def test_graph_data_basics(node_features, edge_index, edge_features, node_pos_features) -> None: # Trigger all __init__() failures. with pytest.raises(TypeError): @@ -255,6 +267,7 @@ def test_graph_data_basics(node_features, edge_index, edge_features, node_pos_fe return +@pytest.mark.max def test_getters( graph_data, custom_graph_data, @@ -285,6 +298,7 @@ def test_getters( return +@pytest.mark.max def test_to_pyg( custom_graph_data, node_features, @@ -301,6 +315,7 @@ def test_to_pyg( return +@pytest.mark.max def test_custom_data_attributes(custom_data) -> None: data = custom_data assert data.smiles == "abc" @@ -321,6 +336,7 @@ def test_custom_data_attributes(custom_data) -> None: return +@pytest.mark.max def test_base_featurizer(): featurizer = Featurizer() datapoints = [1] @@ -332,6 +348,7 @@ def test_base_featurizer(): assert np.array_equal(features, np.asarray([np.array([])])) +@pytest.mark.max def test_molecular_featurizer(mol, smiles): featurizer = MolecularFeaturizer() featurizer.featurize([mol]) @@ -340,6 +357,7 @@ def test_molecular_featurizer(mol, smiles): featurizer.featurize(smiles) +@pytest.mark.max def test_exceptions(): with pytest.raises(NotInitializedError): raise NotInitializedError("msg") diff --git a/serenityff/charge/tests/test_off_plugin.py b/serenityff/charge/tests/test_off_plugin.py index 4d919a0..0a78b31 100644 --- a/serenityff/charge/tests/test_off_plugin.py +++ b/serenityff/charge/tests/test_off_plugin.py @@ -88,6 +88,7 @@ def charges_amber(): chg_atol = 0.4 +@pytest.mark.med def test_handler_functions(handler): assert handler.check_handler_compatibility(handler) is None assert handler._TAGNAME == "SerenityFFCharge" @@ -95,6 +96,7 @@ def test_handler_functions(handler): assert handler._KWARGS == ["toolkit_registry"] +@pytest.mark.med def test_off_handler_plugins(force_field_with_plugins, keys): keys.append("SerenityFFCharge") for key in keys: @@ -103,6 +105,7 @@ def test_off_handler_plugins(force_field_with_plugins, keys): force_field_with_plugins.get_parameter_handler("faulty") +@pytest.mark.med @pytest.mark.skipif( condition=are_in_CI(), reason="This test is too slow for CI", @@ -122,6 +125,7 @@ def test_off_handler_custom(force_field_custom_offxml, keys): # ------------------------------------------------ +@pytest.mark.med @pytest.mark.skipif( condition=are_in_CI(), reason="This test is too slow for CI", @@ -142,6 +146,7 @@ def test_plugin_charges_get(force_field_with_plugins, molecule, charges_amber, c ) +@pytest.mark.med @pytest.mark.skipif( condition=are_in_CI(), reason="This test is too slow for CI", @@ -162,6 +167,7 @@ def test_plugin_charges_register(force_field_with_plugins, molecule, handler, ch ) +@pytest.mark.med @pytest.mark.skipif( condition=are_in_CI(), reason="This test is too slow for CI", diff --git a/tests/conftest.py b/tests/conftest.py index cc1d77b..e4eca61 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,40 +1,43 @@ -# Copyright (C) 2024-2025 ETH Zurich, Niels Maeder and other DASH contributors. - """Pytest configuration file.""" -from collections.abc import Sequence - import pytest +from collections.abc import Sequence def pytest_addoption(parser: pytest.Parser) -> None: - """Add cli option to include slow tests. - - Args: - parser (pytest.Parser): pytest parser. - """ + parser.addoption( + "--tier", + action="store", + default="min", + choices=("min", "med", "max"), + help="Run tests for a specific environment tier.", + ) parser.addoption("--run-slow", action="store_true", default=False, help="Include slow tests.") def pytest_configure(config: pytest.Config) -> None: - """Add slow marker to pytest. - - Args: - config (pytest.Config): pytest configuration. - """ - config.addinivalue_line("markers", "slow: mark test as slow to run") + config.addinivalue_line("markers", "min: expected to pass with tree_ony_env.yml") + config.addinivalue_line("markers", "med: expected to pass with min_environment.yml") + config.addinivalue_line("markers", "max: expected to pass with environment.yml") + config.addinivalue_line("markers", "slow: slow running tests") def pytest_collection_modifyitems(config: pytest.Config, items: Sequence[pytest.Item]) -> None: - """Skip slow tests if not included. - - Args: - config (pytest.Config): pytest configuration. - items (Sequence[pytest.Item]): pytest items. - """ - if config.getoption("--run-slow"): - return - skip_slow = pytest.mark.skip(reason="need --run-slow option to run.") + tier = config.getoption("--tier") + run_slow = config.getoption("--run-slow") + + allowed_tiers = {"min"} + if tier == "med": + allowed_tiers.update({"med"}) + elif tier == "max": + allowed_tiers.update({"med", "max"}) + for item in items: - if "slow" in item.keywords: - item.add_marker(skip_slow) + item_tiers = {mark.name for mark in item.iter_markers() if mark.name in {"min", "med", "max"}} + + if item_tiers and not (item_tiers & allowed_tiers): + item.add_marker(pytest.mark.skip(reason=f"Test requires higher tier than '{tier}'")) + continue + + if "slow" in item.keywords and not run_slow: + item.add_marker(pytest.mark.skip(reason="Slow test: use --run-slow to run")) diff --git a/tests/serenityff/charge/gnn/utils/test_rdkit_helper.py b/tests/serenityff/charge/gnn/utils/test_rdkit_helper.py index 91ab425..ba77f21 100644 --- a/tests/serenityff/charge/gnn/utils/test_rdkit_helper.py +++ b/tests/serenityff/charge/gnn/utils/test_rdkit_helper.py @@ -34,6 +34,7 @@ def sample_mol_missing_prop(): return mol +@pytest.mark.max def test_get_mol_prop_as_pt_tensor_success(sample_mol_with_prop): """Test successful retrieval of property as a tensor.""" expected = pt.tensor([1.0, 2.5, -3.0], dtype=pt.float) @@ -42,6 +43,7 @@ def test_get_mol_prop_as_pt_tensor_success(sample_mol_with_prop): assert pt.equal(result, expected) +@pytest.mark.max def test_get_mol_prop_as_pt_tensor_raises_value_error_on_none_prop( sample_mol_missing_prop, ): @@ -50,6 +52,7 @@ def test_get_mol_prop_as_pt_tensor_raises_value_error_on_none_prop( get_mol_prop_as_pt_tensor(None, sample_mol_missing_prop) +@pytest.mark.max def test_get_mol_prop_as_pt_tensor_raises_value_error_on_missing_prop( sample_mol_missing_prop, ): @@ -58,6 +61,7 @@ def test_get_mol_prop_as_pt_tensor_raises_value_error_on_missing_prop( get_mol_prop_as_pt_tensor("missing_prop", sample_mol_missing_prop) +@pytest.mark.max def test_get_mol_prop_as_pt_tensor_raises_type_error_on_nan( sample_mol_with_nan_prop, ): @@ -66,6 +70,7 @@ def test_get_mol_prop_as_pt_tensor_raises_type_error_on_nan( get_mol_prop_as_pt_tensor("test_prop_nan", sample_mol_with_nan_prop) +@pytest.mark.max def test_get_mol_prop_as_np_array_success(sample_mol_with_prop): """Test successful retrieval of property as a numpy array.""" expected = np.array([1.0, 2.5, -3.0]) @@ -74,6 +79,7 @@ def test_get_mol_prop_as_np_array_success(sample_mol_with_prop): np.testing.assert_array_equal(result, expected) +@pytest.mark.max def test_get_mol_prop_as_np_array_raises_value_error_on_none_prop( sample_mol_missing_prop, ): @@ -82,6 +88,7 @@ def test_get_mol_prop_as_np_array_raises_value_error_on_none_prop( get_mol_prop_as_np_array(None, sample_mol_missing_prop) +@pytest.mark.max def test_get_mol_prop_as_np_array_raises_value_error_on_missing_prop( sample_mol_missing_prop, ): @@ -90,6 +97,7 @@ def test_get_mol_prop_as_np_array_raises_value_error_on_missing_prop( get_mol_prop_as_np_array("missing_prop", sample_mol_missing_prop) +@pytest.mark.max def test_get_mol_prop_as_np_array_raises_type_error_on_nan( sample_mol_with_nan_prop, ): diff --git a/tests/serenityff/charge/utils/test_serenityff_charge_handler.py b/tests/serenityff/charge/utils/test_serenityff_charge_handler.py index d5d3526..bc57945 100644 --- a/tests/serenityff/charge/utils/test_serenityff_charge_handler.py +++ b/tests/serenityff/charge/utils/test_serenityff_charge_handler.py @@ -79,6 +79,7 @@ def molecule() -> Molecule: return Molecule.from_smiles("CCO") +@pytest.mark.med def test_handler_init(handler: SerenityFFChargeHandler) -> None: assert handler._TAGNAME == "SerenityFFCharge" assert handler._DEPENDENCIES == [ @@ -100,6 +101,7 @@ def test_handler_init(handler: SerenityFFChargeHandler) -> None: assert handler.attention_threshold == 10 +@pytest.mark.med def test_singleton() -> None: instance1 = SerenityFFChargeHandler(version=0.3) instance2 = SerenityFFChargeHandler(version=0.3) @@ -107,6 +109,7 @@ def test_singleton() -> None: assert instance1 is instance2 +@pytest.mark.med def test_loading_off_handler_plugins( force_field_custom_offxml: ForceField, force_field_with_plugins: ForceField ) -> None: @@ -117,6 +120,7 @@ def test_loading_off_handler_plugins( assert force_field_custom_offxml.get_parameter_handler(key) +@pytest.mark.med def test_plugin_charges_get_parameter_handler( force_field_with_plugins: SerenityFFChargeHandler, molecule, @@ -134,6 +138,7 @@ def test_plugin_charges_get_parameter_handler( ) +@pytest.mark.med def test_plugin_charges_register( force_field_with_plugins, molecule, @@ -152,6 +157,7 @@ def test_plugin_charges_register( ) +@pytest.mark.med def test_custom_force_field_file_charges(force_field_custom_offxml: ForceField, molecule) -> None: assert allclose( force_field_custom_offxml.get_partial_charges(molecule),