From dcae347420186cb171183f10a4c7d3f3c68ac28d Mon Sep 17 00:00:00 2001 From: Panaintescu Adrian Valentin Date: Sun, 8 Dec 2024 19:56:39 +0200 Subject: [PATCH 1/4] support for working with class --- README.md | 49 +++++++++++++++++++++++++++++++++++++++ pipe.py | 58 ++++++++++++++++++++++++++++++++++++++++------ tests/test_pipe.py | 28 ++++++++++++++++++++++ 3 files changed, 128 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c0bc887..846a8b4 100644 --- a/README.md +++ b/README.md @@ -533,6 +533,55 @@ optional arguments after: >>> ``` +### Organizing using class + +You can also organize pipes using classes as shown below: + +>>> class Factor: +... n: int = 10 +... +... 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] + +Supported on classmethod + +>>> class Factor: +... n: int = 10 +... +... def __init__(self, n: int): +... self.n = n +... +... @Pipe +... @classmethod +... def mul(cls, iterable): +... return (x * cls.n for x in iterable) +... +>>> list([1, 2, 3] | Factor.mul) +[10, 20, 30] + +Supported on classmethod like functions + +>>> class Factor: +... n: int = 10 +... +... def __init__(self, n: int): +... self.n = n +... +... @Pipe +... def mul(cls, iterable): +... return (x * cls.n 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..39d7fd5 100644 --- a/pipe.py +++ b/pipe.py @@ -8,12 +8,28 @@ import functools import itertools import socket +import inspect import sys from contextlib import closing from collections import deque import builtins +def _is_a_classmethod(func): + """ + Check if a given function is a class method. + + Args: + func (function): The function to check. + + Returns: + bool: True if the function is a class method, False otherwise. + """ + signature = inspect.signature(func) + parameters = list(signature.parameters.values()) + return len(parameters) > 0 and parameters[0].name == "cls" + + class Pipe: """ Represent a Pipeable Element : @@ -33,21 +49,49 @@ class Pipe: """ 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 + self.instance = None functools.update_wrapper(self, function) def __ror__(self, other): - return self.function(other) + bound_args = [] if self.instance is None else [self.instance] + return self.function(*bound_args, 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): + if instance is None: + if owner is None: # pragma: no cover + return self + if isinstance(self.function, classmethod): + self.instance = owner + self.function = self.function.__func__.__get__(None, owner) + return self + if _is_a_classmethod(self.function): + # function is like a classmethod, but not a classmethod + # only the first argument is the class, name it cls + self.instance = owner + return self # pragma: no cover + return self + self.instance = instance + return self + @Pipe def take(iterable, qte): diff --git a/tests/test_pipe.py b/tests/test_pipe.py index 8c51980..647f1fc 100644 --- a/tests/test_pipe.py +++ b/tests/test_pipe.py @@ -39,3 +39,31 @@ 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(): + 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_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 From e89994ba516c6df334c522e4834ee627bff8021c Mon Sep 17 00:00:00 2001 From: Panaintescu Adrian Valentin Date: Fri, 14 Mar 2025 18:39:05 +0200 Subject: [PATCH 2/4] find bound lookup & update tox configuration With this commit I also include usage of the ruff as linter and formatter --- README.md | 21 ++++++--- pipe.py | 104 +++++++++++++++++++++---------------------- pyproject.toml | 117 +++++++++++++++++++++++++++++++++++++++++++------ tox.ini | 54 ----------------------- 4 files changed, 167 insertions(+), 129 deletions(-) delete mode 100644 tox.ini diff --git a/README.md b/README.md index 846a8b4..1efa5e9 100644 --- a/README.md +++ b/README.md @@ -537,6 +537,9 @@ optional arguments after: You can also organize pipes using classes as shown below: +1. As a `method` method + +```python >>> class Factor: ... n: int = 10 ... @@ -550,9 +553,12 @@ You can also organize pipes using classes as shown below: >>> fact = Factor(10) >>> list([1, 2, 3] | fact.mul) [10, 20, 30] +>>> +``` -Supported on classmethod +1. As a `classmethod` +```python >>> class Factor: ... n: int = 10 ... @@ -566,22 +572,25 @@ Supported on classmethod ... >>> list([1, 2, 3] | Factor.mul) [10, 20, 30] +>>> +``` Supported on classmethod like functions +```py >>> class Factor: -... n: int = 10 -... ... def __init__(self, n: int): ... self.n = n ... ... @Pipe -... def mul(cls, iterable): -... return (x * cls.n for x in iterable) +... @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 39d7fd5..da89ede 100644 --- a/pipe.py +++ b/pipe.py @@ -5,47 +5,42 @@ __credits__ = """Jérôme Schneider for teaching me the Python datamodel, and all contributors.""" +import builtins import functools import itertools import socket -import inspect import sys -from contextlib import closing from collections import deque -import builtins +from contextlib import closing -def _is_a_classmethod(func): +class Pipe: """ - Check if a given function is a class method. + Pipe class enable a sh like infix syntax. - Args: - func (function): The function to check. + 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. - Returns: - bool: True if the function is a class method, False otherwise. - """ - signature = inspect.signature(func) - parameters = list(signature.parameters.values()) - return len(parameters) > 0 and parameters[0].name == "cls" + 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 + ----- + ... -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 """ def __init__(self, function, *args, **kwargs): @@ -56,8 +51,22 @@ def __init__(self, function, *args, **kwargs): functools.update_wrapper(self, function) def __ror__(self, other): - bound_args = [] if self.instance is None else [self.instance] - return self.function(*bound_args, other, *self.args, **self.kwargs) + """ + 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( @@ -75,27 +84,17 @@ def __repr__(self) -> str: self.kwargs, ) - def __get__(self, instance, owner): - if instance is None: - if owner is None: # pragma: no cover - return self - if isinstance(self.function, classmethod): - self.instance = owner - self.function = self.function.__func__.__get__(None, owner) - return self - if _is_a_classmethod(self.function): - # function is like a classmethod, but not a classmethod - # only the first argument is the class, name it cls - self.instance = owner - return self # pragma: no cover - return self - self.instance = instance - return self + 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: @@ -107,13 +106,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 @@ -257,8 +256,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..87fd456 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,36 +1,125 @@ [build-system] -requires = ["setuptools", "wheel"] -build-backend = "setuptools.build_meta" +requires = ["hatchling"] +build-backend = "hatchling.build" [project] 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" -[tool.setuptools] -py-modules = [ - "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.hatch.metadata] +allow-direct-references = true + +[tool.hatch.version] +path = "pipe.py" + +[tool.hatch.build.targets.sdist] +include = ["pipe.py"] + +[tool.hatch.build.targets.wheel] +include = ["pipe.py"] + +[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", ] -include-package-data = false -[tool.setuptools.dynamic.version] -attr = "pipe.__version__" +[tool.coverage] +html.show_contexts = true +html.skip_covered = false +run.parallel = true +run.omit = [ + "tests/*.py", + # add others +] + + +[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/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 From 12998032fa9dcc22c01e5f3be2527c2076f1a050 Mon Sep 17 00:00:00 2001 From: Panaintescu Adrian Valentin Date: Wed, 19 Mar 2025 19:04:31 +0200 Subject: [PATCH 3/4] revert setuptools backend & cleanup --- .gitignore | 1 + README.md | 25 ++++++------------------- pipe.py | 3 +-- pyproject.toml | 19 ++++++------------- tests/test_pipe.py | 10 ++++++++++ 5 files changed, 24 insertions(+), 34 deletions(-) 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 1efa5e9..b3abf7e 100644 --- a/README.md +++ b/README.md @@ -533,60 +533,47 @@ optional arguments after: >>> ``` -### Organizing using class +### Organizing pipes more effectively using classes -You can also organize pipes using classes as shown below: +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. As a `method` method +1. Using an Instance Method ```python >>> class Factor: -... n: int = 10 -... ... 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] >>> ``` -1. As a `classmethod` +2. Using a Class Method ```python >>> class Factor: ... n: int = 10 -... -... def __init__(self, n: int): -... self.n = n -... ... @Pipe ... @classmethod ... def mul(cls, iterable): ... return (x * cls.n for x in iterable) -... >>> list([1, 2, 3] | Factor.mul) [10, 20, 30] >>> ``` -Supported on classmethod like functions +3. Using a Static Method -```py +```python >>> class Factor: -... def __init__(self, n: int): -... self.n = n -... ... @Pipe ... @staticmethod ... def mul(iterable): ... return (x * 10 for x in iterable) -... >>> list([1, 2, 3] | Factor.mul) [10, 20, 30] >>> diff --git a/pipe.py b/pipe.py index da89ede..1af26e4 100644 --- a/pipe.py +++ b/pipe.py @@ -47,7 +47,6 @@ def __init__(self, function, *args, **kwargs): self.args = args self.kwargs = kwargs self.function = function - self.instance = None functools.update_wrapper(self, function) def __ror__(self, other): @@ -88,7 +87,7 @@ def __get__(self, instance, owner=None): return Pipe( function=self.function.__get__(instance, owner), *self.args, - *self.kwargs, + **self.kwargs, ) diff --git a/pyproject.toml b/pyproject.toml index 87fd456..90556f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" [project] name = "pipe" @@ -36,17 +36,10 @@ 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.hatch.metadata] -allow-direct-references = true - -[tool.hatch.version] -path = "pipe.py" - -[tool.hatch.build.targets.sdist] -include = ["pipe.py"] - -[tool.hatch.build.targets.wheel] -include = ["pipe.py"] +[tool.setuptools] +py-modules = ["pipe"] +include-package-data = false +dynamic.version.attr = "pipe.__version__" [tool.ruff] lint.pydocstyle.convention = "numpy" diff --git a/tests/test_pipe.py b/tests/test_pipe.py index 647f1fc..b511ae2 100644 --- a/tests/test_pipe.py +++ b/tests/test_pipe.py @@ -52,6 +52,16 @@ def mul(self, iterable): assert list([1, 2, 3] | Factory().mul) == [10, 20, 30] +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): From de022de4bdb8ca36cd037fa6f846d281fdfe1477 Mon Sep 17 00:00:00 2001 From: Panaintescu Adrian Valentin Date: Sat, 22 Mar 2025 20:58:08 +0200 Subject: [PATCH 4/4] add some more tests --- pyproject.toml | 4 ++++ tests/test_pipe.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 90556f4..07c648b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,10 @@ run.omit = [ "tests/*.py", # add others ] +run.plugins = [ + # all plugins + "covdefaults", +] [tool.tox] diff --git a/tests/test_pipe.py b/tests/test_pipe.py index b511ae2..7ce18dc 100644 --- a/tests/test_pipe.py +++ b/tests/test_pipe.py @@ -41,7 +41,7 @@ def test_enumerate(): assert list(data | pipe.enumerate(start=5)) == expected -def test_class_support(): +def test_class_support_on_methods(): class Factory: n = 10 @@ -52,6 +52,34 @@ def mul(self, 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