pax_global_header00006660000000000000000000000064151674077540014532gustar00rootroot0000000000000052 comment=b0f77dff771b94861e10b8a82b52196d3ef698fd banal-1.1.2/000077500000000000000000000000001516740775400126105ustar00rootroot00000000000000banal-1.1.2/.bumpversion.cfg000066400000000000000000000003061516740775400157170ustar00rootroot00000000000000[bumpversion] current_version = 1.1.2 tag_name = {new_version} commit = True tag = True [bumpversion:file:pyproject.toml] search = version = "{current_version}" replace = version = "{new_version}" banal-1.1.2/.github/000077500000000000000000000000001516740775400141505ustar00rootroot00000000000000banal-1.1.2/.github/dependabot.yml000066400000000000000000000003721516740775400170020ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: daily time: "04:00" open-pull-requests-limit: 100 - package-ecosystem: github-actions directory: "/" schedule: interval: weekly banal-1.1.2/.github/workflows/000077500000000000000000000000001516740775400162055ustar00rootroot00000000000000banal-1.1.2/.github/workflows/build.yml000066400000000000000000000021241516740775400200260ustar00rootroot00000000000000name: build on: [push] permissions: id-token: write jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | pip install -e '.[dev]' - name: Validate typing run: | mypy --strict banal - name: Run tests run: | pytest tests/ publish: runs-on: ubuntu-latest needs: test if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') steps: - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.x' - name: Build a distribution run: | pip install build python3 -m build --wheel - name: Publish a Python distribution to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: skip-existing: true banal-1.1.2/.gitignore000066400000000000000000000022271516740775400146030ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class .vscode .DS_Store # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ banal-1.1.2/CLAUDE.md000066400000000000000000000026231516740775400140720ustar00rootroot00000000000000# banal Python micro-utilities library for buffering type uncertainties. Pure standard library — no external dependencies allowed. ## Development ```bash # Install with dev deps pip install -e ".[dev]" # Type checking (strict) make typecheck # or: mypy --strict banal # Build distribution make build # or: python3 -m build ``` ## PERFORMANCE IS CRITICAL This library is called in tight inner loops across many downstream projects. Every unnecessary branch, type check, or import adds real cost. When making changes: - Minimize the number of `isinstance` checks and conditional branches. - Don't add a guard for a case that existing checks already exclude. - Prefer the cheapest check that covers the needed cases. - Benchmark before and after if you're unsure. ## Key constraints - **No external dependencies.** Only Python standard library imports. - **Strict mypy typing.** All code must pass `mypy --strict`. - **Python 3.10+** compatibility required. ## Release Uses `bumpversion` for versioning. Publishing to PyPI is automatic on tagged pushes via GitHub Actions. ## Modules - `lists.py` — sequence utilities (`ensure_list`, `unique_list`, `chunked_iter`, etc.) - `dicts.py` — mapping utilities (`ensure_dict`, `clean_dict`, `keys_values`) - `cache.py` — hashing (`hash_data`, `bytes_iter`) - `bools.py` — boolean coercion (`as_bool`) - `filesystem.py` — path decoding (`decode_path`) banal-1.1.2/LICENSE000066400000000000000000000020721516740775400136160ustar00rootroot00000000000000MIT License Copyright (c) 2017-2025 Friedrich Lindenberg Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. banal-1.1.2/Makefile000066400000000000000000000004371516740775400142540ustar00rootroot00000000000000 all: check test: pytest -v tests typecheck: mypy --strict banal check: typecheck test clean: rm -rf dist build .eggs find . -name '*.egg-info' -exec rm -fr {} + find . -name '*.egg' -exec rm -f {} + find . -name '*.pyc' -exec rm -f {} + find . -name '*.pyo' -exec rm -f {} + banal-1.1.2/README.md000066400000000000000000000012601516740775400140660ustar00rootroot00000000000000# banal Commons of Python micro-functions. This basically an out-sourced, shared utils module with a focus on functions that buffer type uncertainties in Python (e.g. "is this a list?"). Rules: * Functions are properly typed, library passes `mypy`. * Cannot depend on anything but the standard library ## Functions * ``is_listish``: check if something is list-ish * ``is_mapping``: check if an object is dict-ish * ``ensure_list``: make sure an argument is a list, or make it into a single-element list * ``clean_dict``: remove null values from a dict, recursively * ``decode_path``: decode a path name to be unicode * ``hash_data``: generate a SHA1 from a dict of reasonable objectsbanal-1.1.2/banal/000077500000000000000000000000001516740775400136655ustar00rootroot00000000000000banal-1.1.2/banal/__init__.py000066400000000000000000000011741516740775400160010ustar00rootroot00000000000000from banal.lists import is_sequence, is_listish from banal.lists import ensure_list, unique_list from banal.lists import first, chunked_iter, chunked_iter_sets from banal.dicts import is_mapping, clean_dict from banal.dicts import ensure_dict, keys_values from banal.filesystem import decode_path from banal.cache import hash_data from banal.bools import as_bool __all__ = [ "is_sequence", "is_listish", "ensure_list", "unique_list", "chunked_iter", "chunked_iter_sets", "first", "as_bool", "is_mapping", "clean_dict", "ensure_dict", "keys_values", "decode_path", "hash_data", ] banal-1.1.2/banal/bools.py000066400000000000000000000005511516740775400153560ustar00rootroot00000000000000from typing import Any BOOL_TRUEISH = ["1", "yes", "y", "t", "true", "on", "enabled"] def as_bool(value: Any, default: bool = False) -> bool: if isinstance(value, bool): return value if value is None: return default value = str(value).strip().lower() if not len(value): return default return value in BOOL_TRUEISH banal-1.1.2/banal/cache.py000066400000000000000000000034351516740775400153070ustar00rootroot00000000000000from hashlib import sha1 from itertools import chain from typing import Any, Iterable from datetime import date, datetime from banal.dicts import is_mapping from banal.lists import is_sequence HASH_ENCODING = "utf-8" def bytes_iter(obj: Any) -> Iterable[bytes]: """Recursively decompose an object into an iterator of byte strings. Handles None (yields nothing), str, bytes, date/datetime, mappings (sorted by key), sequences (sorted when possible), callables (by __name__), and falls back to str() for everything else.""" if obj is None: return elif isinstance(obj, bytes): yield obj elif isinstance(obj, str): yield obj.encode(HASH_ENCODING) elif isinstance(obj, (date, datetime)): yield obj.isoformat().encode(HASH_ENCODING) elif is_mapping(obj): if None in obj: yield from bytes_iter(obj[None]) for key in sorted(k for k in obj.keys() if k is not None): for out in chain(bytes_iter(key), bytes_iter(obj[key])): yield out elif is_sequence(obj): if isinstance(obj, (list, set)): try: obj = sorted(obj) except Exception: pass for item in obj: for out in bytes_iter(item): yield out elif hasattr(obj, "__name__"): yield obj.__name__.encode(HASH_ENCODING) else: yield str(obj).encode(HASH_ENCODING) def hash_data(obj: Any) -> str: """Generate a deterministic SHA1 hex digest from a complex object. Key order in mappings and element order in sortable sequences are normalized, so structurally equivalent objects produce the same hash.""" collect = sha1() for data in bytes_iter(obj): collect.update(data) return collect.hexdigest() banal-1.1.2/banal/dicts.py000066400000000000000000000033621516740775400153510ustar00rootroot00000000000000from typing import ( Any, Dict, List, TypeGuard, TypeVar, overload, ) from collections.abc import Mapping from banal.lists import is_sequence, ensure_list K = TypeVar("K") V = TypeVar("V") def is_mapping(obj: Any) -> TypeGuard[Mapping[Any, Any]]: return isinstance(obj, Mapping) @overload def ensure_dict(obj: Mapping[K, V]) -> Dict[K, V]: pass @overload def ensure_dict(obj: Any) -> Dict[Any, Any]: pass def ensure_dict(obj: Any) -> Dict[Any, Any]: """Normalize uncertain input into a dict. Mappings (and objects with an ``items`` method) are converted via dict(obj.items()). Everything else returns an empty dict.""" # hasattr fallback: legacy compat for dict-likes that aren't Mapping if is_mapping(obj) or hasattr(obj, "items"): return dict(obj.items()) return {} def clean_dict(data: Any) -> Any: """Remove None-valued keys from a dictionary, recursively. Also filters None values from nested sequences. Non-dict, non-sequence values pass through unchanged.""" if isinstance(data, Mapping): out = {} for k, v in data.items(): if v is not None: out[k] = clean_dict(v) return out elif is_sequence(data): return [clean_dict(d) for d in data if d is not None] return data def keys_values(data: Dict[K, V], *keys: K) -> List[V]: """Look up one or more keys in a dict, returning all found values as a flat list. Each value is passed through ensure_list, so scalar values are wrapped and None values are dropped.""" values: List[V] = [] if isinstance(data, Mapping): for key in keys: if key in data: values.extend(ensure_list(data[key])) return values banal-1.1.2/banal/filesystem.py000066400000000000000000000004701516740775400164240ustar00rootroot00000000000000import sys from typing import Optional def decode_path(file_path: Optional[str]) -> Optional[str]: """Turn a path name into unicode.""" if file_path is None: return None if isinstance(file_path, bytes): file_path = file_path.decode(sys.getfilesystemencoding()) return file_path banal-1.1.2/banal/lists.py000066400000000000000000000075531516740775400154070ustar00rootroot00000000000000from typing import overload, Optional, Union, List, Set, FrozenSet, Tuple, Any from typing import TypeVar, Generator, Sequence, Iterable, TypeGuard from collections.abc import MappingView, Mapping T = TypeVar("T") def is_sequence(obj: Any) -> bool: return isinstance(obj, Sequence) and not isinstance(obj, (str, bytes)) def is_listish( obj: Any, ) -> TypeGuard[Union[List[Any], Tuple[Any, ...], Set[Any], FrozenSet[Any]]]: """Check if something is an iterable collection of items. Returns True for list, tuple, set, frozenset, dict views, and other Sequence types. Returns False for str, bytes, mappings, generators, and scalars.""" if isinstance(obj, (list, tuple, set, frozenset, MappingView)): return True return is_sequence(obj) def unique_list(lst: Iterable[T]) -> List[T]: """Remove duplicates from an iterable, retaining order of first appearance. Uses dict.fromkeys for hashable items (O(n)). Falls back to a linear scan for unhashable items like lists or dicts.""" try: return list(dict.fromkeys(lst)) except TypeError: # unhashable items — fall back to linear scan uniq: List[T] = [] for item in lst: if item not in uniq: uniq.append(item) return uniq @overload def ensure_list(obj: str) -> List[str]: pass @overload def ensure_list(obj: bytes) -> List[bytes]: pass @overload def ensure_list(obj: List[T]) -> List[T]: pass @overload def ensure_list(obj: Tuple[T, ...]) -> List[T]: pass @overload def ensure_list(obj: Set[T]) -> List[T]: pass @overload def ensure_list(obj: FrozenSet[T]) -> List[T]: pass @overload def ensure_list(obj: Iterable[T]) -> List[T]: pass @overload def ensure_list(obj: T) -> List[T]: pass def ensure_list(obj: Any) -> List[Any]: """Normalize uncertain input into a list. None returns []. Collections (list, tuple, set, frozenset, dict views, etc.) are converted to a list. Strings, bytes, dicts, and all other values are wrapped as a single-element list.""" if obj is None: return [] if isinstance(obj, Iterable) and not isinstance(obj, (str, bytes, Mapping)): return list(obj) return [obj] def chunked_iter( iterable: Iterable[T], batch_size: int = 500 ) -> Generator[List[T], None, None]: """Yield successive lists of up to ``batch_size`` items from an iterable. The final batch may be shorter. Raises ValueError if batch_size < 1.""" if batch_size < 1: raise ValueError("batch_size must be at least 1") batch = list() for item in iterable: batch.append(item) if len(batch) >= batch_size: yield batch batch = list() if len(batch) > 0: yield batch def chunked_iter_sets( iterable: Iterable[T], batch_size: int = 500 ) -> Generator[Set[T], None, None]: """Yield successive sets of up to ``batch_size`` unique items from an iterable. Duplicates within a batch reduce the set size without triggering a new batch. The final batch may be smaller. Raises ValueError if batch_size < 1.""" if batch_size < 1: raise ValueError("batch_size must be at least 1") batch = set() for item in iterable: batch.add(item) if len(batch) >= batch_size: yield batch batch = set() if len(batch) > 0: yield batch @overload def first(lst: None) -> None: pass @overload def first(lst: Sequence[T]) -> Optional[T]: pass @overload def first(lst: T) -> T: pass def first(lst: Any) -> Any: """Return the first non-None element, or None if empty. Input is passed through ensure_list, so None returns None, scalars return themselves, and sequences are searched for the first non-None item.""" for item in ensure_list(lst): if item is not None: return item return None banal-1.1.2/banal/py.typed000066400000000000000000000000001516740775400153520ustar00rootroot00000000000000banal-1.1.2/pyproject.toml000066400000000000000000000016011516740775400155220ustar00rootroot00000000000000[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "banal" version = "1.1.2" description = "Commons of banal micro-functions for Python." readme = "README.md" license = { text = "MIT" } authors = [{ name = "Friedrich Lindenberg", email = "friedrich@pudo.org" }] classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] keywords = ["utilities", "commons", "functions"] requires-python = ">=3.10" [project.urls] Homepage = "http://github.com/pudo/banal" [project.optional-dependencies] dev = ["mypy", "build", "wheel", "pytest"] [tool.hatch.build.targets.wheel] packages = ["banal"] banal-1.1.2/tests/000077500000000000000000000000001516740775400137525ustar00rootroot00000000000000banal-1.1.2/tests/__init__.py000066400000000000000000000000001516740775400160510ustar00rootroot00000000000000banal-1.1.2/tests/test_bools.py000066400000000000000000000024311516740775400165010ustar00rootroot00000000000000from banal.bools import as_bool def test_as_bool_true(): assert as_bool(True) is True def test_as_bool_false(): assert as_bool(False) is False def test_as_bool_none_default_false(): assert as_bool(None) is False def test_as_bool_none_default_true(): assert as_bool(None, default=True) is True def test_as_bool_truthy_strings(): for val in ["1", "yes", "y", "t", "true", "on", "enabled"]: assert as_bool(val) is True, f"expected True for {val!r}" def test_as_bool_truthy_strings_case_insensitive(): for val in ["Yes", "YES", "True", "TRUE", "ON"]: assert as_bool(val) is True, f"expected True for {val!r}" def test_as_bool_truthy_strings_whitespace(): assert as_bool(" yes ") is True def test_as_bool_falsy_strings(): for val in ["0", "no", "false", "off", "disabled", "nope"]: assert as_bool(val) is False, f"expected False for {val!r}" def test_as_bool_empty_string_default_false(): assert as_bool("") is False def test_as_bool_empty_string_default_true(): assert as_bool("", default=True) is True def test_as_bool_int_one(): assert as_bool(1) is True def test_as_bool_int_zero(): assert as_bool(0) is False def test_as_bool_int_other(): # "42" is not in BOOL_TRUEISH assert as_bool(42) is False banal-1.1.2/tests/test_cache.py000066400000000000000000000037501516740775400164330ustar00rootroot00000000000000from datetime import date, datetime from banal.cache import bytes_iter, hash_data # bytes_iter def test_bytes_iter_none(): assert list(bytes_iter(None)) == [] def test_bytes_iter_string(): assert list(bytes_iter("hello")) == [b"hello"] def test_bytes_iter_bytes(): assert list(bytes_iter(b"hello")) == [b"hello"] def test_bytes_iter_int(): assert list(bytes_iter(42)) == [b"42"] def test_bytes_iter_date(): d = date(2024, 1, 15) assert list(bytes_iter(d)) == [b"2024-01-15"] def test_bytes_iter_datetime(): dt = datetime(2024, 1, 15, 12, 30, 0) assert list(bytes_iter(dt)) == [b"2024-01-15T12:30:00"] def test_bytes_iter_list(): # lists get sorted result = list(bytes_iter([2, 1, 3])) assert result == [b"1", b"2", b"3"] def test_bytes_iter_dict(): # dicts iterate sorted keys then values result = list(bytes_iter({"b": 2, "a": 1})) assert result == [b"a", b"1", b"b", b"2"] def test_bytes_iter_nested(): result = list(bytes_iter({"key": [1, 2]})) assert result == [b"key", b"1", b"2"] def test_bytes_iter_dict_not_mutated(): d = {None: "x", "a": 1} list(bytes_iter(d)) assert None in d def test_bytes_iter_function(): def my_func(): pass result = list(bytes_iter(my_func)) assert result == [b"my_func"] # hash_data def test_hash_data_deterministic(): assert hash_data({"a": 1}) == hash_data({"a": 1}) def test_hash_data_different_values(): assert hash_data({"a": 1}) != hash_data({"a": 2}) def test_hash_data_key_order_irrelevant(): assert hash_data({"a": 1, "b": 2}) == hash_data({"b": 2, "a": 1}) def test_hash_data_none(): result = hash_data(None) # SHA1 of empty input assert isinstance(result, str) assert len(result) == 40 def test_hash_data_string(): result = hash_data("hello") assert isinstance(result, str) assert len(result) == 40 def test_hash_data_returns_hex(): result = hash_data("test") int(result, 16) # should not raise banal-1.1.2/tests/test_dicts.py000066400000000000000000000050331516740775400164720ustar00rootroot00000000000000from collections import OrderedDict from banal.dicts import is_mapping, ensure_dict, clean_dict, keys_values # is_mapping def test_is_mapping_dict(): assert is_mapping({"a": 1}) is True def test_is_mapping_ordered_dict(): assert is_mapping(OrderedDict()) is True def test_is_mapping_list(): assert is_mapping([1, 2]) is False def test_is_mapping_none(): assert is_mapping(None) is False def test_is_mapping_string(): assert is_mapping("hello") is False # ensure_dict def test_ensure_dict_dict(): assert ensure_dict({"a": 1}) == {"a": 1} def test_ensure_dict_ordered_dict(): od = OrderedDict([("a", 1), ("b", 2)]) assert ensure_dict(od) == {"a": 1, "b": 2} def test_ensure_dict_none(): assert ensure_dict(None) == {} def test_ensure_dict_list(): assert ensure_dict([1, 2]) == {} def test_ensure_dict_string(): assert ensure_dict("hello") == {} def test_ensure_dict_int(): assert ensure_dict(42) == {} # clean_dict def test_clean_dict_removes_none_values(): assert clean_dict({"a": 1, "b": None}) == {"a": 1} def test_clean_dict_recursive(): data = {"a": {"b": None, "c": 2}, "d": None} assert clean_dict(data) == {"a": {"c": 2}} def test_clean_dict_cleans_lists(): data = {"a": [1, None, 3]} assert clean_dict(data) == {"a": [1, 3]} def test_clean_dict_empty(): assert clean_dict({}) == {} def test_clean_dict_no_none(): assert clean_dict({"a": 1, "b": 2}) == {"a": 1, "b": 2} def test_clean_dict_all_none(): assert clean_dict({"a": None, "b": None}) == {} def test_clean_dict_non_dict_passthrough(): assert clean_dict(42) == 42 assert clean_dict("hello") == "hello" def test_clean_dict_nested_list_of_dicts(): data = {"items": [{"a": 1, "b": None}, {"c": None}]} assert clean_dict(data) == {"items": [{"a": 1}, {}]} # keys_values def test_keys_values_single_key(): assert keys_values({"a": 1}, "a") == [1] def test_keys_values_missing_key(): assert keys_values({"a": 1}, "b") == [] def test_keys_values_fallback_keys(): assert keys_values({"b": 2}, "a", "b") == [2] def test_keys_values_multiple_matching_keys(): assert keys_values({"a": 1, "b": 2}, "a", "b") == [1, 2] def test_keys_values_list_value(): assert keys_values({"a": [1, 2]}, "a") == [1, 2] def test_keys_values_non_mapping(): assert keys_values("hello", "a") == [] def test_keys_values_empty_dict(): assert keys_values({}, "a") == [] def test_keys_values_none_value(): result = keys_values({"a": None}, "a") assert result == [] banal-1.1.2/tests/test_filesystem.py000066400000000000000000000004501516740775400175460ustar00rootroot00000000000000from banal.filesystem import decode_path def test_decode_path_none(): assert decode_path(None) is None def test_decode_path_string(): assert decode_path("/tmp/test.txt") == "/tmp/test.txt" def test_decode_path_unicode(): assert decode_path("/tmp/café.txt") == "/tmp/café.txt" banal-1.1.2/tests/test_lists.py000066400000000000000000000144261516740775400165300ustar00rootroot00000000000000import pytest from banal.lists import ( is_sequence, is_listish, ensure_list, unique_list, chunked_iter, chunked_iter_sets, first, ) # is_sequence def test_is_sequence_list(): assert is_sequence([1, 2]) is True def test_is_sequence_tuple(): assert is_sequence((1, 2)) is True def test_is_sequence_string(): assert is_sequence("hello") is False def test_is_sequence_bytes(): assert is_sequence(b"hello") is False def test_is_sequence_int(): assert is_sequence(42) is False def test_is_sequence_none(): assert is_sequence(None) is False def test_is_sequence_dict(): assert is_sequence({"a": 1}) is False def test_is_sequence_set(): # set is not a Sequence assert is_sequence({1, 2}) is False def test_is_sequence_range(): assert is_sequence(range(5)) is True # is_listish def test_is_listish_list(): assert is_listish([1, 2]) is True def test_is_listish_tuple(): assert is_listish((1, 2)) is True def test_is_listish_set(): assert is_listish({1, 2}) is True def test_is_listish_string(): assert is_listish("hello") is False def test_is_listish_bytes(): assert is_listish(b"hello") is False def test_is_listish_dict(): assert is_listish({"a": 1}) is False def test_is_listish_int(): assert is_listish(42) is False def test_is_listish_none(): assert is_listish(None) is False def test_is_listish_generator(): assert is_listish(x for x in [1]) is False def test_is_listish_range(): assert is_listish(range(5)) is True def test_is_listish_frozenset(): assert is_listish(frozenset({1, 2})) is True def test_is_listish_dict_keys(): assert is_listish({"a": 1, "b": 2}.keys()) is True def test_is_listish_dict_values(): assert is_listish({"a": 1, "b": 2}.values()) is True def test_is_listish_dict_items(): assert is_listish({"a": 1}.items()) is True def test_is_listish_empty_list(): assert is_listish([]) is True def test_is_listish_empty_set(): assert is_listish(set()) is True # ensure_list def test_ensure_list_none(): assert ensure_list(None) == [] def test_ensure_list_list(): assert ensure_list([1, 2, 3]) == [1, 2, 3] def test_ensure_list_tuple(): assert ensure_list((1, 2)) == [1, 2] def test_ensure_list_set(): result = ensure_list({1}) assert result == [1] def test_ensure_list_string(): assert ensure_list("hello") == ["hello"] def test_ensure_list_bytes(): assert ensure_list(b"hello") == [b"hello"] def test_ensure_list_int(): assert ensure_list(42) == [42] def test_ensure_list_dict(): d = {"a": 1} assert ensure_list(d) == [d] def test_ensure_list_empty_list(): assert ensure_list([]) == [] def test_ensure_list_nested_list(): assert ensure_list([[1, 2], [3]]) == [[1, 2], [3]] def test_ensure_list_bool(): assert ensure_list(True) == [True] def test_ensure_list_zero(): assert ensure_list(0) == [0] def test_ensure_list_empty_string(): assert ensure_list("") == [""] def test_ensure_list_frozenset(): result = ensure_list(frozenset({1})) assert result == [1] def test_ensure_list_dict_keys(): result = ensure_list({"a": 1, "b": 2}.keys()) assert set(result) == {"a", "b"} def test_ensure_list_dict_values(): result = ensure_list({"a": 1, "b": 2}.values()) assert set(result) == {1, 2} # unique_list def test_unique_list_duplicates(): assert unique_list([1, 2, 2, 3, 1]) == [1, 2, 3] def test_unique_list_no_duplicates(): assert unique_list([1, 2, 3]) == [1, 2, 3] def test_unique_list_empty(): assert unique_list([]) == [] def test_unique_list_preserves_order(): assert unique_list([3, 1, 2, 1, 3]) == [3, 1, 2] def test_unique_list_strings(): assert unique_list(["a", "b", "a"]) == ["a", "b"] def test_unique_list_unhashable(): assert unique_list([[1], [2], [1]]) == [[1], [2]] def test_unique_list_single(): assert unique_list([1]) == [1] # chunked_iter def test_chunked_iter_exact_batches(): result = list(chunked_iter([1, 2, 3, 4], batch_size=2)) assert result == [[1, 2], [3, 4]] def test_chunked_iter_remainder(): result = list(chunked_iter([1, 2, 3, 4, 5], batch_size=2)) assert result == [[1, 2], [3, 4], [5]] def test_chunked_iter_empty(): assert list(chunked_iter([], batch_size=2)) == [] def test_chunked_iter_single_batch(): result = list(chunked_iter([1, 2], batch_size=10)) assert result == [[1, 2]] def test_chunked_iter_batch_size_one(): result = list(chunked_iter([1, 2, 3], batch_size=1)) assert result == [[1], [2], [3]] def test_chunked_iter_generator_input(): result = list(chunked_iter(range(5), batch_size=2)) assert result == [[0, 1], [2, 3], [4]] def test_chunked_iter_zero_batch_size(): with pytest.raises(ValueError): list(chunked_iter([1, 2], batch_size=0)) def test_chunked_iter_negative_batch_size(): with pytest.raises(ValueError): list(chunked_iter([1, 2], batch_size=-1)) # chunked_iter_sets def test_chunked_iter_sets_exact_batches(): result = list(chunked_iter_sets([1, 2, 3, 4], batch_size=2)) assert result == [{1, 2}, {3, 4}] def test_chunked_iter_sets_remainder(): result = list(chunked_iter_sets([1, 2, 3, 4, 5], batch_size=2)) assert result == [{1, 2}, {3, 4}, {5}] def test_chunked_iter_sets_empty(): assert list(chunked_iter_sets([], batch_size=2)) == [] def test_chunked_iter_sets_duplicates_shrink_batch(): # Duplicates within a batch don't count toward batch_size result = list(chunked_iter_sets([1, 1, 1, 2, 3], batch_size=2)) assert result == [{1, 2}, {3}] def test_chunked_iter_sets_zero_batch_size(): with pytest.raises(ValueError): list(chunked_iter_sets([1, 2], batch_size=0)) def test_chunked_iter_sets_negative_batch_size(): with pytest.raises(ValueError): list(chunked_iter_sets([1, 2], batch_size=-1)) # first def test_first_basic(): assert first([1, 2, 3]) == 1 def test_first_skips_none(): assert first([None, None, 3]) == 3 def test_first_all_none(): assert first([None, None]) is None def test_first_empty(): assert first([]) is None def test_first_none_input(): assert first(None) is None def test_first_string_input(): assert first("hello") == "hello" def test_first_int_input(): assert first(42) == 42