Repository: HK3-Lab-Team/pytrousse

Issue:
implement Compose class for FeatureOperations chaining

Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 645ce4c..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-# Wire up travis
-language: python
-
-python:
-  - "3.6"
-  - "3.7"
-
-# command to install dependencies
-install:
-  - pip install -e .[testing]
-  - pip install flake8
-
-# command to run tests
-script:
-  - flake8 .
-  - pytest --cov=trousse
-
-after_success:
-  - coveralls
-  
-notifications:
-  slack:
-    secure: scXDWD8UAsSDwSIeHdpoh4iK7R8DvkSEZOc+WAieKC6Dz+TYOEt08JOU6uiaKaqZAGHn/Zkp3balELRRXeap3HBfqNH1Tm2De+DLFDEdiQRDLnOlT/I4jzTl4Unof+QMeVtz6f5+lVKMTBozz4D6YXnWIFZONMCKTHTzhLnDPa8hRHFUfKHZGSW3wM2FWYjObNOew1+6SLkq3h7L5UCYgvf6ubbhW9/1N9nOAMs4YZZt3ND12bzq1L/D381W94VrpsR2O1rwcMwfqW+cJbb9++UPY8/PhmjvRuM42Q5YdE+EGgNW2IR57pA4XNxzCtOIZEKBFlPki+IFUwOKBK5yTK4oUPBnB8OrTiIcgD1aOle+cXWKuG24GqKa/wOaIsYri1EG3pkq+yE86EWoImwB0PDYzr+AOYt8ujZzodbTvCCSp0IjonwQZqu/ot1rgXgQDPHgrg2Uoo59cyaVXVCflQOjr6W0kyOlIe9wWH0mzq+WphZU+ZjUJLNMUIh2XJ1qR2npPk3ZmiLqS4DX/DcdzRjD5Vi9EjLc6zFb1OPfAu8CMBnpGKT4DUQi0AxaLDgYdmeTEYQrXloh7bpHidpd+ZFZtPbMkkjHiw8spSJ/RCLRy02TOZEkGgWi14iXZkim1323E8KMKvv2oMB0UU00xd6ZRaFc852/bXjGJ8gZRXU=
diff --git a/src/trousse/feature_operations.py b/src/trousse/feature_operations.py
index 949897d..3011914 100644
--- a/src/trousse/feature_operations.py
+++ b/src/trousse/feature_operations.py
@@ -1,7 +1,7 @@
 import copy
 from abc import ABC
 from abc import abstractmethod
-from typing import Any, List, Mapping
+from typing import Any, List, Mapping, Tuple
 
 from .dataset import Dataset
 from .util import is_sequence_and_not_str
@@ -94,6 +94,46 @@ class FeatureOperation(ABC):
         raise NotImplementedError
 
 
