diff --git a/README.md b/README.md index e2145c3..996ea0a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ -# python-switch +# switchlang +[![](https://img.shields.io/badge/python-3.6+-blue.svg)](https://www.python.org/downloads/) +[![](https://img.shields.io/pypi/l/markdown-subtemplate.svg)](https://github.com/mikeckennedy/python-switch/blob/master/LICENSE) +[![](https://img.shields.io/pypi/dm/switchlang.svg)](https://pypi.org/project/switchlang/) -Adds switch blocks to Python. + +Adds switch blocks to the Python language. This module adds explicit switch functionality to Python without changing the language. It builds upon a standard @@ -11,13 +15,14 @@ way to define execution blocks: the `with` statement. ```python from switchlang import switch -num = 7 -val = input("Enter a key. a, b, c or any other: ") +def main(): + num = 7 + val = input("Enter a character, a, b, c or any other: ") -with switch(val) as s: - s.case('a', process_a) - s.case('b', lambda: process_with_data(val, num, 'other values still')) - s.default(process_any) + with switch(val) as s: + s.case('a', process_a) + s.case('b', lambda: process_with_data(val, num, 'other values still')) + s.default(process_any) def process_a(): print("Found A!") @@ -27,8 +32,18 @@ def process_any(): def process_with_data(*value): print("Found with data: {}".format(value)) + +main() ``` +## Installation + +Simply install via pip: + +```bash +pip install switchlang +``` + ## Features * More explicit than using dictionaries with functions as values. @@ -58,18 +73,147 @@ with switch(value) as s: value = 4 # matches first case with switch(value) as s: - s.case(range(1, 5), lambda: ...) - s.case(range(6, 7), lambda: ...) + s.case(range(1, 6), lambda: ...) + s.case(range(6, 10), lambda: ...) s.default(lambda: ...) ``` -**Warning / open for debate**: +## Closed vs. Open ranges + +Looking at the above code it's a bit weird that 6 appears +at the end of one case, beginning of the next. But `range()` is +half open/closed. + +To handle the inclusive case, I've added `closed_range(start, stop)`. +For example, `closed_range(1,5)` -> `[1,2,3,4,5]` + +## Why not just raw `dict`? + +The biggest push back on this idea is that we already have this problem solved. +You write the following code. + +```python +switch = { + 1: method_on_one, + 2: method_on_two, + 3: method_three +} + +result = switch.get(value, default_method_to_run)() +``` + +This works but is very low on the functionality level. We have a better solution here +I believe. Let's take this example and see how it looks in python-switch vs raw dicts: + +```python +# with python-switch: + +while True: + action = get_action(action) + + with switch(action) as s: + s.case(['c', 'a'], create_account) + s.case('l', log_into_account) + s.case('r', register_cage) + s.case('u', update_availability) + s.case(['v', 'b'], view_bookings) + s.case('x', exit_app) + s.case('', lambda: None) + s.case(range(1,6), lambda: set_level(action)) + s.default(unknown_command) + + print('Result is {}'.format(s.result)) +``` + +Now compare that to the espoused *pythonic* way: + +```python +# with raw dicts + +while True: + action = get_action(action) + + switch = { + 'c': create_account, + 'a': create_account, + 'l': log_into_account, + 'r': register_cage, + 'u': update_availability, + 'v': view_bookings, + 'b': view_bookings, + 'x': exit_app, + 1: lambda: set_level(action), + 2: lambda: set_level(action), + 3: lambda: set_level(action), + 4: lambda: set_level(action), + 5: lambda: set_level(action), + '': lambda: None, + } + result = switch.get(action, unknown_command)() + print('Result is {}'.format(result)) +``` + +Personally, I much prefer to read and write the one above. That's why I wrote this module. +It seems to convey the intent of switch way more than the dict. But either are options. -I'm a little unsure what is the right way to handle this. -On one hand, reading `case(range(1,5))` seems like it should -include `1, 2, 3, 4, 5`. But `list(range(1,5))` is `[1,2,3,4]`. -So that would be inconsistent. +## Why not just `if / elif / else`? -Thoughts? I'm going with `1,2,3,4,5` for `range(1,5)` for now. +The another push back on this idea is that we already have this problem solved. +Switch statements are really if / elif / else blocks. So you write the following code. +```python +# with if / elif / else + +while True: + action = get_action(action) + + if action == 'c' or action == 'a': + result = create_account() + elif action == 'l': + result = log_into_account() + elif action == 'r': + result = register_cage() + elif action == 'a': + result = update_availability() + elif action == 'v' or action == 'b': + result = view_bookings() + elif action == 'x': + result = exit_app() + elif action in {1, 2, 3, 4, 5}: + result = set_level(action) + else: + unknown_command() + + print('Result is {}'.format(result)) +``` + +I actually believe this is a little better than the +[raw dict option](https://github.com/mikeckennedy/python-switch#why-not-just-raw-dict). +But there are still things that are harder. + +* How would you deal with fall-through cleanly? +* Did you notice the bug? We forgot to set result in default case (`else`) and will result in a runtime error (but only if that case hits). +* There is another bug. Update `update_availability` will never run because it's command (`a`) is bound to two cases. +This is guarded against in switch and you would receive a duplicate case error the first time it runs at all. +* While it's pretty clear, it's much more verbose and less declarative than the switch version. +Again, compare the if / elif / else to what you have with switch. This code is identical except +doesn't have the default case bug. + +```python +while True: + action = get_action(action) + + with switch(action) as s: + s.case(['c', 'a'], create_account) + s.case('l', log_into_account) + s.case('r', register_cage) + s.case('u', update_availability) + s.case(['v', 'b'], view_bookings) + s.case('x', exit_app) + s.case('', lambda: None) + s.case(range(1,6), lambda: set_level(action)) + s.default(unknown_command) + + print('Result is {}'.format(s.result)) +``` diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..0aa42e0 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest +# twine diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..8c1e8a7 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,43 @@ +# [ruff] +line-length = 120 +format.quote-style = "single" + +# Enable Pyflakes `E` and `F` codes by default. +lint.select = ["E", "F", "I"] +lint.ignore = [] + +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".ruff_cache", + ".svn", + ".tox", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + ".env", + ".venv", + "venv", + "typings/**/*.pyi", +] +lint.per-file-ignores = { } + +# Allow unused variables when underscore-prefixed. +# dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +# Assume Python 3.13. +target-version = "py313" + +#[tool.ruff.mccabe] +## Unlike Flake8, default to a complexity level of 10. +lint.mccabe.max-complexity = 10 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f4d8418 --- /dev/null +++ b/setup.py @@ -0,0 +1,49 @@ +import io +import os +import re + +from setuptools import find_packages +from setuptools import setup + + +def read(filename): + filename = os.path.join(os.path.dirname(__file__), filename) + text_type = type('') + with io.open(filename, mode='r', encoding='utf-8') as fd: + return re.sub(text_type(r':[a-z]+:`~?(.*?)`'), text_type(r'``\1``'), fd.read()) + + +def read_version(): + filename = os.path.join(os.path.dirname(__file__), 'switchlang', '__init__.py') + with open(filename, mode='r', encoding='utf-8') as fin: + for line in fin: + if line and line.strip() and line.startswith('__version__'): + return line.split('=')[1].strip().strip("'") + + return '0.0.0.0' + + +requires = [] + +setup( + name='switchlang', + version=read_version(), + url='https://github.com/mikeckennedy/python-switch', + license='MIT', + author='Michael Kennedy', + author_email='michael@talkpython.fm', + description='Adds switch blocks to the Python language.', + long_description=read('README.md'), + long_description_content_type='text/markdown', + packages=find_packages(exclude=('tests',)), + install_requires=requires, + classifiers=[ + 'Development Status :: 4 - Beta', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + ], +) diff --git a/switchlang.py b/switchlang.py deleted file mode 100644 index bcdc3d3..0000000 --- a/switchlang.py +++ /dev/null @@ -1,47 +0,0 @@ -# Here is a first pass implementation at adding switch -from typing import Callable, Any - - -class switch: - def __init__(self, value): - self.value = value - self.cases = {} - - def default(self, func: Callable[[], Any]): - self.case('__default__', func) - - def case(self, key, func: Callable[[], Any]): - if isinstance(key, range): - for n in range(key.start, key.stop + 1, key.step): - self.case(n, func) - return - - if isinstance(key, list): - for i in key: - self.case(i, func) - return - - if key in self.cases: - raise ValueError("Duplicate case: {}".format(key)) - if not func: - raise ValueError("Action for case cannot be None.") - if not callable(func): - raise ValueError("Func must be callable.") - - self.cases[key] = func - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - if exc_val: - raise exc_val - - func = self.cases.get(self.value) - if not func: - func = self.cases.get('__default__') - - if not func: - raise Exception("Value does not match any case and there is no default case: value {}".format(self.value)) - - func() diff --git a/switchlang/__init__.py b/switchlang/__init__.py new file mode 100644 index 0000000..29a3506 --- /dev/null +++ b/switchlang/__init__.py @@ -0,0 +1,14 @@ +""" +switchlang - Adds switch blocks to Python + +See https://github.com/mikeckennedy/python-switch for full details. +Copyright Michael Kennedy (https://twitter.com/mkennedy) +License: MIT +""" + +__version__ = '0.1.1' +__author__ = 'Michael Kennedy ' +__all__ = ['switch', 'closed_range'] + +from .__switchlang_impl import switch +from .__switchlang_impl import closed_range diff --git a/switchlang/__switchlang_impl.py b/switchlang/__switchlang_impl.py new file mode 100644 index 0000000..5def359 --- /dev/null +++ b/switchlang/__switchlang_impl.py @@ -0,0 +1,154 @@ +import typing +import uuid + + +class switch: + """ + switch is a module-level implementation of the switch statement for Python. + See https://github.com/mikeckennedy/python-switch for full details. + Copyright Michael Kennedy (https://mkennedy.codes) + License: MIT + """ + + __no_result: typing.Any = uuid.uuid4() + __default: typing.Any = uuid.uuid4() + + def __init__(self, value: typing.Any): + self.value = value + self.cases: typing.Set[typing.Any] = set() + self._found = False + self.__result = switch.__no_result + self._falling_through = False + self._func_stack: typing.List[typing.Callable[[], typing.Any]] = [] + + def default(self, func: typing.Callable[[], typing.Any]): + """ + Use as option final statement in switch block. + + ``` + with switch(val) as s: + s.case(...) + s.case(...) + s.default(function) + ``` + + :param func: Any callable taking no parameters to be executed if this (default) case matches. + :return: None + """ + self.case(switch.__default, func) + + def case( + self, + key: typing.Any, + func: typing.Callable[[], typing.Any], + fallthrough: typing.Optional[bool] = False, + ): + """ + Specify a case for the switch block: + + ``` + with switch(val) as s: + s.case('a', function) + s.case('b', function, fallthrough=True) + s.default(function) + ``` + + :param key: Key for the case test (if this is a list or range, the items will each be added as a case) + :param func: Any callable taking no parameters to be executed if this case matches. + :param fallthrough: Optionally fall through to the subsequent case (defaults to False) + :return: + """ + if fallthrough is not None: + if self._falling_through: + self._func_stack.append(func) + if not fallthrough: + self._falling_through = False + + if isinstance(key, range): + key = list(key) + + if isinstance(key, list): + if not key: + raise ValueError('You cannot pass an empty collection as the case. It will never match.') + + found = False + for i in key: + if self.case(i, func, fallthrough=None): + found = True + if fallthrough is not None: + self._falling_through = fallthrough + + return found + + if key in self.cases: + raise ValueError(f'Duplicate case: {key}') + if not func: + raise ValueError('Action for case cannot be None.') + if not callable(func): + raise ValueError('Func must be callable.') + + self.cases.add(key) + if key == self.value or not self._found and key == self.__default: + self._func_stack.append(func) + self._found = True + if fallthrough is not None: + self._falling_through = fallthrough + return True + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_val: + raise exc_val + + if not self._func_stack: + raise Exception('Value does not match any case and there is no default case: value {}'.format(self.value)) + + for func in self._func_stack: + # noinspection PyCallingNonCallable + self.__result = func() + + @property + def result(self): + """ + The value captured from the method called for a given case. + + ``` + value = 4 + with switch(value) as s: + s.case(closed_range(1, 5), lambda: "1-to-5") + # ... + + res = s.result # res == '1-to-5' + ``` + + :return: The value captured from the method called for a given case. + """ + if self.__result == switch.__no_result: + raise Exception('No result has been computed (did you access switch.result inside the with block?)') + + return self.__result + + +def closed_range(start: int, stop: int, step=1) -> range: + """ + Creates a closed range that allows you to specify a case + from [start, stop] inclusively. + + ``` + with switch(value) as s: + s.case(closed_range(1, 5), lambda: "1-to-5") + s.case(closed_range(6, 7), lambda: "6") + s.default(lambda: 'default') + ``` + + :param start: The inclusive lower bound of the range [start, stop]. + :param stop: The inclusive upper bound of the range [start, stop]. + :param step: The step size between elements (defaults to 1). + :return: A range() generator that has a closed upper bound. + """ + if start >= stop: + raise ValueError('Start must be less than stop.') + + return range(start, stop + step, step) diff --git a/tests/coretests.py b/tests/coretests.py deleted file mode 100644 index 31800da..0000000 --- a/tests/coretests.py +++ /dev/null @@ -1,126 +0,0 @@ -import unittest -from switchlang import switch - - -# here is a custom type we can use as a key for our tests -class TestKeyObject: - pass - - -class CoreTests(unittest.TestCase): - def test_has_matched_case_int(self): - value = 7 - - executed_case = None - - def get_set_case(val): - nonlocal executed_case - executed_case = val - - with switch(value) as s: - s.case(1, lambda: get_set_case("one")) - s.case(5, lambda: get_set_case("five")) - s.case(7, lambda: get_set_case("seven")) - s.default(lambda: get_set_case('default')) - - self.assertEqual(executed_case, "seven") - - def test_has_matched_case_object(self): - t1 = TestKeyObject() - t2 = TestKeyObject() - t3 = TestKeyObject() - value = t2 - - executed_case = None - - def get_set_case(val): - nonlocal executed_case - executed_case = val - - with switch(value) as s: - s.case(t1, lambda: get_set_case(t1)) - s.case(t2, lambda: get_set_case(t2)) - s.case(t3, lambda: get_set_case(t3)) - s.default(lambda: get_set_case(None)) - - self.assertEqual(executed_case, t2) - - def test_default_passthrough(self): - value = 11 - executed_case = None - - def get_set_case(val): - nonlocal executed_case - executed_case = val - - with switch(value) as s: - s.case(1, lambda: get_set_case(1)) - s.case(2, lambda: get_set_case(2)) - s.default(lambda: get_set_case("default")) - - self.assertEqual(executed_case, "default") - - def test_none_as_valid_case(self): - value = None - executed_case = None - - def get_set_case(val): - nonlocal executed_case - executed_case = val - - with switch(value) as s: - s.case(1, lambda: get_set_case(1)) - s.case(None, lambda: get_set_case(None)) - s.default(lambda: get_set_case("default")) - - self.assertEqual(executed_case, None) - - def test_error_no_match_no_default(self): - with self.assertRaises(Exception): - with switch('val') as s: - s.case(1, lambda: None) - s.case(1, lambda: None) - - def test_error_duplicate_case(self): - with self.assertRaises(ValueError): - with switch('val') as s: - s.case(1, lambda: None) - s.case(1, lambda: None) - - def test_multiple_values_one_case_range(self): - value = 7 - - executed_case = None - - def get_set_case(val): - nonlocal executed_case - executed_case = val - - # I'm a little unsure what is the right way to handle this - # On one hand, reading case(range(1,5)) seems like it should - # include 1, 2, 3, 4, 5. - # But list(range(1,5)) is [1,2,3,4]. So that would be inconsistent. - # Thoughts? - # I'm going with 1,2,3,4,5 for range(1,5) for now. - with switch(value) as s: - s.case(range(1, 5), lambda: get_set_case("1-to-5")) - s.case(range(6, 7), lambda: get_set_case("6-to-7")) - s.default(lambda: get_set_case('default')) - - self.assertEqual(executed_case, "6-to-7") - - def test_multiple_values_one_case_list(self): - value = 6 - - executed_case = None - - def get_set_case(val): - nonlocal executed_case - executed_case = val - - with switch(value) as s: - s.case([1, 3, 5, 7], lambda: get_set_case("odd")) - s.case([0, 2, 4, 6, 8], lambda: get_set_case("even")) - s.default(lambda: get_set_case('default')) - - self.assertEqual(executed_case, "even") diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..bfe73f5 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,232 @@ +import unittest +from switchlang import switch, closed_range + + +# here is a custom type we can use as a key for our tests +class TestKeyObject: + pass + + +class CoreTests(unittest.TestCase): + def test_has_matched_case_int(self): + value = 7 + with switch(value) as s: + s.case(1, lambda: 'one') + s.case(5, lambda: 'five') + s.case(7, lambda: 'seven') + s.default(lambda: 'default') + + self.assertEqual(s.result, 'seven') + + def test_has_matched_case_object(self): + t1 = TestKeyObject() + t2 = TestKeyObject() + t3 = TestKeyObject() + + with switch(t2) as s: + s.case(t1, lambda: t1) + s.case(t2, lambda: t2) + s.case(t3, lambda: t3) + s.default(lambda: None) + + self.assertEqual(s.result, t2) + + def test_default_passthrough(self): + value = 11 + with switch(value) as s: + s.case(1, lambda: '1') + s.case(2, lambda: '2') + s.default(lambda: 'default') + + self.assertEqual(s.result, 'default') + + def test_none_as_valid_case(self): + with switch(None) as s: + s.case(1, lambda: 'one') + s.case(None, lambda: 'none') + s.default(lambda: 'default') + + self.assertEqual(s.result, 'none') + + def test_error_no_match_no_default(self): + with self.assertRaises(Exception): + with switch('val') as s: + s.case(1, lambda: None) + s.case(2, lambda: None) + + def test_error_duplicate_case(self): + with self.assertRaises(ValueError): + with switch('val') as s: + s.case(1, lambda: None) + s.case(1, lambda: None) + + def test_multiple_values_one_case_range(self): + for value in range(1, 5): + with switch(value) as s: + s.case(range(1, 6), lambda: '1-to-5') + s.case(range(6, 7), lambda: '6') + s.default(lambda: 'default') + + self.assertEqual(s.result, '1-to-5') + + for value in range(6, 7): + with switch(value) as s: + s.case(range(1, 6), lambda: '1-to-5') + s.case(range(6, 7), lambda: '6') + s.default(lambda: 'default') + + self.assertEqual(s.result, '6') + + with switch(7) as s: + s.case(range(1, 6), lambda: '1-to-5') + s.case(range(6, 7), lambda: '6') + s.default(lambda: 'default') + + self.assertEqual(s.result, 'default') + + def test_multiple_values_one_case_list(self): + with switch(6) as s: + s.case([1, 3, 5, 7], lambda: 'odd') + s.case([0, 2, 4, 6, 8], lambda: 'even') + s.default(lambda: 'default') + + self.assertEqual(s.result, 'even') + + def test_return_value_from_case(self): + value = 4 + with switch(value) as s: + s.case([1, 3, 5, 7], lambda: value + 1) + s.case([0, 2, 4, 6, 8], lambda: value * value) + s.default(lambda: 0) + + self.assertEqual(s.result, 16) + + # noinspection PyStatementEffect + def test_result_inaccessible_if_hasnt_run(self): + with self.assertRaises(Exception): + s = switch(7) + s.result + + def test_closed_range(self): + for value in [1, 2, 3, 4, 5]: + with switch(value) as s: + s.case(closed_range(1, 5), lambda: '1-to-5') + s.case(closed_range(6, 7), lambda: '6') + s.default(lambda: 'default') + + self.assertEqual(s.result, '1-to-5') + + with switch(0) as s: + s.case(closed_range(1, 5), lambda: '1-to-5') + s.case(closed_range(6, 7), lambda: '6') + s.default(lambda: 'default') + + self.assertEqual(s.result, 'default') + + with switch(6) as s: + s.case(closed_range(1, 5), lambda: '1-to-5') + s.case(closed_range(6, 7), lambda: '6') + s.default(lambda: 'default') + + self.assertEqual(s.result, '6') + + def test_fallthrough_simple(self): + visited = [] + value = 2 + with switch(value) as s: + s.case(1, lambda: visited.append(1) or 1) + s.case(2, lambda: visited.append(2) or 2, fallthrough=True) + s.default(lambda: visited.append('default') or 'default') + + self.assertEqual(s.result, 'default') + self.assertEqual(visited, [2, 'default']) + + def test_fallthrough_list(self): + visited = [] + value = 5 + with switch(value) as s: + s.case([1, 2, 3], lambda: visited.append(1) or 1) + s.case([4, 5, 6], lambda: visited.append(4) or 4, fallthrough=True) + s.case([7, 8, 9], lambda: visited.append(7) or 7, fallthrough=True) + s.default(lambda: visited.append('default') or 'default') + + self.assertEqual(s.result, 'default') + self.assertEqual(visited, [4, 7, 'default']) + + def test_fallthrough_some_list(self): + visited = [] + value = 5 + with switch(value) as s: + s.case([1, 2, 3], lambda: visited.append(1) or 1) + s.case([4, 5, 6], lambda: visited.append(4) or 4, fallthrough=True) + s.case([7, 8, 9], lambda: visited.append(7) or 7) + s.default(lambda: visited.append('default') or 'default') + + self.assertEqual(s.result, 7) + self.assertEqual(visited, [4, 7]) + + def test_fallthrough_then_stop(self): + visited = [] + value = 2 + with switch(value) as s: + s.case(1, lambda: visited.append(1) or 1) + s.case(2, lambda: visited.append(2) or 2, fallthrough=True) + s.case(3, lambda: visited.append(3) or 3, fallthrough=True) + s.case(4, lambda: visited.append(4) or 4) + s.case(5, lambda: visited.append(5) or 5) + s.default(lambda: visited.append('default') or 'default') + + self.assertEqual(s.result, 4) + self.assertEqual(visited, [2, 3, 4]) + + def test_fallthrough_middle_then_stop(self): + visited = [] + value = 3 + with switch(value) as s: + s.case(1, lambda: visited.append(1) or 1) + s.case(2, lambda: visited.append(2) or 2, fallthrough=True) + s.case(3, lambda: visited.append(3) or 3, fallthrough=True) + s.case(4, lambda: visited.append(4) or 4) + s.case(5, lambda: visited.append(5) or 5) + s.default(lambda: visited.append('default') or 'default') + + self.assertEqual(s.result, 4) + self.assertEqual(visited, [3, 4]) + + def test_fallthrough_available_but_not_hit(self): + visited = [] + value = 5 + with switch(value) as s: + s.case(1, lambda: visited.append(1) or 1) + s.case(2, lambda: visited.append(2) or 2, fallthrough=True) + s.case(3, lambda: visited.append(3) or 3, fallthrough=True) + s.case(4, lambda: visited.append(4) or 4) + s.case(5, lambda: visited.append(5) or 5) + s.default(lambda: visited.append('default') or 'default') + + self.assertEqual(s.result, 5) + self.assertEqual(visited, [5]) + + def test_fallthrough__no_match_but_not_hit(self): + visited = [] + value = 'gone' + with switch(value) as s: + s.case(1, lambda: visited.append(1) or 1) + s.case(2, lambda: visited.append(2) or 2, fallthrough=True) + s.case(3, lambda: visited.append(3) or 3, fallthrough=True) + s.case(4, lambda: visited.append(4) or 4) + s.case(5, lambda: visited.append(5) or 5) + s.default(lambda: visited.append('default') or 'default') + + self.assertEqual(s.result, 'default') + self.assertEqual(visited, ['default']) + + def test_empty_collection_clause_is_error(self): + with self.assertRaises(ValueError): + with switch('val') as s: + s.case([], lambda: None) + s.default(lambda: 'default') + + +if __name__ == '__main__': + unittest.main()