Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ repos:
entry: ruff format
language: system
types_or: [ python, pyi, jupyter ]
- id: ty
name: ty check
entry: uv run ty check
language: system
pass_filenames: false
always_run: true
- id: pytest
name: pytest
entry: uv run pytest -vv --cov=src --cov-report term-missing:skip-covered --cov-report=json:coverage.json
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "danom"
version = "0.10.0"
version = "0.10.1"
description = "Functional streams and monads"
readme = "README.md"
license = "MIT"
Expand All @@ -24,6 +24,7 @@ build-backend = "uv_build"
dev = [
"hypothesis>=6.148.2",
"ipykernel>=7.1.0",
"papertrail>=0.1.2",
"pre-commit>=4.5.0",
"pytest>=9.0.1",
"pytest-asyncio>=1.3.0",
Expand All @@ -46,6 +47,9 @@ show_missing = true
skip_covered = true
fail_under = 95

[project.entry-points.pytest11]
papertrail = "papertrail.__main__"

[tool.pytest]
# addopts = ["--codspeed"]

Expand Down
25 changes: 15 additions & 10 deletions src/danom/_new_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
import inspect
from collections.abc import Callable, Sequence
from functools import wraps
from typing import TypeVar
from typing import ParamSpec, Self, TypeVar

import attrs

T = TypeVar("T")


# skip return type because it makes Pylance think the returned type isn't a type
def new_type( # noqa: ANN202
Expand Down Expand Up @@ -54,11 +56,11 @@ def has_len(email: str) -> bool:
kwargs = _callables_to_kwargs(base_type, validators, converters)

@attrs.define(frozen=frozen, eq=True, hash=frozen)
class _Wrapper[T]:
inner: T = attrs.field(**kwargs)
class _Wrapper:
inner: T = attrs.field(**kwargs) # ty: ignore[no-matching-overload]

def map(self, func: Callable[[T], T]) -> T:
return type(self)(func(self.inner))
def map(self, func: Callable[[T], T]) -> Self:
return self.__class__(func(self.inner)) # ty: ignore[invalid-argument-type]

locals().update(_create_forward_methods(base_type))

Expand All @@ -74,7 +76,7 @@ def _create_forward_methods(base_type: type) -> dict[str, Callable]:
continue

def make_forwarder(name: str) -> Callable:
def method[T](self, *args: tuple, **kwargs: dict) -> T: # noqa: ANN001
def method(self, *args: tuple, **kwargs: dict) -> T: # noqa: ANN001
return getattr(self.inner, name)(*args, **kwargs)

method.__name__ = name
Expand All @@ -97,8 +99,11 @@ def _callables_to_kwargs(
return {k: v for k, v in kwargs.items() if v}


P = ParamSpec("P")


def _validate_bool_func[T](
bool_fn: Callable[..., bool],
bool_fn: Callable[[T], bool],
) -> Callable[[attrs.AttrsInstance, attrs.Attribute, T], None]:
if not callable(bool_fn):
raise TypeError("provided boolean function must be callable")
Expand All @@ -107,21 +112,21 @@ def _validate_bool_func[T](
def wrapper(_instance: attrs.AttrsInstance, attribute: attrs.Attribute, value: T) -> None:
if not bool_fn(value):
raise ValueError(
f"{attribute.name} does not return True for `{bool_fn.__name__}`, received `{value}`."
f"{attribute.name} does not return True for the given boolean function, received `{value}`."
)

return wrapper


C = TypeVar("C", bound=Callable[..., object])
C = TypeVar("C", bound=Callable[P, object])


def _to_list(value: C | Sequence[C] | None) -> list[C]:
if value is None:
return []

if callable(value):
return [value]
return [value] # ty: ignore[invalid-return-type]

if isinstance(value, Sequence) and not all(callable(fn) for fn in value):
raise TypeError(f"Given items are not all callable: {value = }")
Expand Down
13 changes: 9 additions & 4 deletions src/danom/_safe.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import functools
import traceback
from collections.abc import Callable
from typing import ParamSpec
from typing import Concatenate, ParamSpec, TypeVar

from danom._result import Err, Ok, Result

T = TypeVar("T")
P = ParamSpec("P")
U = TypeVar("U")
E = TypeVar("E")


def safe[U, E](func: Callable[..., U]) -> Callable[..., Result[U, E]]:
def safe[**P, U](func: Callable[P, U]) -> Callable[P, Result[U, E]]:
"""Decorator for functions that wraps the function in a try except returns `Ok` on success else `Err`.

.. code-block:: python
Expand All @@ -32,7 +35,9 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[U, E]:
return wrapper


def safe_method[U, E](func: Callable[..., U]) -> Callable[..., Result[U, E]]:
def safe_method[T, **P, U](
func: Callable[Concatenate[T, P], U],
) -> Callable[Concatenate[T, P], Result[U, E]]:
"""The same as `safe` except it forwards on the `self` of the class instance to the wrapped function.

.. code-block:: python
Expand All @@ -51,7 +56,7 @@ def add_one(self, a: int) -> int:
"""

@functools.wraps(func)
def wrapper(self, *args: P.args, **kwargs: P.kwargs) -> Result[U, E]: # noqa: ANN001
def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> Result[U, E]:
try:
return Ok(func(self, *args, **kwargs))
except Exception as e: # noqa: BLE001
Expand Down
2 changes: 1 addition & 1 deletion src/danom/_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ def par_collect(self, workers: int = 4, *, use_threads: bool = False) -> tuple[U

batches = [
(list(chunk), self.ops)
for chunk in batched(self.seq, n=max(10, len(self.seq) // workers))
for chunk in batched(self.seq, n=max(4, len(self.seq) // workers))
]

with executor_cls(max_workers=workers) as ex:
Expand Down
33 changes: 25 additions & 8 deletions src/danom/_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from collections.abc import Callable, Sequence
from functools import reduce
from operator import not_
from typing import ParamSpec, TypeVar

Expand All @@ -19,10 +20,11 @@ class _Compose:
fns: Sequence[Composable]

def __call__(self, initial: T_co) -> T_co | U_co:
value = initial
for fn in self.fns:
value = fn(value)
return value
return reduce(_apply, self.fns, initial) # ty: ignore[invalid-return-type]


def _apply[T_co](value: T_co, fn: Composable) -> T_co | U_co:
return fn(value)


def compose(*fns: Composable) -> Composable:
Expand All @@ -46,8 +48,8 @@ def compose(*fns: Composable) -> Composable:
class _AllOf:
fns: Sequence[Filterable]

def __call__(self, initial: T_co) -> bool:
return all(fn(initial) for fn in self.fns)
def __call__(self, item: T_co) -> bool:
return all(fn(item) for fn in self.fns)


def all_of(*fns: Filterable) -> Filterable:
Expand All @@ -67,8 +69,8 @@ def all_of(*fns: Filterable) -> Filterable:
class _AnyOf:
fns: Sequence[Filterable]

def __call__(self, initial: T_co) -> bool:
return any(fn(initial) for fn in self.fns)
def __call__(self, item: T_co) -> bool:
return any(fn(item) for fn in self.fns)


def any_of(*fns: Filterable) -> Filterable:
Expand Down Expand Up @@ -107,6 +109,21 @@ def identity[T_co](x: T_co) -> T_co:
identity("abc") == "abc"
identity(1) == 1
identity(ComplexDataType(a=1, b=2, c=3)) == ComplexDataType(a=1, b=2, c=3)

Papertrail examples:

>>> identity(1) == 1
True

>>> identity("abc") == "abc"
True

>>> identity([0, 1, 2]) == [0, 1, 2]
True

>>> identity(Ok(inner=1)) == Ok(inner=1)
True
::
"""
return x

Expand Down
4 changes: 2 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import asyncio
from multiprocessing.managers import ListProxy
from pathlib import Path
from typing import Any, Never, Self
from typing import Any, Self

from src.danom import safe, safe_method
from src.danom._result import Err, Ok, Result
Expand Down Expand Up @@ -81,7 +81,7 @@ def safe_get_error_type(exception: Exception) -> str:


@safe
def div_zero(x: int) -> Never:
def div_zero(x: int) -> float:
return x / 0


Expand Down
20 changes: 18 additions & 2 deletions tests/test_new_type.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from contextlib import nullcontext
from types import SimpleNamespace

import attrs
import hypothesis.strategies as st
import pytest
from hypothesis import given
Expand Down Expand Up @@ -100,5 +100,21 @@ def test_new_type_map(initial_value, base_type, map_fn, get_attr, expected_inner
def test_validate_bool_func(args):
bool_fn, value, expected_context = args

attr = attrs.Attribute(
name="x", # ty: ignore[unknown-argument]
cmp=None, # ty: ignore[unknown-argument]
inherited=False, # ty: ignore[unknown-argument]
default=None, # ty: ignore[unknown-argument]
validator=None, # ty: ignore[unknown-argument]
repr=True, # ty: ignore[unknown-argument]
eq=True, # ty: ignore[unknown-argument]
order=True, # ty: ignore[unknown-argument]
hash=None, # ty: ignore[unknown-argument]
init=True, # ty: ignore[unknown-argument]
metadata={}, # ty: ignore[unknown-argument]
type=object, # ty: ignore[unknown-argument]
converter=None, # ty: ignore[unknown-argument]
)

with expected_context:
_validate_bool_func(bool_fn)(object(), SimpleNamespace(name="x"), value)
_validate_bool_func(bool_fn)(object(), attr, value)
18 changes: 13 additions & 5 deletions tests/test_safe.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,19 @@ def test_invalid_safe_method_pipeline():


def test_traceback():
err = div_zero()
assert err.traceback.replace(str(REPO_ROOT), ".").splitlines() == [
err = div_zero(1)

expected_lines = [
"Traceback (most recent call last):",
' File "./src/danom/_safe.py", line 28, in wrapper',
' File "./src/danom/_safe.py", line 31, in wrapper',
" return Ok(func(*args, **kwargs))",
" ^^^^^^^^^^^^^^^^^^^^^",
"TypeError: div_zero() missing 1 required positional argument: 'x'",
' File "./tests/conftest.py", line 85, in div_zero',
" return x / 0",
"ZeroDivisionError: division by zero",
]

tb_lines = err.traceback.replace(str(REPO_ROOT), ".").splitlines()

missing_lines = [line for line in expected_lines if line not in tb_lines]

assert missing_lines == []
3 changes: 2 additions & 1 deletion tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
from papertrail import example

from src.danom import Ok, compose, identity, invert
from src.danom._utils import all_of, any_of, none_of
Expand Down Expand Up @@ -65,7 +66,7 @@ def test_none_of(inp_args, fns, expected_result):
],
)
def test_identity(x):
assert identity(x) == x
assert example(identity, x) == x


@pytest.mark.parametrize(
Expand Down
Loading