+class Trousse:
+    """Composes several FeatureOperations together.
+
+    Parameters
+    ----------
+    *operations : Tuple[FeatureOperations]
+        Tuple of FeatureOperations to compose
+    """
+
+    def __init__(self, *operations: FeatureOperation) -> None:
+        self._operations = operations
+
+    @property
+    def operations(self) -> Tuple[FeatureOperation]:
+        return self._operations
+
+    def __call__(self, dataset: Dataset) -> Dataset:
+        """Apply the composed FeatureOperations to the ``dataset`` one after the other.
+
+        Parameters
+        ----------
+        dataset : Dataset
+            Dataset to apply the operations on
+
+        Returns
+        -------
+        Dataset
+            New Dataset instance with the operations applied on
+        """
+        for op in self._operations:
+            dataset = op(dataset)
+        return dataset
+
+    def __repr__(self) -> str:
+        return self.__class__.__name__ + f": {str(self._operations)}"
+
+    def __str__(self) -> str:
+        return self.__class__.__name__ + f": {str(self._operations)}"
+
+
 class FillNA(FeatureOperation):
     """Fill NaN values ``columns`` (single-element list) column with value ``value``.
 


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..739fa6c
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,72 @@
+name: CI
+
+on:
+  push:
+    branches:
+      - main
+    tags:
+      - v*
+  pull_request:
+
+jobs:
+
+  build:
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [macOS-latest, ubuntu-latest]
+        python-version: [3.6, 3.7]
+        include:
+          - os: macOS-latest
+            python-version: 3.8
+          - os: ubuntu-latest
+            python-version: 3.8
+    env:
+      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        id: python-dependencies
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install -e .[testing]
+          python -m pip install flake8
+      - name: Lint with flake8
+        id: flake8
+        run: |
+          flake8 . --count --max-complexity=10 --max-line-length=88 --statistics
+      - name: Test with pytest
+        id: tests
+        run: |
+          python -m pytest
+      - name: Slack Notification
+        uses: act10ns/slack@v1
+        with:
+          status: ${{ job.status }}
+          steps: ${{ toJson(steps) }}
+        if: always()
+
+  coveralls:
+    needs: build
+    runs-on: ubuntu-latest
+    env:
+      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python 3.7
+        uses: actions/setup-python@v2
+        with:
+          python-version: 3.7
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install -e .[testing]
+          python -m pip install flake8
+      - name: Test with pytest
+        run: python -m pytest --cov=trousse
+      - name: Coveralls
+        run: coveralls
\ No newline at end of file
diff --git a/tests/conftest.py b/tests/conftest.py
index 5628250..68fe77e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -39,20 +39,29 @@ def temporary_data_dir(request) -> Path:
 
 
 @pytest.fixture
-def fillna_col0_col1(request):
+def fillna_col0_col1():
     return fop.FillNA(columns=["col0"], derived_columns=["col1"], value=0)
 
 
 @pytest.fixture
-def fillna_col1_col4(request):
+def fillna_col1_col4():
     return fop.FillNA(columns=["col1"], derived_columns=["col4"], value=0)
 
 
 @pytest.fixture
-def fillna_col4_none(request):
+def fillna_col4_none():
     return fop.FillNA(columns=["col4"], derived_columns=None, value=0)
 
 
 @pytest.fixture
-def fillna_col1_col2(request):
+def fillna_col1_col2():
     return fop.FillNA(columns=["col1"], derived_columns=["col2"], value=0)
+
+
+@pytest.fixture
+def replacestrings_exam_num_col_0_replaced_exam_num_col_0_a_b():
+    return fop.ReplaceStrings(
+        columns=["exam_num_col_0"],
+        derived_columns=["replaced_exam_num_col_0"],
+        replacement_map={"a": "b"},
+    )
diff --git a/tests/unit/test_feature_operations.py b/tests/unit/test_feature_operations.py
index 61cdca6..8f4278f 100644
--- a/tests/unit/test_feature_operations.py
+++ b/tests/unit/test_feature_operations.py
@@ -1,13 +1,12 @@
 import pandas as pd
 import pytest
+import sklearn.preprocessing as sk_preproc
 
 from trousse import feature_operations as fop
 from trousse.dataset import Dataset
 
 from ..dataset_util import DataFrameMock
-import sklearn.preprocessing as sk_preproc
-
-from ..unitutil import ANY, function_mock, initializer_mock, method_mock
+from ..unitutil import ANY, function_mock, initializer_mock, instance_mock, method_mock
 
 
 class DescribeFeatureOperation:
@@ -107,6 +106,91 @@ class DescribeFeatureOperation:
         )
 
 
+class DescribeTrousse:
+    @pytest.mark.parametrize(
+        "operations",
+        [
+            (),
+            (
+                fop.FillNA(
+                    columns=["nan"],
+                    value=0,
+                ),
+            ),
+            (
+                fop.ReplaceStrings(
+                    columns=["exam_num_col_0"],
+                    derived_columns=["replaced_exam_num_col_0"],
+                    replacement_map={"a": "b"},
+                ),
+                fop.FillNA(columns=["nan"], value=0),
+            ),
+        ],
+    )
+    def it_contructs_from_args(self, request, operations):
+        _init_ = initializer_mock(request, fop.Trousse)
+
+        trousse = fop.Trousse(*operations)
+
+        _init_.assert_called_once_with(ANY, *operations)
+        assert isinstance(trousse, fop.Trousse)
+
+    def it_knows_its_operations(
+        self,
+        replacestrings_exam_num_col_0_replaced_exam_num_col_0_a_b,
+        fillna_col0_col1,
+    ):
+        trousse = fop.Trousse(
+            replacestrings_exam_num_col_0_replaced_exam_num_col_0_a_b, fillna_col0_col1
+        )
+
+        operations = trousse.operations
+
+        assert type(operations) == tuple
+        assert operations == (
+            replacestrings_exam_num_col_0_replaced_exam_num_col_0_a_b,
+            fillna_col0_col1,
+        )
+
+    def it_knows_how_to_call(
+        self,
+        request,
+        replacestrings_exam_num_col_0_replaced_exam_num_col_0_a_b,
+        fillna_col0_col1,
+    ):
+        dataset_in = instance_mock(request, Dataset, "in")
+        dataset_out_1 = instance_mock(request, Dataset, "1")
+        dataset_out_2 = instance_mock(request, Dataset, "2")
+        _call_replacestrings = method_mock(request, fop.ReplaceStrings, "__call__")
+        _call_replacestrings.return_value = dataset_out_1
+        _call_fillna = method_mock(request, fop.FillNA, "__call__")
+        _call_fillna.return_value = dataset_out_2
+        trousse = fop.Trousse(
+            replacestrings_exam_num_col_0_replaced_exam_num_col_0_a_b, fillna_col0_col1
+        )
+
+        new_dataset = trousse(dataset_in)
+
+        _call_replacestrings.assert_called_once_with(
+            replacestrings_exam_num_col_0_replaced_exam_num_col_0_a_b, dataset_in
+        )
+        _call_fillna.assert_called_once_with(fillna_col0_col1, dataset_out_1)
+        assert isinstance(new_dataset, Dataset)
+        assert new_dataset == dataset_out_2
+
+    def it_knows_its_str(self, fillna_col0_col1, fillna_col1_col4):
+        trousse = fop.Trousse(fillna_col0_col1, fillna_col1_col4)
+
+        _str = str(trousse)
+
+        assert type(_str) == str
+        assert _str == (
+            "Trousse: (FillNA(\n\tcolumns=['col0'],\n\tvalue=0,\n\t"
+            "derived_columns=['col1'],\n), FillNA(\n\tcolumns=['col1']"
+            ",\n\tvalue=0,\n\tderived_columns=['col4'],\n))"
+        )
+
+
 class DescribeFillNa:
     def it_construct_from_args(self, request):
         _init_ = initializer_mock(request, fop.FillNA)