diff --git a/.gitignore b/.gitignore index b8704d6..cfabf9c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ pip-log.txt .mypy_cache/ .venv/ .envrc +.build diff --git a/README.md b/README.md index c0bc887..b3abf7e 100644 --- a/README.md +++ b/README.md @@ -533,6 +533,51 @@ optional arguments after: >>> ``` +### Organizing pipes more effectively using classes + +The `@Pipe` decorator isn't just for functions-it also works with classes. You can use it with instance methods, class methods, and static methods to better structure your code while keeping it pipeable. + +1. Using an Instance Method + +```python +>>> class Factor: +... def __init__(self, n: int): +... self.n = n +... @Pipe +... def mul(self, iterable): +... return (x * self.n for x in iterable) +>>> fact = Factor(10) +>>> list([1, 2, 3] | fact.mul) +[10, 20, 30] +>>> +``` + +2. Using a Class Method + +```python +>>> class Factor: +... n: int = 10 +... @Pipe +... @classmethod +... def mul(cls, iterable): +... return (x * cls.n for x in iterable) +>>> list([1, 2, 3] | Factor.mul) +[10, 20, 30] +>>> +``` + +3. Using a Static Method + +```python +>>> class Factor: +... @Pipe +... @staticmethod +... def mul(iterable): +... return (x * 10 for x in iterable) +>>> list([1, 2, 3] | Factor.mul) +[10, 20, 30] +>>> +``` ## One-off pipes diff --git a/pipe.py b/pipe.py index 42ba7e9..1af26e4 100644 --- a/pipe.py +++ b/pipe.py @@ -5,53 +5,95 @@ __credits__ = """Jérôme Schneider for teaching me the Python datamodel, and all contributors.""" +import builtins import functools import itertools import socket import sys -from contextlib import closing from collections import deque -import builtins +from contextlib import closing class Pipe: """ - Represent a Pipeable Element : - Described as : - first = Pipe(lambda iterable: next(iter(iterable))) - and used as : - print [1, 2, 3] | first - printing 1 - - Or represent a Pipeable Function : - It's a function returning a Pipe - Described as : - select = Pipe(lambda iterable, pred: (pred(x) for x in iterable)) - and used as : - print [1, 2, 3] | select(lambda x: x * 2) - # 2, 4, 6 + Pipe class enable a sh like infix syntax. + + This class allows you to create a pipeline of operations by chaining functions + together using the `|` operator. It wraps a function and its arguments, enabling + you to apply the function to an input in a clean and readable manner. + + Examples + -------- + Create a new Pipe operation: + + >>> from pipe import Pipe + >>> @Pipe + ... def double(iterable): + ... return (x * 2 for x in iterable) + + Use the Pipe operation: + + >>> result = [1, 2, 3] | double + >>> list(result) + [2, 4, 6] + + Notes + ----- + ... + """ def __init__(self, function, *args, **kwargs): - self.function = lambda iterable, *args2, **kwargs2: function( - iterable, *args, *args2, **kwargs, **kwargs2 - ) + self.args = args + self.kwargs = kwargs + self.function = function functools.update_wrapper(self, function) def __ror__(self, other): - return self.function(other) + """ + Implement the reverse pipe operator (`|`) for the object. + + Parameters + ---------- + other : Any + The left-hand operand of the `|` operator. + + Returns + ------- + Any + The result of applying the stored function to `other` with the + provided arguments and keyword arguments. + + """ + return self.function(other, *self.args, **self.kwargs) def __call__(self, *args, **kwargs): return Pipe( - lambda iterable, *args2, **kwargs2: self.function( - iterable, *args, *args2, **kwargs, **kwargs2 - ) + self.function, + *self.args, + *args, + **self.kwargs, + **kwargs, + ) + + def __repr__(self) -> str: + return "piped::<%s>(*%s, **%s)" % ( + self.function.__name__, + self.args, + self.kwargs, + ) + + def __get__(self, instance, owner=None): + return Pipe( + function=self.function.__get__(instance, owner), + *self.args, + **self.kwargs, ) @Pipe def take(iterable, qte): - "Yield qte of elements in the given iterable." + """Yield qte of elements in the given iterable.""" if not qte: return for item in iterable: @@ -63,13 +105,13 @@ def take(iterable, qte): @Pipe def tail(iterable, qte): - "Yield qte of elements in the given iterable." + """Yield qte of elements in the given iterable.""" return deque(iterable, maxlen=qte) @Pipe def skip(iterable, qte): - "Skip qte elements in the given iterable, then yield others." + """Skip qte elements in the given iterable, then yield others.""" for item in iterable: if qte == 0: yield item @@ -213,8 +255,3 @@ def batched(iterable, n): chain_with = Pipe(itertools.chain) islice = Pipe(itertools.islice) izip = Pipe(zip) - -if __name__ == "__main__": - import doctest - - doctest.testfile("README.md") diff --git a/pyproject.toml b/pyproject.toml index a5477d6..07c648b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,31 +6,117 @@ build-backend = "setuptools.build_meta" name = "pipe" description = "Module enabling a sh like infix syntax (using pipes)" readme = "README.md" -license = {text = "MIT License"} +license = { text = "MIT License" } dynamic = ["version"] -authors = [ - {name = "Julien Palard", email = "julien@palard.fr"}, -] +authors = [{ name = "Julien Palard", email = "julien@palard.fr" }] classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers", + "Development Status :: 5 - Production/Stable", "Operating System :: OS Independent", "Topic :: Software Development", "Topic :: Software Development :: Libraries :: Python Modules", ] requires-python = ">= 3.8" +keywords = ["pipe"] [project.urls] repository = "https://github.com/JulienPalard/Pipe" +[dependency-groups] +ruff = ["ruff>=0.10.0"] +coverage = ["coverage[toml]", "covdefaults"] +dev = [{ include-group = "ruff" }, "pre-commit>=2.21.0", "tox>=4.23.2"] +test = [{ include-group = "coverage" }, "mock", "pytest", "pytest-cov"] + [tool.setuptools] -py-modules = [ - "pipe", -] +py-modules = ["pipe"] include-package-data = false +dynamic.version.attr = "pipe.__version__" -[tool.setuptools.dynamic.version] -attr = "pipe.__version__" +[tool.ruff] +lint.pydocstyle.convention = "numpy" +lint.extend-select = ["C", "D", "FURB", "I", "N", "PL", "PTH", "SIM"] +lint.extend-ignore = [ + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D102", # + "D103", # Missing docstring in public function + "D105", +] + +[tool.coverage] +html.show_contexts = true +html.skip_covered = false +run.parallel = true +run.omit = [ + "tests/*.py", + # add others +] +run.plugins = [ + # all plugins + "covdefaults", +] + + +[tool.tox] +requires = ["tox>=4.23.2", "tox-uv>=1.13"] +isolated_build = true +env_list = ["ruff", "cov-report", "py38", "py39", "py311", "py312", "py313"] + +[tool.tox.env_run_base] +commands = [ + [ + "coverage", + "run", + "-m", + "pytest", + "--doctest-glob=README.md", + "--junit-xml=.build/tests/pytest_{env_name}_junit.xml", + ], +] +set_env.COVERAGE_FILE = "{toxworkdir}/.coverage" +dependency_groups = ["test"] +package = "wheel" +wheel_build_env = ".pkg" + +[tool.tox.env.ruff] +recreate = false +skip_install = true +dependency_groups = ["ruff"] +commands = [ + # commands + ["ruff", "format", "--check", "."], + ["ruff", "check", "."], +] + +[tool.tox.env.cov-report] +parallel_show_output = true +recreate = false +set_env.COVERAGE_FILE = "{toxworkdir}/.coverage" +skip_install = true +dependency_groups = ["coverage"] +depends = ["py38", "py39", "py311", "py312", "py313"] +commands = [ + [ + "coverage", + "combine", + ], + [ + "coverage", + "html", + "-d", + ".build/coverage", + ], + [ + "coverage", + "report", + ], +] diff --git a/tests/test_pipe.py b/tests/test_pipe.py index 8c51980..7ce18dc 100644 --- a/tests/test_pipe.py +++ b/tests/test_pipe.py @@ -39,3 +39,69 @@ def test_enumerate(): data = [4, "abc", {"key": "value"}] expected = [(5, 4), (6, "abc"), (7, {"key": "value"})] assert list(data | pipe.enumerate(start=5)) == expected + + +def test_class_support_on_methods(): + class Factory: + n = 10 + + @pipe.Pipe + def mul(self, iterable): + return (x * self.n for x in iterable) + + assert list([1, 2, 3] | Factory().mul) == [10, 20, 30] + + +def test_class_support_on_static_methods(): + class TenFactory: + @pipe.Pipe + @staticmethod + def mul(iterable): + return (x * 10 for x in iterable) + + assert list([1, 2, 3] | TenFactory.mul) == [10, 20, 30] + + +def test_class_support_on_class_methods(): + class Factory: + n = 10 + + @pipe.Pipe + @classmethod + def mul(cls, iterable): + return (x * cls.n for x in iterable) + + assert list([1, 2, 3] | Factory.mul) == [10, 20, 30] + + Factory.n = 2 + assert list([1, 2, 3] | Factory.mul) == [2, 4, 6] + + obj = Factory() + assert list([1, 2, 3] | obj.mul) == [2, 4, 6] + + +def test_class_support_with_named_parameter(): + class Factory: + @pipe.Pipe + @staticmethod + def mul(iterable, factor=None): + return (x * factor for x in iterable) + + assert list([1, 2, 3] | Factory.mul(factor=5)) == [5, 10, 15] + + +def test_pipe_repr(): + @pipe.Pipe + def sample_pipe(iterable): + return (x * 2 for x in iterable) + + assert repr(sample_pipe) == "piped::(*(), **{})" + + @pipe.Pipe + def sample_pipe_with_args(iterable, factor): + return (x * factor for x in iterable) + + pipe_instance = sample_pipe_with_args(3) + real_repr = repr(pipe_instance) + assert "piped::(" in real_repr + assert "3" in real_repr diff --git a/tox.ini b/tox.ini deleted file mode 100644 index e40dd4c..0000000 --- a/tox.ini +++ /dev/null @@ -1,54 +0,0 @@ -[flake8] -max-line-length = 88 - -[coverage:run] -branch = true -parallel = true -omit = - .tox/* - tests/* - -[coverage:report] -skip_covered = True -show_missing = True -exclude_lines = - if __name__ == .__main__.: - -[tox] -envlist = py3{8,9,10,11,12,13}, flake8, black, pylint, coverage -isolated_build = True - -[testenv] -deps = - pytest - coverage - hypothesis -commands = coverage run -m pytest --doctest-glob="README.md" -setenv = - COVERAGE_FILE={toxworkdir}/.coverage.{envname} - -[testenv:coverage] -deps = coverage -depends = py38, py39, py310, py311, py312, py313 -parallel_show_output = True -skip_install = True -setenv = COVERAGE_FILE={toxworkdir}/.coverage -commands = - coverage combine - coverage report --fail-under 100 - -[testenv:flake8] -deps = - flake8 - flake8-bugbear -skip_install = True -commands = flake8 tests/ pipe.py - -[testenv:black] -deps = black -skip_install = True -commands = black --check --diff tests/ pipe.py - -[testenv:pylint] -deps = pylint -commands = pylint --disable=missing-function-docstring,redefined-builtin,invalid-name pipe.py