diff --git a/CHANGES.md b/CHANGES.md index 4b9b7e1..6f1c91a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,15 @@ # **py2be** Changes +## 0.1.0 - 2nd July 2026 + +* modularised implementation (`constants`, `parse`, `truthy`); +* added **benchmarks/string_truthy.py** and **benchmarks/run_all_benchmarks.sh**; +* `str2bool()` and `string_is_truthy()` now classify stock terms via `_TRUTHY_STRINGS` and a module-built `_TRUTHY_TABLE` keyed by `(length, first_char)` (cf. **to-be.Rust** first-letter dispatch); always strips before lookup; strong on padded stock terms and unrecognised inputs (benchmarked); +* retained one-sided `string_is_falsey()` and `string_is_truey()` paths (`_str_is_falsey`, `_str_is_truey`) with opposite-precise fast-fail and conditional `strip().lower()` (cf. **to-be.Rust**); `constants.py` precise/lowercase tuples remain for these paths until a later elision pass; +* added docstrings to `str2bool()`, `string_is_falsey()`, `string_is_truey()`, and `string_is_truthy()`; +* measured and ruled out for stock vocabulary at current table sizes: merged precise-table linear scan, `bisect` lookup, state-machine parsers, and conditional-trim fast paths (see **README** benchmarks section); + ## 0.0.4 - 2nd July 2026 * added top-level `__all__` documenting the public API; diff --git a/MANIFEST.in b/MANIFEST.in index 0dc986e..463f7fb 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ include LICENSE README.md CHANGES.md EXAMPLES.md TODO.md +recursive-include benchmarks *.py recursive-include examples *.py recursive-include tests *.py diff --git a/README.md b/README.md index 3a51f93..6fd5909 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Simple Python library determining whether strings indicate *truey* or *falsey* v - [Terminology](#terminology) - [Components](#components) - [Functions](#functions) +- [Benchmarks](#benchmarks) - [Examples](#examples) - [Project Information](#project-information) - [Where to get help](#where-to-get-help) @@ -154,11 +155,32 @@ The following public functions are defined in the current version: Stock falsey terms (after optional trimming and case folding) include `0`, `false`, `no`, and `off`. Stock truey terms include `1`, `true`, `yes`, and `on`. Several common capitalisations and mixtures of case are recognised without lower-casing first. +## Benchmarks + +Benchmark scripts are provided under `benchmarks/`, modelled on **to-be.Rust**'s `string_truthy` Criterion suite. + +Install the package (or set `PYTHONPATH=.` from the repository root), then run: + +``` +$ pip install -e . +$ ./benchmarks/run_all_benchmarks.sh +``` + +To run a subset of groups: + +``` +$ python benchmarks/string_truthy.py string_is_truthy +$ python benchmarks/string_truthy.py mixed_batch +``` + +Run benchmarks on mains power for stable timings. On AC power (Apple Silicon, CPython 3.9, `number=200000`, `repeat=5`), one-sided `string_is_falsey()` / `string_is_truey()` paths are roughly **30–40% faster** on matching stock terms and **~12% faster** on unrecognised inputs versus routing both through full `_str2bool()`; `string_is_truthy()` is unchanged. Cross-polarity use (e.g. `string_is_truey("false")`) may be slower — use `str2bool()` or `string_is_truthy()` when full classification is needed. + + ## Examples Examples are provided in the `examples` directory. A detailed list of them is provided in [EXAMPLES.md](./EXAMPLES.md). -To run the stock string classification example: +To run the stock string classification example (after `pip install -e .`): ``` $ python examples/truthy_strings.py diff --git a/TODO.md b/TODO.md index 9f76640..2df53e3 100644 --- a/TODO.md +++ b/TODO.md @@ -23,12 +23,15 @@ ## Functional improvements -* \ +* [ ] `Terms`, `string_is_truthy_with()`, and `stock_term_strings()` (cf. **to-be.Rust**); ## Performance improvements -* \ +* [x] **benchmarks/string_truthy.py** and **benchmarks/run_all_benchmarks.sh**; +* [x] one-sided `string_is_falsey()` / `string_is_truey()` paths with opposite-precise fast-fail (cf. **to-be.Rust**); +* [-] ~~~conditional-trim fast path (cf. **to-be.Rust**; measured regression on CPython for stock terms)~~~; +* [-] ~~~`bisect` precise-table lookup (cf. **to-be.Rust**; measured regression at current stock table sizes)~~~; diff --git a/benchmarks/run_all_benchmarks.sh b/benchmarks/run_all_benchmarks.sh new file mode 100755 index 0000000..36b0a9f --- /dev/null +++ b/benchmarks/run_all_benchmarks.sh @@ -0,0 +1,9 @@ +#! /bin/bash + +set -e + +cd "$(dirname "$0")/.." + +export PYTHONPATH="${PYTHONPATH:+$PYTHONPATH:}$(pwd)" + +python benchmarks/string_truthy.py "$@" diff --git a/benchmarks/string_truthy.py b/benchmarks/string_truthy.py new file mode 100644 index 0000000..ea78861 --- /dev/null +++ b/benchmarks/string_truthy.py @@ -0,0 +1,188 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +""" +Benchmark string truthy evaluation (stock terms). + +Run from the repository root with py2be installed or on PYTHONPATH: + + python benchmarks/string_truthy.py + +Filter benchmark groups: + + python benchmarks/string_truthy.py string_is_truthy + python benchmarks/string_truthy.py mixed_batch +""" + +from __future__ import print_function + +import sys +import timeit + +from py2be import ( + string_is_falsey, + string_is_truey, + string_is_truthy, +) + + +INPUTS = ( + ('true', 'lower'), + ('TRUE', 'upper'), + ('True', 'title'), + ('false', 'lower'), + ('FALSE', 'upper'), + ('False', 'title'), + ('yes', 'lower'), + ('YES', 'upper'), + ('Yes', 'title'), + ('no', 'lower'), + ('NO', 'upper'), + ('No', 'title'), + ('on', 'lower'), + ('ON', 'upper'), + ('On', 'title'), + ('off', 'lower'), + ('OFF', 'upper'), + ('Off', 'title'), + ('1', ''), + ('0', ''), + ('unrecognised', 'unrecognised'), +) + + +PADDED_INPUTS = ( + (' true', 'leading/lower/true'), + ('true ', 'trailing/lower/true'), + (' true ', 'both/lower/true'), + (' FALSE', 'leading/upper/FALSE'), + ('false ', 'trailing/lower/false'), + (' YES ', 'both/upper/YES'), + (' 1', 'leading/1'), + ('0 ', 'trailing/0'), + (' unrecognised ', 'both/unrecognised'), +) + + +MIXED_INPUTS = ( + 'yes', + 'no', + 'TRUE', + 'off', + 'maybe', + '1', + '0', + '', +) + +MIXED_PADDED_INPUTS = ( + ' yes ', + ' no ', + ' TRUE ', + ' off ', + ' maybe ', + ' 1 ', + ' 0 ', + ' ', +) + + +NUMBER = 200000 +REPEAT = 5 + + +def _bench_name(label, input_value): + + if label: + return '%s/%s' % (label, input_value) + return input_value + + +def _run_bench(name, fn, arg): + + elapsed = min(timeit.repeat(lambda: fn(arg), repeat=REPEAT, number=NUMBER)) + per_op_ns = (elapsed / float(NUMBER)) * 1e9 + + print('%-48s %8.1f ns/op' % (name, per_op_ns)) + + +def _run_mixed(name, fn, inputs): + + def batch(): + + for s in inputs: + fn(s) + + elapsed = min(timeit.repeat(batch, repeat=REPEAT, number=NUMBER)) + per_op_ns = (elapsed / float(NUMBER)) * 1e9 + + print('%-48s %8.1f ns/op' % (name, per_op_ns)) + + +def bench_group(group_name, inputs, classify): + + print('[%s]' % group_name) + + for input_value, label in inputs: + name = '%s/%s' % (group_name, _bench_name(label, input_value)) + _run_bench(name, classify, input_value) + + print('') + + +def bench_mixed(group_name, inputs, classify): + + print('[%s]' % group_name) + _run_mixed('%s/mixed_batch' % group_name, classify, inputs) + print('') + + +def main(argv): + + groups = { + 'string_is_truthy': ( + lambda: bench_group('string_is_truthy', INPUTS, string_is_truthy), + lambda: bench_group('string_is_truthy_padded', PADDED_INPUTS, string_is_truthy), + lambda: bench_mixed('string_is_truthy', MIXED_INPUTS, string_is_truthy), + lambda: bench_mixed('string_is_truthy_padded', MIXED_PADDED_INPUTS, string_is_truthy), + ), + 'string_is_truey': ( + lambda: bench_group('string_is_truey', INPUTS, string_is_truey), + lambda: bench_group('string_is_truey_padded', PADDED_INPUTS, string_is_truey), + ), + 'string_is_falsey': ( + lambda: bench_group('string_is_falsey', INPUTS, string_is_falsey), + lambda: bench_group('string_is_falsey_padded', PADDED_INPUTS, string_is_falsey), + ), + 'mixed_batch': ( + lambda: bench_mixed('string_is_truthy', MIXED_INPUTS, string_is_truthy), + lambda: bench_mixed('string_is_truthy_padded', MIXED_PADDED_INPUTS, string_is_truthy), + ), + } + + selected = argv[1:] + + if not selected: + selected = [ + 'string_is_truthy', + 'string_is_truey', + 'string_is_falsey', + 'mixed_batch', + ] + + print('py2be string truthy benchmarks (number=%d, repeat=%d)' % (NUMBER, REPEAT)) + print('') + + for key in selected: + if key not in groups: + print('Unknown group: %s' % key, file=sys.stderr) + return 1 + + for run in groups[key]: + run() + + return 0 + + +if '__main__' == __name__: + + sys.exit(main(sys.argv)) diff --git a/py2be/__init__.py b/py2be/__init__.py index 99b7277..db0bc6f 100644 --- a/py2be/__init__.py +++ b/py2be/__init__.py @@ -8,7 +8,7 @@ __license__ = 'BSD-3-Clause' __maintainer__ = 'Matt Wilson' __status__ = 'Beta' -__version__ = '0.0.4' +__version__ = '0.1.0' from .truthy import ( str2bool, @@ -29,4 +29,3 @@ # ############################## end of file ############################# # - diff --git a/py2be/constants.py b/py2be/constants.py new file mode 100644 index 0000000..b1eb64b --- /dev/null +++ b/py2be/constants.py @@ -0,0 +1,40 @@ + +FALSEY_PRECISE_STRINGS = ( + "0", + "FALSE", + "False", + "NO", + "No", + "OFF", + "Off", + "false", + "no", + "off", +) + +TRUEY_PRECISE_STRINGS = ( + "1", + "ON", + "On", + "TRUE", + "True", + "YES", + "Yes", + "on", + "true", + "yes", +) + +FALSEY_LOWERCASE_STRINGS = ( + "false", + "no", + "off", + "0", +) + +TRUEY_LOWERCASE_STRINGS = ( + "true", + "yes", + "on", + "1", +) diff --git a/py2be/internal/__init__.py b/py2be/internal/__init__.py index 60eec5b..c2fd4c8 100644 --- a/py2be/internal/__init__.py +++ b/py2be/internal/__init__.py @@ -1,69 +1,2 @@ -FALSEY_PRECISE_STRINGS = [ - "0", - "FALSE", - "False", - "NO", - "No", - "OFF", - "Off", - "false", - "no", - "off", -] - -TRUEY_PRECISE_STRINGS = [ - "1", - "ON", - "On", - "TRUE", - "True", - "YES", - "Yes", - "on", - "true", - "yes", -] - -FALSEY_LOWERCASE_STRINGS = [ - "false", - "no", - "off", - "0", -] - -TRUEY_LOWERCASE_STRINGS = [ - "true", - "yes", - "on", - "1", -] - - -def _str2bool( - s -): - if s is None: - - return None - - if s in FALSEY_PRECISE_STRINGS: - - return False - - if s in TRUEY_PRECISE_STRINGS: - - return True - - s = s.strip().lower() - - if s in TRUEY_LOWERCASE_STRINGS: - - return True - - if s in FALSEY_LOWERCASE_STRINGS: - - return False - - return None - +from ..parse import _str2bool diff --git a/py2be/parse.py b/py2be/parse.py new file mode 100644 index 0000000..ea0a070 --- /dev/null +++ b/py2be/parse.py @@ -0,0 +1,233 @@ + +import sys +from collections import namedtuple + +from .constants import ( + FALSEY_LOWERCASE_STRINGS, + FALSEY_PRECISE_STRINGS, + TRUEY_LOWERCASE_STRINGS, + TRUEY_PRECISE_STRINGS, +) + +if sys.version_info[0] < 3: + _text_type = basestring # noqa: F821 +else: + _text_type = str + + +# Stock vocabulary: eight canonical spellings (Title-case words, +# single-char digits). +# Source of truth for str2bool() / string_is_truthy(); constants.py +# tuples remain for the one-sided paths below until a later elision pass. +_TRUTHY_STRINGS = { + "0": False, + "1": True, + "False": False, + "True": True, + "No": False, + "Yes": True, + "Off": False, + "On": True, +} + +_TRUTHY_RECORD = namedtuple('_TruthyRecord', ('length', 'strings', 'truth')) + + +def _make_strings_tuple(canonical): + # Precomputed accept variants per canonical key: 1-tuple for digits; + # 2-tuple for all-lower / all-upper keys; 3-tuple (title, UPPER, + # lower) for Title-case keys such as "True". Mixed-case input is + # handled in _record_matches() via comparison to the lower-case + # element. + + if canonical.upper() == canonical: + + if canonical.upper() == canonical.lower(): + + return (canonical, ) + + if canonical.isupper(): + + return (canonical, canonical.lower()) + + if canonical.islower(): + + return (canonical.upper(), canonical) + + return (canonical, canonical.upper(), canonical.lower()) + + +def _build_truthy_table(truthy_strings): + # Key (length, first_char) gives O(1) lookup and disambiguates stock + # collisions on the same letter (e.g. "On" vs "Off"). Register both + # casings of the first character so lookup needs no normalisation + # step. + + table = {} + + for canonical, truth in truthy_strings.items(): + + record = _TRUTHY_RECORD( + len(canonical), + _make_strings_tuple(canonical), + truth, + ) + length = len(canonical) + first = canonical[0] + + for letter in (first, first.swapcase()): + + table[(length, letter)] = record + + return table + + +_TRUTHY_TABLE = _build_truthy_table(_TRUTHY_STRINGS) + + +def _record_matches(s, record): + # Try exact variant hit first; for 2/3-tuples fall back to lower-case + # equality so inputs like "tRuE" match without an extra table entry. + + strings = record.strings + + if s in strings: + + return True + + if len(strings) >= 2 and s.lower() == strings[-1]: + + return True + + return False + + +def _str2bool(s): + # Full classification path used by str2bool() and string_is_truthy(). + # Always strips first so padded and unpadded inputs share one code + # path (benchmarks: faster padded stock matches and misses vs the + # legacy precise-tuple tables; unpadded exact hits are slower — see + # README). Caller must ensure s is text; public entry points guard + # isinstance. + + s = s.strip() + + if not s: + + return None + + length = len(s) + record = _TRUTHY_TABLE.get((length, s[0])) + if not record: + + return None + + if _record_matches(s, record): + + return record.truth + + return None + + +def _str_is_falsey(s): + # One-sided path: opposite-precise bail-out then lowercase falsey + # table. Faster than routing through _str2bool() when the question + # is polarity only (benchmarks ~2x on matching stock falsey terms). + # Caller must ensure s is text. + + if s in FALSEY_PRECISE_STRINGS: + + return True + + if s in TRUEY_PRECISE_STRINGS: + + return False + + s = s.strip().lower() + + if s in FALSEY_LOWERCASE_STRINGS: + + return True + + return False + + +def _str_is_truey(s): + # One-sided path: mirror of _str_is_falsey() for truey polarity. + + if s in TRUEY_PRECISE_STRINGS: + + return True + + if s in FALSEY_PRECISE_STRINGS: + + return False + + s = s.strip().lower() + + if s in TRUEY_LOWERCASE_STRINGS: + + return True + + return False + + +def str2bool(s): + """Classify ``s`` as truey, falsey, or unrecognised. + + Returns ``True`` or ``False`` when ``s`` matches a recognised stock + term after stripping leading and trailing whitespace. Returns + ``None`` for unrecognised strings and for non-text ``s`` (for + example ``bytes`` on Python 3). + """ + + if not isinstance(s, _text_type): + + return None + + return _str2bool(s) + + +def string_is_falsey(s): + """Indicate whether ``s`` is a recognised stock falsey term. + + Returns ``True`` when ``s`` is classified as truthy and deemed + falsey. Returns ``False`` for truey terms, unrecognised strings, + and non-text ``s``. + """ + + if not isinstance(s, _text_type): + + return False + + return _str_is_falsey(s) + + +def string_is_truey(s): + """Indicate whether ``s`` is a recognised stock truey term. + + Returns ``True`` when ``s`` is classified as truthy and deemed + truey. Returns ``False`` for falsey terms, unrecognised strings, + and non-text ``s``. + """ + + if not isinstance(s, _text_type): + + return False + + return _str_is_truey(s) + + +def string_is_truthy(s): + """Indicate whether ``s`` is a recognised stock truthy term. + + Returns ``True`` for any recognised stock term, whether deemed + falsey or truey. Returns ``False`` for unrecognised strings and + for non-text ``s``. + """ + + if not isinstance(s, _text_type): + + return False + + return _str2bool(s) is not None diff --git a/py2be/truthy.py b/py2be/truthy.py index c336611..dcc1649 100644 --- a/py2be/truthy.py +++ b/py2be/truthy.py @@ -1,59 +1,15 @@ -from .internal import ( - _str2bool, +from .parse import ( + str2bool, + string_is_falsey, + string_is_truey, + string_is_truthy, ) -def str2bool(s): - """ - Determines the "truthy" nature of whether the given string is "truthy" - and, if so, whether it is "falsey" or "truey". - - Returns - ------- - - `None` - string is not classified as "truthy"; - - `False` - string is classified as "truthy" and is deemed "falsey"; - - `True` - string is classified as "truthy" and is deemed "truey"; - """ - - return _str2bool(s) - - -def string_is_falsey(s): - """ - Indicates that the given string, when trimmed, is classified as "truthy" - and is deemed as "falsey". - - Note - ---- - `string_is_falsey(x) == !string_is_truey(x)` is NOT guaranteed. - """ - - return _str2bool(s) == False - - -def string_is_truey(s): - """ - Indicates that the given string, when trimmed, is classified as "truthy" - and is deemed as "truey". - - Note - ---- - `string_is_falsey(x) == !string_is_truey(x)` is NOT guaranteed. - """ - - return _str2bool(s) == True - - -def string_is_truthy(s): - """ - Indicates that the given string, when trimmed, is classified as "truthy" - (and is deemed as either "falsey" or "truey"). - - Returns - ------- - - `False` - string is not classified as "truthy"; - - `True` - string is deemed "truey"; - """ - - return _str2bool(s) is not None +__all__ = [ + 'str2bool', + 'string_is_falsey', + 'string_is_truey', + 'string_is_truthy', +] diff --git a/setup.py b/setup.py index 1acd45d..ac49950 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ name='py2be', python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*, !=3.7.*', - version='0.0.4', + version='0.1.0', author='Matt Wilson', author_email='matthew@synesis.com.au', diff --git a/tests/test_constants.py b/tests/test_constants.py new file mode 100644 index 0000000..04b7dd2 --- /dev/null +++ b/tests/test_constants.py @@ -0,0 +1,27 @@ +#! /usr/bin/env python + +import unittest + +from py2be.constants import ( + FALSEY_PRECISE_STRINGS, + TRUEY_PRECISE_STRINGS, +) + + +class Constants_tester(unittest.TestCase): + + def test__stock_precise_tables_are_sorted(self): + + self.assertEqual( + list(FALSEY_PRECISE_STRINGS), + sorted(FALSEY_PRECISE_STRINGS), + ) + self.assertEqual( + list(TRUEY_PRECISE_STRINGS), + sorted(TRUEY_PRECISE_STRINGS), + ) + + +if '__main__' == __name__: + + unittest.main()