diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index a6bf655..48fe7e0 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -16,7 +16,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install setuptools wheel twine - name: Build and publish env: TWINE_USERNAME: __token__ diff --git a/linq/linq.py b/linq/linq.py index 5adf4ac..c1d495c 100644 --- a/linq/linq.py +++ b/linq/linq.py @@ -1,9 +1,10 @@ import sys -from contextlib import suppress from itertools import islice, groupby, takewhile, dropwhile, zip_longest from typing import Generator, Iterable, Callable, Iterator, TypeVar, Generic, List, Optional, Tuple, Any, cast, Final +from more_itertools import first, interleave_longest, last, chunked, unique_everseen + PYTHON_VERSION: Final[Tuple[int, int]] = sys.version_info[:2] T = TypeVar('T') @@ -127,7 +128,7 @@ def to_list(self) -> List[T]: """ return list(self.iterable) - def first_or_default(self, default: Optional[T] = None) -> Optional[T]: + def first(self, default: Optional[T] = None) -> Optional[T]: """ Returns the first element of the iterable or the default value if the iterable is empty. @@ -148,9 +149,9 @@ def first_or_default(self, default: Optional[T] = None) -> Optional[T]: >>> print(result) 42 """ - return next(iter(self.iterable), default) + return first(iter(self.iterable), default=default) - def last_or_default(self, default: Optional[T] = None) -> Optional[T]: + def last(self, default: Optional[T] = None) -> Optional[T]: """ Returns the last element of the iterable or the default value if the iterable is empty. @@ -171,9 +172,7 @@ def last_or_default(self, default: Optional[T] = None) -> Optional[T]: >>> print(result) 42 """ - with suppress(StopIteration): - return next(reversed(list(self.iterable)), default) - return default + return last(iter(self.iterable), default=default) def any(self, predicate: Callable[[T], bool] = lambda x: True) -> bool: """ @@ -376,43 +375,108 @@ def batch(self, size: int) -> 'Linq[Tuple[T, ...]]': [(1, 2), (3, 4), (5, 6)] """ - def batch_generator(iterable: Iterable[T], size: int) -> Generator[Tuple[T, ...], None, None]: - """ - Generates batches of elements from an iterable. + if PYTHON_VERSION < (3, 12): + from more_itertools import batched + else: + from itertools import batched - Args: - iterable (Iterable[T]): The iterable to generate batches from. - size (int): The size of each batch. + return Linq(batched(self.iterable, size)) - Yields: - Generator[Tuple[T, ...], None, None]: A generator that yields batches of elements as tuples. + def chunk_into(self, size: int, strict: bool = False) -> 'Linq[List[T]]': + """ + Chunks the iterable into lists of the specified size. - Raises: - ValueError: If size is less than or equal to 0. + Args: + size (int): The size of each chunk. + strict (bool, optional): If True, raises an error if the iterable cannot be evenly divided into chunks of the specified size. Defaults to False. - Example: - >>> numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] - >>> for batch in batch_generator(numbers, 3): - ... print(batch) - (1, 2, 3) - (4, 5, 6) - (7, 8, 9) - (10,) - """ - if size <= 0: - raise ValueError("Size must be greater than 0.") - it: Iterator[T] = iter(iterable) - while batch := tuple(islice(it, size)): - yield batch + Returns: + Linq[List[T]]: A new Linq object containing the chunked lists. - if PYTHON_VERSION < (3, 12): - batcher = batch_generator + """ + return Linq(chunked(self.iterable, size, strict)) + + def consecutive_pairs(self) -> 'Linq[Tuple[T, T]]': + """ + Returns an iterable of consecutive pairs of elements. + + Returns: + Linq[Tuple[T, T]]: A new Linq object with consecutive pairs of elements. + + Example: + >>> linq = Linq([1, 2, 3, 4]) + >>> result = linq.consecutive_pairs().to_list() + >>> print(result) + [(1, 2), (2, 3), (3, 4)] + """ + if PYTHON_VERSION < (3, 10): + from more_itertools import pairwise else: - from itertools import batched + from itertools import pairwise + return Linq(pairwise(self.iterable)) - batcher = batched + def unique_seen(self, key: Optional[Callable[[T], Any]] = None) -> 'Linq[T]': + """ + Returns unique elements in the order they are first seen, based on a specified key function. + + Args: + key (Optional[Callable[[T], Any]]): A function that takes an element as input and returns a value + to be compared for uniqueness. Defaults to None, meaning the elements themselves are compared. - return Linq(batcher(self.iterable, size)) + Returns: + Linq[T]: A new Linq object with unique elements in the order they were first seen. + + Examples: + + # Example 1: Unique elements based on their length + >>> linq = Linq(['apple', 'banana', 'pear', 'apricot', 'peach']) + >>> result = linq.unique_seen(key=len).to_list() + >>> print(result) + ['apple', 'banana', 'apricot'] + + # Example 2: Unique elements based on the first character + >>> linq = Linq(['apple', 'banana', 'avocado', 'blueberry', 'cherry']) + >>> result = linq.unique_seen(key=lambda x: x[0]).to_list() + >>> print(result) + ['apple', 'banana', 'cherry'] + + # Example 3: Unique elements based on a dictionary attribute + >>> linq = Linq([ + ... {'name': 'apple', 'color': 'red'}, + ... {'name': 'banana', 'color': 'yellow'}, + ... {'name': 'cherry', 'color': 'red'}, + ... {'name': 'pear', 'color': 'green'} + ... ]) + >>> result = linq.unique_seen(key=lambda x: x['color']).to_list() + >>> print(result) + [{'name': 'apple', 'color': 'red'}, {'name': 'banana', 'color': 'yellow'}, {'name': 'pear', 'color': 'green'}] + + # Example 4: Unique elements ignoring case sensitivity + >>> linq = Linq(['Apple', 'banana', 'apple', 'Banana', 'CHERRY']) + >>> result = linq.unique_seen(key=lambda x: x.lower()).to_list() + >>> print(result) + ['Apple', 'banana', 'CHERRY'] + """ + return Linq(unique_everseen(self.iterable, key=key)) + + def interleave_with(self, *others: Iterable) -> 'Linq[T]': + """ + Interleaves the elements of the iterable with the elements of other iterables, filling with None + if one iterable is shorter. + + Args: + *others (Iterable[T]): Other iterables to interleave with. + + Returns: + Linq[T]: A new Linq object with interleaved elements. + + Example: + >>> linq = Linq([1, 2, 3]) + >>> result = linq.interleave_with(['a', 'b'], ['x', 'y', 'z']).to_list() + >>> print(result) + [1, 'a', 'x', 2, 'b', 'y', 3, None, 'z'] + """ + return Linq(interleave_longest(self.iterable, *others)) def __iter__(self) -> Iterator[T]: """ diff --git a/requirements.txt b/requirements.txt index 1ea4d0e..918089f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,34 +1 @@ -build==1.2.1 -certifi==2024.7.4 -cffi==1.16.0 -charset-normalizer==3.3.2 -cryptography==43.0.0 -docutils==0.21.2 -idna==3.7 -importlib_metadata==8.2.0 -jaraco.classes==3.4.0 -jaraco.context==5.3.0 -jaraco.functools==4.0.2 -jeepney==0.8.0 -keyring==25.3.0 -markdown-it-py==3.0.0 -mdurl==0.1.2 -more-itertools==10.3.0 -nh3==0.2.18 -packaging==24.1 -pkginfo==1.10.0 -pycparser==2.22 -Pygments==2.18.0 -pyproject_hooks==1.1.0 -readme_renderer==44.0 -requests==2.32.3 -requests-toolbelt==1.0.0 -rfc3986==2.0.0 -rich==13.7.1 -SecretStorage==3.3.3 -setuptools==72.1.0 -setuptools-scm==8.1.0 -twine==5.1.1 -urllib3==2.2.2 -wheel==0.44.0 -zipp==3.19.2 +more-itertools==10.4.0 diff --git a/setup.py b/setup.py index eaa51e1..7408c32 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,9 @@ with open('README.md', 'r', encoding='utf-8') as fh: long_description = fh.read() -DESCRIPTION: Final[str] = 'A LINQ-like library for Python inspired by C# LINQ using itertools internally.' +DESCRIPTION: Final[str] = ( + 'A LINQ-like library for Python inspired by C# LINQ using itertools and more-itertools internally.' +) setup( name='linq-tool', @@ -22,5 +24,6 @@ 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', ], - python_requires='>=3.7', + python_requires='>=3.8', + install_requires=['more-itertools'], ) diff --git a/tests/test_linq.py b/tests/test_linq.py index d90e889..8d3f714 100644 --- a/tests/test_linq.py +++ b/tests/test_linq.py @@ -33,20 +33,20 @@ def test_skip(self) -> None: def test_first_or_default(self) -> None: linq = Linq([1, 2, 3]) - result = linq.first_or_default() + result = linq.first() self.assertEqual(result, 1) empty_linq = Linq([]) - result = empty_linq.first_or_default(42) + result = empty_linq.first(42) self.assertEqual(result, 42) def test_last_or_default(self) -> None: linq = Linq([1, 2, 3]) - result = linq.last_or_default() + result = linq.last() self.assertEqual(result, 3) empty_linq = Linq([]) - result = empty_linq.last_or_default(42) + result = empty_linq.last(42) self.assertEqual(result, 42) def test_any(self) -> None: @@ -108,6 +108,26 @@ def test_batch(self) -> None: result = linq.batch(2).to_list() self.assertEqual(result, [(1, 2), (3, 4), (5,)]) + def test_chunk_into(self) -> None: + linq = Linq([1, 2, 3, 4, 5]) + result = linq.chunk_into(2).to_list() + self.assertEqual(result, [[1, 2], [3, 4], [5]]) + + def test_consecutive_pairs(self) -> None: + linq = Linq([1, 2, 3, 4]) + result = linq.consecutive_pairs().to_list() + self.assertEqual(result, [(1, 2), (2, 3), (3, 4)]) + + def test_unique_seen(self) -> None: + linq = Linq([1, 2, 2, 3, 3, 3]) + result = linq.unique_seen().to_list() + self.assertEqual(result, [1, 2, 3]) + + def test_interleave_with(self) -> None: + linq = Linq([1, 2, 3]) + result = linq.interleave_with(['a', 'b', 'c']).to_list() + self.assertEqual(result, [1, 'a', 2, 'b', 3, 'c']) + if __name__ == '__main__': unittest.main()