Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.2.0

- Change: `tests/` has been separated into `tests/modelblocks` and `tests/unit` (convention and unit testing, respectively).
- `tests/integration_test.py` was split into `convention_test.py` (interfacing checks) and `module_integration_test.py` (module export checks).
- Add: new `unit-test` pixi environment, resolved in tandem with the `module` environment while adding `pytest` to it.
- Add: new `pixi run test-unit` task for unit testing script code.

## 0.1.0

### Added
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ A template for modular data workflows built with [`Snakemake`](https://snakemake
>
> - **Modules do not work like regular `Snakemake` workflows**
> - They must be tested externally using the `module:` command in `Snakemake`, passing user resources, and requesting specific results.
Check the pre-made example in `tests/integration` for details.
Check the pre-made example in `tests/modelblocks/integration` for details.
> - Internal access (e.g., calling the `all:` rule) is discouraged, as the module may not have the necessary `resources/` to execute properly.
> - **Please be sure to maintain the following files to ensure Modelblocks compatibility**
> - `INTERFACE.yaml`: a simple description of the module's input/output structure.
Expand Down Expand Up @@ -82,7 +82,7 @@ This template uses [`pixi`](https://pixi.sh/) as its package manager. Once insta
6. Extra: run the auto-generated example module!

```shell
cd tests/integration # go to the integration test...
cd tests/modelblocks/integration # go to the integration test...
pixi run snakemake --use-conda # run it!
```

Expand All @@ -93,10 +93,16 @@ This template uses [`pixi`](https://pixi.sh/) as its package manager. Once insta
Export `<ENVIRONMENT>` to `conda`-compatible dependency files, saved in `workflow/envs`, allowing `Snakemake` to use them during rule execution.
This will generate both an `<ENVIRONMENT>.yaml` file and platform-specific pin files for Windows, Linux and macOS (e.g., `<ENVIRONMENT>.win-64.pin.txt`).

### `pixi run test-integration`
### `pixi run test-modelblocks`

Run a minimal set of standardised tests to ensure your module complies with Modelblock requirements.
These are executed by Github's CI during pull requests.
These tests are executed by Github's CI during pull requests.


### `pixi run test-unit`

Run unit tests on workflow python code in `workflow/scripts/`, using a separate `unit-test` environment.
These tests are executed by Github's CI during pull requests.


## Contributors ✨
Expand Down
13 changes: 9 additions & 4 deletions copier.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,30 @@ _exclude:
- "{% if _copier_operation == 'update' -%}figures{% endif %}"
- "{% if _copier_operation == 'update' -%}results{% endif %}"
- "{% if _copier_operation == 'update' -%}config/**{% endif %}"
- "{% if _copier_operation == 'update' -%}tests/**{% endif %}"
# Special cases within the above that should not be excluded.
- "{% if _copier_operation == 'update' -%}!config/README.md{% endif %}"
- "{% if _copier_operation == 'update' -%}!tests/integration_test.py{% endif %}"
- "{% if _copier_operation == 'update' -%}!workflow/profiles/default/config.yaml{% endif %}"
_skip_if_exists:
# Respect user changes, except for a few standard files.
- config/**
- resources/**
- figures/**
- results/**
- tests/**
- tests/unit/**
- workflow/**
# Special cases within the above that should not be skipped.
- "!config/README.md"
- "!tests/integration_test.py"
- "!workflow/profiles/default/config.yaml"
_templates_suffix: .jinja
_answers_file: .copier-answers.yml
_message_after_update: |
⚠️⚠️ IMPORTANT: Integration test layout changed ⚠️⚠️

Modelblocks integration and compatibility tests now live in tests/modelblocks/.
If your project still uses tests/integration/, merge its Snakefile and supporting files into tests/modelblocks/integration/.
Adjust relative paths as needed.

Run pixi run test-modelblocks to validate a successful migration.

# === copier user provided ===
module_code_name:
Expand Down
16 changes: 10 additions & 6 deletions template/.github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,28 @@ jobs:
- uses: actions/checkout@v7
- name: Setup pixi
uses: prefix-dev/setup-pixi@v0.9.6
- name: Run integration tests
id: tests
run: pixi run test-integration
- name: Run standard Modelblocks tests
id: test_modelblocks
run: pixi run test-modelblocks
continue-on-error: true
- name: Run unit tests
id: test_unit
run: pixi run test-unit
continue-on-error: true
- name: Run snakemake linting
id: linting
run: |
pixi run snakemake --lint --snakefile workflow/Snakefile
pixi run snakemake --lint --snakefile tests/integration/Snakefile
pixi run snakemake --lint --snakefile tests/modelblocks/integration/Snakefile
continue-on-error: true
- name: Save integration logs
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: integration-test-logs-${{ matrix.os }}
path: tests/integration/resources/module/logs
path: tests/modelblocks/integration/resources/module/logs
if-no-files-found: ignore
retention-days: 30
- name: Fail if integration or linting failed
if: ${{ steps.tests.outcome == 'failure' || steps.linting.outcome == 'failure' }}
if: ${{ steps.test_modelblocks.outcome == 'failure' || steps.test_unit.outcome == 'failure' || steps.linting.outcome == 'failure' }}
run: exit 1
7 changes: 4 additions & 3 deletions template/README.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
This is a modular `snakemake` workflow created as part of the [Modelblocks project](https://www.modelblocks.org/). It can be imported directly into any `snakemake` workflow.

For more information, please consult the Modelblocks [documentation](https://modelblocks.readthedocs.io/en/latest/),
the [integration example](./tests/integration/Snakefile),
the [integration example](./tests/modelblocks/integration/Snakefile),
and the `snakemake` [documentation](https://snakemake.readthedocs.io/en/stable/snakefiles/modularization.html).

## Overview
Expand Down Expand Up @@ -65,14 +65,15 @@ pixi run export-snakemake-env module
For testing, simply run:

```shell
pixi run test-integration
pixi run test-modelblocks # Modelblocks compatibility
pixi run test-unit # Script unit tests
```

To test a minimal example of a workflow using this module:

```shell
pixi shell # activate this project's environment
cd tests/integration/ # navigate to the integration example
cd tests/modelblocks/integration/ # navigate to the integration example
snakemake --use-conda --cores 2 # run the workflow!
```

Expand Down
4 changes: 3 additions & 1 deletion template/config/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Configuration

We recommend consulting the following before using this module:
- `config/config.yaml`: a generic example configuration of this module.
- `workflow/internal/config.schema.yaml`: a schematic overview of all the configuration options of this module.
- `INTERFACE.yaml`: lists module input and output files, and their default locations.
- `tests/integration/Snakefile`: an example of how to call this module from another workflow.
- `tests/modelblocks/integration/Snakefile`: an example of how to call this module from another workflow.

This data module is part of the [Modelblocks](https://www.modelblocks.org/) project.
Please consult the [Modelblocks documentation](https://modelblocks.readthedocs.io/) for more details.
1 change: 0 additions & 1 deletion template/figures/.gitkeep

This file was deleted.

11 changes: 9 additions & 2 deletions template/pixi.toml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,18 @@ pytz = ">=2026.1.post1"
[feature.module.dependencies]
curl = ">=8.9.1"

[feature.testing.dependencies]
pytest = ">=8.3.5"

[feature.testing.tasks]
test-unit = {cmd = "pytest tests/unit/", default-environment = "unit-test"}

[environments]
module = { features = ["module"], no-default-feature = true }
module = { features = ["module"], no-default-feature = true, solve-group = "module" }
unit-test = { features = ["module", "testing"], no-default-feature = true, solve-group = "module" }

[tasks]
test-integration = {cmd = "pytest tests/integration_test.py"}
test-modelblocks = {cmd = "pytest tests/modelblocks/", default-environment = "default"}

{% raw %}
[tasks.export-snakemake-env]
Expand Down
12 changes: 0 additions & 12 deletions template/tests/conftest.py

This file was deleted.

20 changes: 20 additions & 0 deletions template/tests/modelblocks/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Shared pytest fixtures."""

import tomllib
from pathlib import Path

import pytest


@pytest.fixture(scope="module")
def module_path():
"""Parent directory of the project."""
# If your module needs files in resources/user/, place automated downloads here.
return Path(__file__).parents[2]


@pytest.fixture(scope="module")
def pixi_platforms(module_path) -> list[str]:
"""Pixi platforms defined for this project."""
with (module_path / "pixi.toml").open("rb") as pixi_config:
return tomllib.load(pixi_config)["workspace"]["platforms"]
Original file line number Diff line number Diff line change
@@ -1,24 +1,17 @@
"""Set of standard Modelblocks tests.
"""Test Modelblocks convention compliance.

!!!!! DO NOT ERASE OR MODIFY !!!!!
PLEASE ENSURE THIS SET OF MINIMAL TESTS WORKS BEFORE PUBLISHING YOUR MODULE.
Contents may be updated in future template updates.
"""

import subprocess
import tomllib
from pathlib import Path

import pytest
from clio_tools.data_module import ModuleInterface


@pytest.fixture(scope="module")
def pixi_platforms(module_path) -> list[str]:
"""Pixi platforms defined for this project."""
with (module_path / "pixi.toml").open("rb") as pixi_config:
return tomllib.load(pixi_config)["workspace"]["platforms"]


def test_snakemake_environments(module_path, pixi_platforms, tmp_path):
"""All Snakemake environment files should be based on pixi counterparts."""
env_dir = module_path / "workflow/envs"
Expand Down Expand Up @@ -57,28 +50,14 @@ def test_interface_file(module_path):
"LICENSE",
"README.md",
"config/config.yaml",
"config/README.md",
"figures/module.png",
"tests/modelblocks/integration/Snakefile",
"workflow/internal/config.schema.yaml",
"tests/integration/Snakefile",
".github/workflows/check-version.yml",
".github/workflows/pr-ci.yml",
],
)
def test_standard_file_existance(module_path, file):
"""Check that a minimal set of files used for documentation are present."""
assert Path(module_path / file).exists()


def test_snakemake_all_failure(module_path):
"""The snakemake 'all' rule should return an error by default."""
process = subprocess.run(
"snakemake --cores 1", shell=True, cwd=module_path, capture_output=True
)
assert "INVALID" in str(process.stderr)


def test_snakemake_integration_testing(module_path):
"""Run a light-weight test simulating someone using this module."""
assert subprocess.run(
"snakemake --use-conda --cores 1",
shell=True,
check=True,
cwd=module_path / "tests/integration",
)
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ rule create_external_input:
# `config`: specifies the module configuration.
# `pathvars:` helps you re-wire where the module places files.
module {{module_code_name}}:
snakefile: "../../workflow/Snakefile"
snakefile: "../../../workflow/Snakefile"
config: config["{{module_code_name}}"]
pathvars:
# Redirect specific user resources (inputs)
Expand Down
26 changes: 26 additions & 0 deletions template/tests/modelblocks/module_integration_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Test importing the module through Snakemake.

!!!!! DO NOT ERASE OR MODIFY !!!!!
PLEASE ENSURE THIS SET OF MINIMAL TESTS WORKS BEFORE PUBLISHING YOUR MODULE.
Contents may be updated in future template updates.
"""

import subprocess


def test_snakemake_all_failure(module_path):
"""The snakemake 'all' rule should return an error by default."""
process = subprocess.run(
"snakemake --cores 1", shell=True, cwd=module_path, capture_output=True
)
assert "INVALID" in str(process.stderr)


def test_snakemake_integration_testing(module_path):
"""Run a light-weight test simulating someone using this module."""
assert subprocess.run(
"snakemake --use-conda --cores 1 --forceall",
shell=True,
check=True,
cwd=module_path / "tests/modelblocks/integration",
)
12 changes: 12 additions & 0 deletions template/tests/unit/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Unit tests for module script code.

IMPORTANT: must be executed in the "unit-test" environment.
"""

import sys
from pathlib import Path

MODULE_PATH = Path(__file__).resolve().parent.parent.parent
WORKFLOW_SCRIPTS = MODULE_PATH / "workflow" / "scripts"

sys.path.insert(0, str(WORKFLOW_SCRIPTS))
24 changes: 18 additions & 6 deletions template/workflow/scripts/dummy_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,24 @@

if TYPE_CHECKING:
snakemake: Any
sys.stderr = open(snakemake.log[0], "w")

config = snakemake.params.config_text
readme = Path(snakemake.input.readme).read_text()
user = Path(snakemake.input.user_message).read_text()

output_text = "\n\n".join([readme, user, config])
def dummy_uppercase(text: str) -> str:
"""Turns text to uppercase."""
return text.upper()

Path(snakemake.output.combined_text).write_text(output_text)

def main():
"""Main Snakemake process."""
config = snakemake.params.config_text
readme = Path(snakemake.input.readme).read_text()
user = Path(snakemake.input.user_message).read_text()

output_text = dummy_uppercase("\n\n".join([readme, user, config]))

Path(snakemake.output.combined_text).write_text(output_text)


if __name__ == "__main__":
sys.stderr = open(snakemake.log[0], "w", buffering=1)
main()
Loading
Loading