Repository: ericmjl/pyjanitor (python)

Issue:
[ENH] Warn the user of non-existent column names passed to .select_columns()
It would be nice if the user was given a warning when non-existent column names are passed to `.select_columns()`. For example, it would be helpful if the following chunk

```python
>>> test_df = pd.DataFrame(data=np.random.randn(20, 5), columns=["A", "B", "C", "col_D", "col_E"])
>>> test_df.select_columns(["A", "B", "G", "col_*"]).rename_column("A", "a")

          a         B     col_D     col_E
0 -0.307637 -0.057561 -0.355314  0.050262
1  1.263703  0.050407 -0.300649  1.057278
2  1.133523 -0.742104 -0.866652 -1.503412
3  1.776966 -0.106066  1.221996 -0.901457
4  0.841195  0.503199  0.064804  0.181194
```

also produced a warning (without raising an exception), à la

```bash
/Users/smu095/Documents/github/pyjanitor/janitor/functions.py:2780: UserWarning: Column 'G' not found.
```

Occasionally I find myself selecting columns, only to realise later on that I have misspelled the column name and it hasn't been selected at all. I would like to be warned ASAP if this is the case.

My back of the envelope suggestion is something like this:

```python
@pf.register_dataframe_method
@deprecated_alias(search_cols="search_column_names")
def select_columns(
    df: pd.DataFrame, search_column_names: Iterable, invert: bool = False
) -> pd.DataFrame:
    """
    Method-chainable selection of columns.

    This method does not mutate the original DataFrame.

    Optional ability to invert selection of columns available as well.

    Method-chaining example:

    .. code-block:: python

        df = pd.DataFrame(...).select_columns(['a', 'b', 'col_*'], invert=True)

    :param df: A pandas DataFrame.
    :param search_column_names: A list of column names or search strings to be
        used to select. Valid inputs include:
        1) an exact column name to look for
        2) a shell-style glob string (e.g., `*_thing_*`)
    :param invert: Whether or not to invert the selection.
        This will result in selection of the complement of the columns
        provided.
    :returns: A pandas DataFrame with the specified columns selected.
    """
    wildcards = [col for col in search_column_names if "*" in col]
    non_wildcards = set(search_column_names) - set(wildcards)

    for col in non_wildcards:
        if col not in df:
            warnings.warn(f"Column '{col}' not found.")

    full_column_list = []

    for col in search_column_names:
        search_string = translate(col)
        columns = [col for col in df if re.match(search_string, col)]
        full_column_list.extend(columns)

    return (
        df.drop(columns=full_column_list) if invert else df[full_column_list]
    )
```

This is my first foray into contributing to open-source projects, any feedback is welcome. Thanks!

Pull request description:
[ENH] .select_columns() raises NameError when passed missing column labels
<!-- Thank you for your PR!

BEFORE YOU CONTINUE! Please add the appropriate three-letter abbreviation to your title.

The abbreviations can be:
- [DOC]: Documentation fixes.
- [ENH]: Code contributions and new features.
- [TST]: Test-related contributions.
- [INF]: Infrastructure-related contributions.

Also, do not forget to tag the relevant issue here as well.

Finally, as commits come in, don't forget to regularly rebase!
-->

# PR Description

Please describe the changes proposed in the pull request:

- `.select_columns()` will now raise `NameError` if column labels in `search_column_labels` are note found in `df.columns`.

<!-- Doing so provides maintainers with context on what the PR is, and can help us more effectively review your PR. -->

<!-- Please also identify below which issue that has been raised that you are going to close. -->

**This PR resolves #647**

<!-- As you go down the PR template, please feel free to delete sections that are irrelevant. -->

# PR Checklist

<!-- This checklist exists for newcomers who are not yet familiar with our requirements. If you are experienced with
the project, please feel free to delete this section. -->

Please ensure that you have done the following:

1. [X] PR in from a fork off your branch. Do not PR from `<your_username>`:`dev`, but rather from `<your_username>`:`<feature-branch_name>`.
<!-- Doing this helps us keep the commit history much cleaner than it would otherwise be. -->
2. [X] If you're not on the contributors list, add yourself to `AUTHORS.rst`.
<!-- We'd like to acknowledge your contributions! -->
3. [X] Add a line to `CHANGELOG.rst` under the latest version header (i.e. the one that is "on deck") describing the contribution.
    - Do use some discretion here; if there are multiple PRs that are related, keep them in a single line.

## Quick Check

To do a very quick check that everything is correct, follow these steps below:

- [X] Run the command `make check` from pyjanitor's top-level directory. This will automatically run:
    - black formatting
    - flake8 checking
    - running the test suite
    - docs build

Once done, please check off the check-box above.

If `make check` does not work for you, you can execute the commands listed in the Makefile individually.

## Code Changes

<!-- If you have not made code changes, please feel free to delete this section. -->

If you are adding code changes, please ensure the following:

- [X] Ensure that you have added tests.
- [X] Run all tests (`$ pytest .`) locally on your machine.
    - [X] Check to ensure that test coverage covers the lines of code that you have added.
    - [X] Ensure that all tests pass.

## Documentation Changes

