diff --git a/.github/workflows/workflows.yml b/.github/workflows/workflows.yml new file mode 100644 index 0000000..5ff45cd --- /dev/null +++ b/.github/workflows/workflows.yml @@ -0,0 +1,74 @@ +name: Test, Build, and Publish ๐Ÿ“ฆ + +on: + push: + branches: ["master"] + tags: ["*"] + pull_request: + branches: ["master"] + +jobs: + test: + name: Run tests ๐Ÿงช + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: pip install pytest + - name: Install package + run: pip install -e . + - name: Run tests + run: pytest tests/ -v + + build: + name: Build distribution ๐Ÿ“ฆ + runs-on: ubuntu-latest + needs: [test] + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Extract version from git tag + if: startsWith(github.ref, 'refs/tags/') + run: echo "PACKAGE_VERSION=${{ github.ref_name }}" >> $GITHUB_ENV + - name: Install pypa/build + run: python3 -m pip install build --user + - name: Build a binary wheel and a source tarball + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: Publish Python ๐Ÿ distribution ๐Ÿ“ฆ to PyPI + if: startsWith(github.ref, 'refs/tags/') + needs: [build] + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/str2bool3 + permissions: + id-token: write + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution ๐Ÿ“ฆ to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/README.md b/README.md index 001f470..87c7c4e 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,75 @@ -# str2bool v.1.1 +# str2bool3 v1.4.0 ## About -Convert string to boolean. -Library recognizes "yes", "true", "y", "t", "1" as True, and "no", "false", "n", "f", "0" as False. -Case insensitive. +Convert a string (or compatible type) to a boolean value. + +Recognized **True** values: `yes`, `true`, `t`, `y`, `1` +Recognized **False** values: `no`, `false`, `f`, `n`, `0` + +Matching is case-insensitive and leading/trailing whitespace is stripped automatically. + +`bool` and `int` inputs are also accepted directly: +- `True` / `False` โ†’ returned as-is +- `1` โ†’ `True`, `0` โ†’ `False` ## Installation - $ pip install str2bool + $ pip install str2bool3 ## Examples -Here's a basic example: - >>> from str2bool import str2bool - >>> print(str2bool('Yes')) +Basic usage: + + >>> from str2bool3 import str2bool + >>> str2bool('Yes') + True + >>> str2bool('no') + False + >>> str2bool(' TRUE ') # whitespace stripped True + >>> str2bool(True) # bool pass-through + True + >>> str2bool(1) # int support + True + +Unrecognized values return `None` by default (or a custom default): + + >>> str2bool('maybe') + None + >>> str2bool('maybe', default=False) + False + +Raise an exception for invalid input: + + >>> str2bool('maybe', raise_exc=True) + ValueError: Invalid value 'maybe'. Expected one of: 0, 1, f, false, n, no, t, true, y, yes. + +Convenience wrapper that always raises on invalid input (including `None`): + + >>> from str2bool3 import str2bool_exc + >>> str2bool_exc('invalid') + ValueError: Invalid value 'invalid'. Expected one of: 0, 1, f, false, n, no, t, true, y, yes. + >>> str2bool_exc(None) + ValueError: Cannot convert None to bool. ... + +## API + +### `str2bool(value, raise_exc=False, default=None)` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `value` | `str \| bool \| int \| None` | Value to convert | +| `raise_exc` | `bool` | Raise `ValueError` on unrecognized input (default `False`) | +| `default` | `bool \| None` | Return value when input is unrecognized (default `None`) | + +Raises `TypeError` for unsupported types (e.g. `float`, `list`). + +### `str2bool_exc(value)` + +Shorthand for `str2bool(value, raise_exc=True)`. Always raises `ValueError` +for `None` or unrecognized values. ## License BSD + +Forked from [symonsoft/str2bool](https://github.com/symonsoft/str2bool) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4f7419e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "str2bool3" +dynamic = ["version"] +description = "Convert string to boolean (Forked from SymonSoft/str2bool)" +readme = {file = "README.md", content-type = "text/markdown"} +license = {text = "BSD-3-Clause"} +authors = [ + {name = "Sam Fakhreddine", email = "sam.fakhreddine@gmail.com"} +] +keywords = ["str2bool", "bool", "boolean", "convert", "yes", "no", "true", "false"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Utilities", +] +requires-python = ">=3.8" + +[project.urls] +Homepage = "https://github.com/sam-fakhreddine/str2bool3" +Repository = "https://github.com/sam-fakhreddine/str2bool3" + +[tool.setuptools.packages.find] +where = ["."] +include = ["str2bool3*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/setup.cfg b/setup.cfg index a2f3748..20ce750 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,2 @@ [metadata] -description-file = README.md \ No newline at end of file +description_file = README.md diff --git a/setup.py b/setup.py index 4687965..3a7888a 100644 --- a/setup.py +++ b/setup.py @@ -1,24 +1,6 @@ -from distutils.core import setup - - -setup( - name='str2bool', - packages=['str2bool'], - version='1.1', - description='Convert string to boolean', - author='SymonSoft', - author_email='symonsoft@gmail.com', - url='https://github.com/symonsoft/str2bool', - download_url='https://github.com/symonsoft/str2bool/tarball/1.1', - keywords=['str2bool', 'bool', 'boolean', 'convert', 'yes', 'no', 'true', 'false'], - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Topic :: Utilities' - ], -) +import os +from setuptools import setup + +# Single source of truth: PACKAGE_VERSION env var (set from git tag in CI), +# falling back to the default development version. +setup(version=os.environ.get('PACKAGE_VERSION', '1.4.0')) diff --git a/str2bool/__init__.py b/str2bool/__init__.py deleted file mode 100644 index 4dc0017..0000000 --- a/str2bool/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -import sys - -_true_set = {'yes', 'true', 't', 'y', '1'} -_false_set = {'no', 'false', 'f', 'n', '0'} - - -def str2bool(value, raise_exc=False): - if isinstance(value, str) or sys.version_info[0] < 3 and isinstance(value, basestring): - value = value.lower() - if value in _true_set: - return True - if value in _false_set: - return False - - if raise_exc: - raise ValueError('Expected "%s"' % '", "'.join(_true_set | _false_set)) - return None - - -def str2bool_exc(value): - return str2bool(value, raise_exc=True) diff --git a/str2bool3/__init__.py b/str2bool3/__init__.py new file mode 100644 index 0000000..b390a07 --- /dev/null +++ b/str2bool3/__init__.py @@ -0,0 +1,3 @@ +from .str_utils import FALSE_VALUES, TRUE_VALUES, str2bool, str2bool_exc + +__all__ = ["str2bool", "str2bool_exc", "TRUE_VALUES", "FALSE_VALUES"] diff --git a/str2bool3/str_utils.py b/str2bool3/str_utils.py new file mode 100644 index 0000000..25d470d --- /dev/null +++ b/str2bool3/str_utils.py @@ -0,0 +1,92 @@ +from typing import Optional, Union + +TRUE_VALUES: frozenset = frozenset({'yes', 'true', 't', 'y', '1', 'on', 'enabled'}) +FALSE_VALUES: frozenset = frozenset({'no', 'false', 'f', 'n', '0', 'off', 'disabled'}) + +_VALID: str = ', '.join(sorted(TRUE_VALUES | FALSE_VALUES)) +_MISSING = object() # sentinel: value was not recognized + +_Input = Optional[Union[str, bool, int]] + + +def _parse(value: _Input) -> object: + """Coerce *value* to True, False, or _MISSING. + + Raises TypeError for unsupported types so callers never have to. + """ + # bool must come before int โ€” bool is a subclass of int + if isinstance(value, bool): + return value + + if isinstance(value, int): + if value == 1: + return True + if value == 0: + return False + return _MISSING + + if value is None: + return _MISSING + + if not isinstance(value, str): + raise TypeError( + f"Expected str, bool, int, or None; got {type(value).__name__!r}." + ) + + normalized = value.strip().lower() + if normalized in TRUE_VALUES: + return True + if normalized in FALSE_VALUES: + return False + return _MISSING + + +def str2bool( + value: _Input, + raise_exc: bool = False, + default: Optional[bool] = None, +) -> Optional[bool]: + """Convert a value to bool. + + Recognized True: yes, true, t, y, 1, on, enabled + Recognized False: no, false, f, n, 0, off, disabled + + Matching is case-insensitive; leading/trailing whitespace is stripped. + bool inputs are passed through; int 1/0 map to True/False. + + Args: + value: Input to convert. Accepts str, bool, int, or None. + raise_exc: Raise ValueError for unrecognized values (including None). + default: Returned when value is unrecognized and raise_exc is False. + + Returns: + True, False, or default. + + Raises: + TypeError: If value is not str, bool, int, or None. + ValueError: If value is unrecognized and raise_exc is True. + """ + result = _parse(value) + if result is not _MISSING: + return result # type: ignore[return-value] + + if raise_exc: + if value is None: + raise ValueError(f"Cannot convert None to bool. Expected one of: {_VALID}.") + if isinstance(value, int): + raise ValueError(f"Invalid integer {value!r}. Expected 0 or 1.") + raise ValueError(f"Invalid value {value!r}. Expected one of: {_VALID}.") + + return default + + +def str2bool_exc(value: _Input) -> bool: + """Convert to bool, raising ValueError on any invalid input (including None). + + Shorthand for ``str2bool(value, raise_exc=True)``. + + Raises: + TypeError: If value is not str, bool, int, or None. + ValueError: If value is None or unrecognized. + """ + return str2bool(value, raise_exc=True) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_str_utils.py b/tests/test_str_utils.py new file mode 100644 index 0000000..2a3afdd --- /dev/null +++ b/tests/test_str_utils.py @@ -0,0 +1,297 @@ +import pytest +from str2bool3 import FALSE_VALUES, TRUE_VALUES, str2bool, str2bool_exc + + +# --------------------------------------------------------------------------- +# Public constants +# --------------------------------------------------------------------------- + +class TestValueSets: + def test_true_values_is_frozenset(self): + assert isinstance(TRUE_VALUES, frozenset) + + def test_false_values_is_frozenset(self): + assert isinstance(FALSE_VALUES, frozenset) + + def test_true_and_false_sets_are_disjoint(self): + assert TRUE_VALUES.isdisjoint(FALSE_VALUES) + + def test_true_values_contains_expected(self): + assert {'yes', 'true', 't', 'y', '1', 'on', 'enabled'} <= TRUE_VALUES + + def test_false_values_contains_expected(self): + assert {'no', 'false', 'f', 'n', '0', 'off', 'disabled'} <= FALSE_VALUES + + +# --------------------------------------------------------------------------- +# str2bool โ€” true values +# --------------------------------------------------------------------------- + +class TestTrueValues: + @pytest.mark.parametrize("value", [ + "yes", "YES", "Yes", "yEs", + "true", "True", "TRUE", "tRuE", + "t", "T", + "y", "Y", + "1", + "on", "ON", "On", + "enabled", "ENABLED", "Enabled", + ]) + def test_string_true(self, value): + assert str2bool(value) is True + + def test_bool_true_passthrough(self): + assert str2bool(True) is True + + def test_int_one(self): + assert str2bool(1) is True + + +# --------------------------------------------------------------------------- +# str2bool โ€” false values +# --------------------------------------------------------------------------- + +class TestFalseValues: + @pytest.mark.parametrize("value", [ + "no", "NO", "No", "nO", + "false", "False", "FALSE", "fAlSe", + "f", "F", + "n", "N", + "0", + "off", "OFF", "Off", + "disabled", "DISABLED", "Disabled", + ]) + def test_string_false(self, value): + assert str2bool(value) is False + + def test_bool_false_passthrough(self): + assert str2bool(False) is False + + def test_int_zero(self): + assert str2bool(0) is False + + +# --------------------------------------------------------------------------- +# str2bool โ€” whitespace handling +# --------------------------------------------------------------------------- + +class TestWhitespace: + def test_leading_space_true(self): + assert str2bool(" yes") is True + + def test_trailing_space_true(self): + assert str2bool("yes ") is True + + def test_both_spaces_true(self): + assert str2bool(" yes ") is True + + def test_tab_whitespace_true(self): + assert str2bool("\tyes\t") is True + + def test_leading_space_false(self): + assert str2bool(" no") is False + + def test_trailing_space_false(self): + assert str2bool("no ") is False + + def test_both_spaces_false(self): + assert str2bool(" no ") is False + + def test_whitespace_around_on(self): + assert str2bool(" on ") is True + + def test_whitespace_around_disabled(self): + assert str2bool(" disabled ") is False + + +# --------------------------------------------------------------------------- +# str2bool โ€” None and defaults +# --------------------------------------------------------------------------- + +class TestNoneAndDefaults: + def test_none_returns_none_by_default(self): + assert str2bool(None) is None + + def test_none_with_custom_default_true(self): + assert str2bool(None, default=True) is True + + def test_none_with_custom_default_false(self): + assert str2bool(None, default=False) is False + + def test_invalid_string_returns_none_by_default(self): + assert str2bool("maybe") is None + + def test_invalid_string_with_custom_default(self): + assert str2bool("maybe", default=False) is False + + def test_empty_string_returns_none(self): + assert str2bool("") is None + + def test_empty_string_with_default(self): + assert str2bool("", default=True) is True + + def test_whitespace_only_returns_none(self): + assert str2bool(" ") is None + + def test_whitespace_only_tab_newline_returns_none(self): + assert str2bool("\t\n") is None + + def test_whitespace_only_with_default(self): + assert str2bool(" ", default=False) is False + + def test_whitespace_only_raises_when_raise_exc(self): + with pytest.raises(ValueError, match="Expected one of"): + str2bool(" ", raise_exc=True) + + def test_invalid_int_returns_none(self): + assert str2bool(2) is None + + def test_invalid_int_with_default(self): + assert str2bool(2, default=False) is False + + +# --------------------------------------------------------------------------- +# str2bool โ€” raise_exc=True +# --------------------------------------------------------------------------- + +class TestRaiseExc: + def test_valid_true_no_exception(self): + assert str2bool("yes", raise_exc=True) is True + + def test_valid_false_no_exception(self): + assert str2bool("no", raise_exc=True) is False + + def test_on_no_exception(self): + assert str2bool("on", raise_exc=True) is True + + def test_disabled_no_exception(self): + assert str2bool("disabled", raise_exc=True) is False + + def test_bool_true_no_exception(self): + assert str2bool(True, raise_exc=True) is True + + def test_bool_false_no_exception(self): + assert str2bool(False, raise_exc=True) is False + + def test_int_one_no_exception(self): + assert str2bool(1, raise_exc=True) is True + + def test_int_zero_no_exception(self): + assert str2bool(0, raise_exc=True) is False + + def test_none_raises_value_error(self): + with pytest.raises(ValueError, match="Cannot convert None to bool"): + str2bool(None, raise_exc=True) + + def test_invalid_string_raises_value_error(self): + with pytest.raises(ValueError): + str2bool("maybe", raise_exc=True) + + def test_empty_string_raises_value_error(self): + with pytest.raises(ValueError): + str2bool("", raise_exc=True) + + def test_invalid_int_raises_value_error(self): + with pytest.raises(ValueError): + str2bool(2, raise_exc=True) + + def test_error_message_contains_invalid_value(self): + with pytest.raises(ValueError, match="maybe"): + str2bool("maybe", raise_exc=True) + + def test_error_message_mentions_expected_values(self): + with pytest.raises(ValueError, match="Expected one of"): + str2bool("nope", raise_exc=True) + + def test_none_error_mentions_expected_values(self): + with pytest.raises(ValueError, match="Expected one of"): + str2bool(None, raise_exc=True) + + def test_invalid_int_error_mentions_zero_one(self): + with pytest.raises(ValueError, match="0 or 1"): + str2bool(2, raise_exc=True) + + +# --------------------------------------------------------------------------- +# str2bool โ€” TypeError for unsupported types +# --------------------------------------------------------------------------- + +class TestTypeError: + @pytest.mark.parametrize("value", [ + 1.0, + [], + {}, + object(), + b"yes", + ]) + def test_unsupported_type_raises_type_error(self, value): + with pytest.raises(TypeError): + str2bool(value) + + def test_type_error_message_includes_type_name(self): + with pytest.raises(TypeError, match="float"): + str2bool(1.0) + + +# --------------------------------------------------------------------------- +# str2bool_exc +# --------------------------------------------------------------------------- + +class TestStr2BoolExc: + def test_valid_true(self): + assert str2bool_exc("yes") is True + + def test_valid_false(self): + assert str2bool_exc("no") is False + + def test_on(self): + assert str2bool_exc("on") is True + + def test_off(self): + assert str2bool_exc("off") is False + + def test_enabled(self): + assert str2bool_exc("enabled") is True + + def test_disabled(self): + assert str2bool_exc("disabled") is False + + def test_bool_true_passthrough(self): + assert str2bool_exc(True) is True + + def test_bool_false_passthrough(self): + assert str2bool_exc(False) is False + + def test_int_one(self): + assert str2bool_exc(1) is True + + def test_int_zero(self): + assert str2bool_exc(0) is False + + def test_none_raises(self): + with pytest.raises(ValueError): + str2bool_exc(None) + + def test_invalid_string_raises(self): + with pytest.raises(ValueError): + str2bool_exc("invalid") + + def test_invalid_int_raises(self): + with pytest.raises(ValueError): + str2bool_exc(2) + + def test_whitespace_true(self): + assert str2bool_exc(" yes ") is True + + def test_whitespace_false(self): + assert str2bool_exc(" no ") is False + + def test_whitespace_on(self): + assert str2bool_exc(" on ") is True + + def test_whitespace_disabled(self): + assert str2bool_exc(" disabled ") is False + + def test_unsupported_type_raises_type_error(self): + with pytest.raises(TypeError): + str2bool_exc(1.0)