diff --git a/README.md b/README.md index d4a03dc..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. @@ -84,7 +99,7 @@ switch = { 3: method_three } -result = switch.get(value, defult_method_to_run)() +result = switch.get(value, default_method_to_run)() ``` This works but is very low on the functionality level. We have a better solution here @@ -110,7 +125,7 @@ while True: print('Result is {}'.format(s.result)) ``` -Now compare that to they espoused *pythonic* way: +Now compare that to the espoused *pythonic* way: ```python # with raw dicts @@ -201,4 +216,4 @@ while True: s.default(unknown_command) print('Result is {}'.format(s.result)) -``` \ No newline at end of file +``` 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/__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.py b/switchlang/__switchlang_impl.py similarity index 51% rename from switchlang.py rename to switchlang/__switchlang_impl.py index 1723fbc..5def359 100644 --- a/switchlang.py +++ b/switchlang/__switchlang_impl.py @@ -1,46 +1,57 @@ +import typing import uuid -from typing import Callable, Any class switch: """ - python-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://twitter.com/mkennedy) + 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 = uuid.uuid4() - __default = uuid.uuid4() - def __init__(self, value): + __no_result: typing.Any = uuid.uuid4() + __default: typing.Any = uuid.uuid4() + + def __init__(self, value: typing.Any): self.value = value - self.cases = set() + self.cases: typing.Set[typing.Any] = set() self._found = False self.__result = switch.__no_result self._falling_through = False - self._func_stack = [] + self._func_stack: typing.List[typing.Callable[[], typing.Any]] = [] - def default(self, func: Callable[[], Any]): + def default(self, func: typing.Callable[[], typing.Any]): """ - Use as option final statement in switch block. + 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, func: Callable[[], Any], fallthrough=False): + def case( + self, + key: typing.Any, + func: typing.Callable[[], typing.Any], + fallthrough: typing.Optional[bool] = False, + ): """ - Specify a case for the switch block: + 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. @@ -58,7 +69,7 @@ def case(self, key, func: Callable[[], Any], fallthrough=False): if isinstance(key, list): if not key: - raise ValueError("You cannot pass an empty collection as the case. It will never match.") + raise ValueError('You cannot pass an empty collection as the case. It will never match.') found = False for i in key: @@ -70,11 +81,11 @@ def case(self, key, func: Callable[[], Any], fallthrough=False): return found if key in self.cases: - raise ValueError("Duplicate case: {}".format(key)) + raise ValueError(f'Duplicate case: {key}') if not func: - raise ValueError("Action for case cannot be None.") + raise ValueError('Action for case cannot be None.') if not callable(func): - raise ValueError("Func must be callable.") + raise ValueError('Func must be callable.') self.cases.add(key) if key == self.value or not self._found and key == self.__default: @@ -92,8 +103,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): 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)) + 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 @@ -101,15 +111,44 @@ def __exit__(self, exc_type, exc_val, exc_tb): @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?)") + 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.") + raise ValueError('Start must be less than stop.') return range(start, stop + step, step) diff --git a/tests/coretests.py b/tests/test_core.py similarity index 82% rename from tests/coretests.py rename to tests/test_core.py index f343adc..bfe73f5 100644 --- a/tests/coretests.py +++ b/tests/test_core.py @@ -11,12 +11,12 @@ 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.case(1, lambda: 'one') + s.case(5, lambda: 'five') + s.case(7, lambda: 'seven') s.default(lambda: 'default') - self.assertEqual(s.result, "seven") + self.assertEqual(s.result, 'seven') def test_has_matched_case_object(self): t1 = TestKeyObject() @@ -38,13 +38,13 @@ def test_default_passthrough(self): s.case(2, lambda: '2') s.default(lambda: 'default') - self.assertEqual(s.result, "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") + s.default(lambda: 'default') self.assertEqual(s.result, 'none') @@ -63,34 +63,34 @@ def test_error_duplicate_case(self): 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.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") + 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.case(range(1, 6), lambda: '1-to-5') + s.case(range(6, 7), lambda: '6') s.default(lambda: 'default') - self.assertEqual(s.result, "6") + 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.case(range(1, 6), lambda: '1-to-5') + s.case(range(6, 7), lambda: '6') s.default(lambda: 'default') - self.assertEqual(s.result, "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.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") + self.assertEqual(s.result, 'even') def test_return_value_from_case(self): value = 4 @@ -110,25 +110,25 @@ def test_result_inaccessible_if_hasnt_run(self): 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.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") + 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.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") + 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.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") + self.assertEqual(s.result, '6') def test_fallthrough_simple(self): visited = [] @@ -138,7 +138,7 @@ def test_fallthrough_simple(self): 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(s.result, 'default') self.assertEqual(visited, [2, 'default']) def test_fallthrough_list(self): @@ -150,7 +150,7 @@ def test_fallthrough_list(self): 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(s.result, 'default') self.assertEqual(visited, [4, 7, 'default']) def test_fallthrough_some_list(self):