<!-- If you have not made documentation changes, please feel free to delete this section. -->

If you are adding documentation changes, please ensure the following:

- [X] Build the docs locally.
- [X] View the docs to check that it renders correctly.

# Relevant Reviewers

<!-- Finally, please tag relevant maintainers to review. -->

Please tag maintainers to review.

- @ericmjl


Patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index f2c5361..3f3e33e 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -87,3 +87,4 @@ Contributors
 - `@DollofCuty <https://github.com/DollofCuty>`_ | `contributions <https://github.com/ericmjl/pyjanitor/pulls?utf8=%E2%9C%93&q=is%3Aclosed+mentions%3ADollofCuty>`_
 - `@bdice <https://github.com/bdice>`_ | `contributions <https://github.com/ericmjl/pyjanitor/pulls?utf8=%E2%9C%93&q=is%3Aclosed+mentions%3Abdice>`_
 - `@evan-anderson <https://github.com/evan-anderson>`_ | `contributions <https://github.com/ericmjl/pyjanitor/pulls?utf8=%E2%9C%93&q=is%3Aclosed+mentions%3evan-anderson>`_
+    `@smu095 <https://github.com/smu095>`_ | `contributions <https://github.com/ericmjl/pyjanitor/issues?q=is%3Aclosed+mentions%3smu095>`_
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index fce18df..10ea68e 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,7 +1,8 @@
 new version (on deck)
 =====================
 - [INF]: Replace ``pycodestyle`` with ``flake8`` in order to add ``pandas-vet`` linter @hectormz
-
+- [ENH] ``select_columns()`` now raises ``NameError`` if column label in
+  ``search_columns_labels`` is missing from ``DataFrame`` columns. @smu095
 
 v0.20.1
 =======
diff --git a/janitor/functions.py b/janitor/functions.py
index 07353e0..e5c6bca 100644
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -2747,7 +2747,7 @@ def currency_column_to_numeric(
 @pf.register_dataframe_method
 @deprecated_alias(search_cols="search_column_names")
 def select_columns(
-    df: pd.DataFrame, search_column_names: Iterable, invert: bool = False
+    df: pd.DataFrame, search_column_names: List[str], invert: bool = False
 ) -> pd.DataFrame:
     """
     Method-chainable selection of columns.
@@ -2771,13 +2771,41 @@ def select_columns(
         This will result in selection of the complement of the columns
         provided.
     :returns: A pandas DataFrame with the specified columns selected.
-    """
+    :raises:
+        TypeError: if input is not passed as a list.
+    :raises:
+        NameError: if one or more of the specified column names or
+        search strings are not found in DataFrame columns.
+    """
+    if not isinstance(search_column_names, list):
+        raise TypeError(
+            "Column name(s) or search string(s) must be passed as list"
+        )
+
+    wildcards = {col for col in search_column_names if "*" in col}
+    non_wildcards = set(search_column_names) - wildcards
+
+    if not non_wildcards.issubset(df.columns):
+        nonexistent_column_names = non_wildcards.difference(df.columns)
+        raise NameError(
+            f"{list(nonexistent_column_names)} missing from DataFrame"
+        )
+
+    missing_wildcards = []
     full_column_list = []
 
     for col in search_column_names:
         search_string = translate(col)
         columns = [col for col in df if re.match(search_string, col)]
-        full_column_list.extend(columns)
+        if len(columns) == 0:
+            missing_wildcards.append(col)
+        else:
+            full_column_list.extend(columns)
+
+    if len(missing_wildcards) > 0:
+        raise NameError(
+            f"Search string(s) {missing_wildcards} not found in DataFrame"
+        )
 
     return (
         df.drop(columns=full_column_list) if invert else df[full_column_list]


Test patch:
diff --git a/tests/functions/test_select_columns.py b/tests/functions/test_select_columns.py
index 61147d9..f315f54 100644
--- a/tests/functions/test_select_columns.py
+++ b/tests/functions/test_select_columns.py
@@ -30,3 +30,29 @@ def test_select_columns_glob_inputs(dataframe, invert, expected):
     df = dataframe.select_columns(search_column_names=columns, invert=invert)
 
     pd.testing.assert_frame_equal(df, dataframe[expected])
+
+
+@pytest.mark.functions
+@pytest.mark.parametrize(
+    "columns",
+    [
+        ["a", "Bell__Chart", "foo"],
+        ["a", "Bell__Chart", "foo", "bar"],
+        ["a*", "Bell__Chart", "foo"],
+        ["a*", "Bell__Chart", "foo", "bar"],
+    ],
+)
+def test_select_columns_missing_columns(dataframe, columns):
+    """Check that passing non-existent column names or search strings raises NameError"""  # noqa: E501
+    with pytest.raises(NameError):
+        dataframe.select_columns(search_column_names=columns)
+
+
+@pytest.mark.functions
+@pytest.mark.parametrize(
+    "columns", ["a", ("a", "Bell__Chart"), {"a", "Bell__Chart"}]
+)
+def test_select_columns_input(dataframe, columns):
+    """Check that passing an iterable that is not a list raises TypeError."""
+    with pytest.raises(TypeError):
+        dataframe.select_columns(search_column_names=columns)