├── tests ├── __init__.py ├── test_functorisor.py ├── test_identity.py ├── test_const.py ├── test_pyrsistent.py ├── test_maybe.py ├── test_typeclass_laws.py ├── test_hooks.py ├── test_lens.py └── test_optics.py ├── docs ├── index.rst ├── tutorial │ ├── index.rst │ ├── methods.rst │ ├── compose.rst │ ├── intro.rst │ └── optics.rst ├── api.rst ├── Makefile ├── make.bat └── conf.py ├── pyproject.toml ├── .coveragerc ├── .gitignore ├── .travis.yml ├── .readthedocs.yaml ├── lenses ├── hooks │ ├── __init__.py │ ├── pyrsistent.py │ └── hook_funcs.py ├── ui │ ├── state_func.py │ └── __init__.py ├── optics │ ├── setters.py │ ├── folds.py │ ├── __init__.py │ ├── prisms.py │ ├── isomorphisms.py │ ├── true_lenses.py │ ├── traversals.py │ └── base.py ├── identity.py ├── __init__.py ├── const.py ├── functorisor.py ├── typeclass.py └── maybe.py ├── tox.ini ├── setup.py ├── examples ├── naughts_and_crosses.py └── robots.py └── readme.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Documentation 2 | ============= 3 | 4 | .. toctree:: 5 | tutorial/index 6 | api 7 | 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" 3 | order_by_type = false 4 | case_sensitive = true 5 | -------------------------------------------------------------------------------- /docs/tutorial/index.rst: -------------------------------------------------------------------------------- 1 | Tutorial 2 | ======== 3 | 4 | .. toctree:: 5 | intro 6 | compose 7 | methods 8 | optics 9 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch=true 3 | 4 | [report] 5 | fail_under=95 6 | include=lenses/* 7 | show_missing=true 8 | skip_covered=true 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | .coverage 3 | .eggs/ 4 | .hypothesis/ 5 | .mypy_cache/ 6 | .pytest_cache/ 7 | .tox/ 8 | docs/_build/ 9 | lenses.egg-info/ 10 | venv/ 11 | build/ 12 | dist/ 13 | *.pyc 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.7" 4 | - "3.8" 5 | - "3.9" 6 | - "3.10" 7 | - "pypy3" 8 | install: 9 | - pip install --upgrade pytest>=3.0.0 10 | - pip install tox-travis codecov 11 | script: tox 12 | after_success: 13 | - codecov 14 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | lenses Module 5 | ------------- 6 | 7 | .. automodule:: lenses 8 | 9 | .. autoclass:: lenses.UnboundLens 10 | :members: 11 | :inherited-members: 12 | 13 | lenses.hooks Module 14 | ------------------- 15 | 16 | .. automodule:: lenses.hooks 17 | :members: 18 | :imported-members: 19 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # https://docs.readthedocs.io/en/stable/config-file/v2.html 2 | 3 | version: 2 4 | build: 5 | os: ubuntu-22.04 6 | tools: 7 | python: "3.11" 8 | apt_packages: 9 | - graphviz 10 | python: 11 | install: 12 | - method: pip 13 | path: . 14 | extra_requirements: 15 | - docs 16 | sphinx: 17 | configuration: docs/conf.py 18 | -------------------------------------------------------------------------------- /tests/test_functorisor.py: -------------------------------------------------------------------------------- 1 | from lenses.functorisor import Functorisor 2 | 3 | 4 | def test_Functorisor_pure(): 5 | f = Functorisor(lambda a: [], lambda a: [a]) 6 | assert f.pure(1) == [] 7 | 8 | 9 | def test_Functorisor_call(): 10 | f = Functorisor(lambda a: [], lambda a: [a]) 11 | assert f(1) == [1] 12 | 13 | 14 | def test_Functorisor_map(): 15 | f = Functorisor(lambda a: [], lambda a: [a]) 16 | assert f.map(lambda a: a + 1)(1) == f(2) 17 | -------------------------------------------------------------------------------- /tests/test_identity.py: -------------------------------------------------------------------------------- 1 | from lenses.identity import Identity 2 | 3 | 4 | def test_identity_eq(): 5 | obj = object() 6 | assert Identity(obj) == Identity(obj) 7 | 8 | 9 | def test_identity_not_eq(): 10 | assert Identity(0) != 0 11 | 12 | 13 | def test_identity_pure(): 14 | obj = object() 15 | assert Identity(1).pure(obj) == Identity(obj) 16 | 17 | 18 | def test_identity_descriptive_repr(): 19 | obj = object() 20 | assert repr(obj) in repr(Identity(obj)) 21 | -------------------------------------------------------------------------------- /tests/test_const.py: -------------------------------------------------------------------------------- 1 | from lenses import const 2 | 3 | 4 | def test_const_eq(): 5 | obj = object() 6 | assert const.Const(obj) == const.Const(obj) 7 | 8 | 9 | def test_const_not_eq(): 10 | assert const.Const(0) != 0 11 | 12 | 13 | def test_const_pure(): 14 | assert const.Const([1, 2]).pure([1, 2, 3]) == const.Const([]) 15 | assert const.Const((1, 2)).pure((1, 2)) == const.Const((0, 0)) 16 | 17 | 18 | def test_const_descriptive_repr(): 19 | obj = object() 20 | assert repr(obj) in repr(const.Const(obj)) 21 | -------------------------------------------------------------------------------- /lenses/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | from .hook_funcs import ( 2 | __doc__, 3 | contains_add, 4 | contains_remove, 5 | from_iter, 6 | setattr, 7 | setitem, 8 | to_iter, 9 | ) 10 | 11 | __all__ = [ 12 | "__doc__", 13 | "contains_add", 14 | "contains_remove", 15 | "from_iter", 16 | "setattr", 17 | "setitem", 18 | "to_iter", 19 | ] 20 | 21 | supported_modules = ["pyrsistent"] 22 | 23 | for _hook in supported_modules: 24 | try: 25 | __import__(_hook) 26 | except ImportError: 27 | pass 28 | else: 29 | _subname = "{}.{}".format(__name__, _hook) 30 | __import__(_subname) 31 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = lint,py{37,38,39,310,311},pypy3,docs 3 | 4 | [travis] 5 | python = 6 | 3.11: py311, lint 7 | 8 | [testenv:lint] 9 | deps = .[lints] 10 | commands = 11 | ufmt check lenses 12 | mypy -p lenses 13 | flake8 --max-line-length=88 lenses 14 | 15 | [testenv:docs] 16 | basepython = python 17 | changedir = docs 18 | deps = .[docs] 19 | commands = 20 | sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 21 | 22 | [testenv] 23 | deps = 24 | .[tests] 25 | commands = 26 | pytest \ 27 | lenses \ 28 | tests \ 29 | docs \ 30 | readme.rst \ 31 | --doctest-glob='*.rst' \ 32 | --doctest-modules \ 33 | --cov 34 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = Lenses 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /lenses/ui/state_func.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Generic, TypeVar 2 | 3 | Argument = TypeVar("Argument") 4 | Result = TypeVar("Result") 5 | 6 | 7 | class StateFunction(Generic[Argument, Result]): 8 | """A wrapper around a function that takes a state and returns a 9 | transformed state. This wrapper can be called directly `self(state)` 10 | or you can use the bitwise and operator `state & self`. This syntax 11 | is common in haskell code. It also allows reassignments to be more 12 | pleasant; instead of `state = self(state)` you can write `state &= 13 | self`.""" 14 | 15 | def __init__(self, func: Callable[[Argument], Result]): 16 | self.func = func 17 | 18 | def __call__(self, arg: Argument) -> Result: 19 | return self.func(arg) 20 | 21 | def __rand__(self, other: Argument) -> Result: 22 | return self.func(other) 23 | -------------------------------------------------------------------------------- /lenses/optics/setters.py: -------------------------------------------------------------------------------- 1 | from ..identity import Identity 2 | from .base import Setter 3 | 4 | __all__ = ["ForkedSetter"] 5 | 6 | 7 | class ForkedSetter(Setter): 8 | """A setter representing the parallel composition of several sub-lenses. 9 | 10 | >>> import lenses 11 | >>> gi = lenses.optics.GetitemLens 12 | >>> fs = ForkedSetter(gi(0) & gi(1), gi(2)) 13 | >>> fs 14 | ForkedSetter(GetitemLens(0) & GetitemLens(1), GetitemLens(2)) 15 | >>> state = [[0, 0], 0, 0] 16 | >>> fs.set(state, 1) 17 | [[0, 1], 0, 1] 18 | """ 19 | 20 | def __init__(self, *lenses): 21 | self.lenses = lenses 22 | 23 | def func(self, f, state): 24 | for lens in self.lenses: 25 | state = lens.func(f, state).unwrap() 26 | 27 | return Identity(state) 28 | 29 | def __repr__(self): 30 | args = ", ".join(repr(lens) for lens in self.lenses) 31 | return "ForkedSetter({})".format(args) 32 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=Lenses 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /lenses/optics/folds.py: -------------------------------------------------------------------------------- 1 | from .base import Fold 2 | 3 | 4 | class IterableFold(Fold): 5 | """A fold that can get values from any iterable object in python by 6 | iterating over it. Like any fold, you cannot set values. 7 | 8 | >>> IterableFold() 9 | IterableFold() 10 | >>> data = {2, 1, 3} 11 | >>> IterableFold().to_list_of(data) == list(data) 12 | True 13 | >>> def numbers(): 14 | ... yield 1 15 | ... yield 2 16 | ... yield 3 17 | ... 18 | >>> IterableFold().to_list_of(numbers()) 19 | [1, 2, 3] 20 | >>> IterableFold().to_list_of([]) 21 | [] 22 | 23 | If you want to be able to set values as you iterate then look into 24 | the EachTraversal. 25 | """ 26 | 27 | def __init__(self): 28 | pass 29 | 30 | def folder(self, state): 31 | return iter(state) 32 | 33 | def __repr__(self): 34 | return "IterableFold()" 35 | -------------------------------------------------------------------------------- /lenses/identity.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Generic, TypeVar 2 | 3 | A = TypeVar("A") 4 | B = TypeVar("B") 5 | 6 | 7 | class Identity(Generic[A]): 8 | """The identiy functor applies functions to its contents 9 | with no additional funtionality. It is the trivial or null 10 | functor. 11 | 12 | It is needed for lenses to be able to set values. 13 | """ 14 | 15 | __slots__ = ("item",) 16 | 17 | def __init__(self, item: A) -> None: 18 | self.item = item 19 | 20 | def __repr__(self) -> str: 21 | return "{}({!r})".format(self.__class__.__name__, self.item) 22 | 23 | def __eq__(self, other: object) -> bool: 24 | if not isinstance(other, Identity): 25 | return False 26 | return bool(self.item == other.item) 27 | 28 | def map(self, fn: Callable[[A], B]) -> "Identity[B]": 29 | return Identity(fn(self.item)) 30 | 31 | @classmethod 32 | def pure(cls, item: A) -> "Identity[A]": 33 | return cls(item) 34 | 35 | def apply(self, fn: "Identity[Callable[[A], B]]") -> "Identity[B]": 36 | return Identity(fn.item(self.item)) 37 | 38 | def unwrap(self) -> A: 39 | return self.item 40 | -------------------------------------------------------------------------------- /lenses/__init__.py: -------------------------------------------------------------------------------- 1 | """A python module for manipulating deeply nested data structures 2 | without mutating them. 3 | 4 | A simple overview for this module is available in the readme or 5 | at [http://github.com/ingolemo/python-lenses] . More detailed 6 | information for each object is available in the relevant 7 | docstrings. `help(lenses.UnboundLens)` is particularly useful. 8 | 9 | The entry point to this library is the `lens` object: 10 | 11 | >>> from lenses import lens 12 | >>> lens 13 | UnboundLens(TrivialIso()) 14 | 15 | You can also obtain a bound lens with the `bind` function. 16 | 17 | >>> from lenses import bind 18 | >>> bind([1, 2, 3]) 19 | BoundLens([1, 2, 3], TrivialIso()) 20 | """ 21 | 22 | from typing import TypeVar 23 | 24 | from . import optics, ui 25 | 26 | # included so you can run pydoc lenses.UnboundLens 27 | from .ui import UnboundLens 28 | 29 | S = TypeVar("S") 30 | 31 | 32 | def bind(state: S) -> ui.BoundLens[S, S, S, S]: 33 | "Returns a simple BoundLens object bound to `state`." 34 | return ui.BoundLens(state, optics.TrivialIso()) 35 | 36 | 37 | lens = ui.UnboundLens(optics.TrivialIso()) # type: ui.UnboundLens 38 | 39 | __all__ = ["lens", "bind", "optics", "UnboundLens"] 40 | -------------------------------------------------------------------------------- /lenses/const.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Generic, TypeVar 2 | 3 | from .typeclass import mappend, mempty 4 | 5 | A = TypeVar("A") 6 | B = TypeVar("B") 7 | C = TypeVar("C") 8 | D = TypeVar("D") 9 | 10 | 11 | class Const(Generic[C, A]): 12 | """An applicative functor that doesn't care about the data it's 13 | supposed to be a functor over, caring only about the data it was passed 14 | during creation. This type is essential to the lens' `get` operation. 15 | """ 16 | 17 | __slots__ = ("item",) 18 | 19 | def __init__(self, item: C) -> None: 20 | self.item = item 21 | 22 | def __repr__(self) -> str: 23 | return "{}({!r})".format(self.__class__.__name__, self.item) 24 | 25 | def __eq__(self, other: object) -> bool: 26 | if not isinstance(other, Const): 27 | return False 28 | return bool(self.item == other.item) 29 | 30 | def map(self, func: Callable[[A], B]) -> "Const[C, B]": 31 | return Const(self.item) 32 | 33 | def pure(self, item: D) -> "Const[D, B]": 34 | return Const(mempty(self.item)) 35 | 36 | def apply(self, fn: "Const[C, Callable[[A], B]]") -> "Const[C, B]": 37 | return Const(mappend(fn.item, self.item)) 38 | 39 | def unwrap(self) -> C: 40 | return self.item 41 | -------------------------------------------------------------------------------- /lenses/functorisor.py: -------------------------------------------------------------------------------- 1 | from .typeclass import fmap 2 | 3 | 4 | class Functorisor(object): 5 | """A Functorisor is a wrapper around an ordinary function that carries 6 | information about the return type of that function. Specifically 7 | it wraps functions that return an applicative functor. In haskell 8 | notation: 9 | 10 | func :: a -> Applicative b 11 | 12 | This is neccessary because some functions want to access the result of 13 | `pure :: b -> Applicative b` without having an `a` to call the function 14 | with (and thereby being unable to determine which `b` to call `pure` on, 15 | which the Python implementation requires). 16 | 17 | The Functorisor solves this problem by carrying around a `pure` 18 | function. It's a hack, but it works well enough.""" 19 | 20 | __slots__ = ("pure", "func") 21 | 22 | def __init__(self, pure_func, func): 23 | self.pure = pure_func 24 | self.func = func 25 | 26 | def __call__(self, arg): 27 | return self.func(arg) 28 | 29 | def map(self, f): 30 | def new_f(a): 31 | return fmap(self.func(a), f) 32 | 33 | return Functorisor(self.pure, new_f) 34 | 35 | def update(self, fn): 36 | return Functorisor(self.pure, lambda state: fn(self, state)) 37 | -------------------------------------------------------------------------------- /tests/test_pyrsistent.py: -------------------------------------------------------------------------------- 1 | import pyrsistent as pyr 2 | 3 | import lenses.hooks.pyrsistent # noqa: F401 4 | from lenses import lens 5 | 6 | 7 | def test_pvector_setitem(): 8 | state = pyr.pvector([1, 2, 3]) 9 | assert (lens[0] + 10)(state) == pyr.pvector([11, 2, 3]) 10 | 11 | 12 | def test_pvector_iter(): 13 | state = pyr.pvector([1, 2, 3]) 14 | assert (lens.Each() + 10)(state) == pyr.pvector([11, 12, 13]) 15 | 16 | 17 | def test_pmap_setitem(): 18 | state = pyr.m(a=1, b=2, c=3) 19 | assert (lens["a"] + 10)(state) == pyr.m(a=11, b=2, c=3) 20 | 21 | 22 | def test_pmap_iter(): 23 | state = pyr.m(a=1, b=2, c=3) 24 | assert (lens.Each()[1] + 10)(state) == pyr.m(a=11, b=12, c=13) 25 | 26 | 27 | def test_pset_iter(): 28 | state = pyr.s(1, 2, 3) 29 | assert (lens.Each() + 10)(state) == pyr.s(11, 12, 13) 30 | 31 | 32 | class PairR(pyr.PRecord): 33 | left = pyr.field() 34 | right = pyr.field() 35 | 36 | 37 | def test_precord_setattr(): 38 | state = PairR(left=1, right=2) 39 | assert (lens.left + 10)(state) == PairR(left=11, right=2) 40 | 41 | 42 | def test_precord_iter(): 43 | state = PairR(left=1, right=2) 44 | assert (lens.Each()[1] + 10)(state) == PairR(left=11, right=12) 45 | 46 | 47 | class PairC(pyr.PClass): 48 | left = pyr.field() 49 | right = pyr.field() 50 | 51 | 52 | def test_pclass_setattr(): 53 | state = PairC(left=1, right=2) 54 | assert (lens.left + 10)(state) == PairC(left=11, right=2) 55 | -------------------------------------------------------------------------------- /lenses/hooks/pyrsistent.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from typing import Type 4 | 5 | import pyrsistent 6 | 7 | from . import hook_funcs 8 | 9 | pvector_type: Type[pyrsistent.PVector] = type(pyrsistent.pvector()) 10 | pmap_type: Type[pyrsistent.PMap] = type(pyrsistent.pmap()) 11 | pset_type: Type[pyrsistent.PSet] = type(pyrsistent.pset()) 12 | 13 | 14 | @hook_funcs.setitem.register(pvector_type) 15 | def _pvector_setitem(self, key, value): 16 | return self.set(key, value) 17 | 18 | 19 | @hook_funcs.from_iter.register(pvector_type) 20 | def _pvector_from_iter(self, iterable): 21 | return pyrsistent.pvector(iterable) 22 | 23 | 24 | @hook_funcs.setitem.register(pmap_type) 25 | def _pmap_setitem(self, key, value): 26 | return self.set(key, value) 27 | 28 | 29 | @hook_funcs.to_iter.register(pmap_type) 30 | def _pmap_to_iter(self): 31 | return self.items() 32 | 33 | 34 | @hook_funcs.from_iter.register(pmap_type) 35 | def _pmap_from_iter(self, iterable): 36 | return pyrsistent.pmap(iterable) 37 | 38 | 39 | @hook_funcs.from_iter.register(pset_type) 40 | def _pset_from_iter(self, iterable): 41 | return pyrsistent.pset(iterable) 42 | 43 | 44 | @hook_funcs.setattr.register(pyrsistent.PRecord) 45 | def _precord_setattr(self, attr, value): 46 | return self.set(attr, value) 47 | 48 | 49 | @hook_funcs.to_iter.register(pyrsistent.PRecord) 50 | def _precord_to_iter(self): 51 | return self.items() 52 | 53 | 54 | @hook_funcs.from_iter.register(pyrsistent.PRecord) 55 | def _precord_from_iter(self, iterable): 56 | return type(self)(**dict(iterable)) 57 | 58 | 59 | @hook_funcs.setattr.register(pyrsistent.PClass) 60 | def _pclass_setattr(self, attr, value): 61 | return self.set(attr, value) 62 | -------------------------------------------------------------------------------- /lenses/optics/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import ( 2 | ComposedLens, 3 | Equality, 4 | ErrorIso, 5 | Fold, 6 | Getter, 7 | Isomorphism, 8 | Lens, 9 | LensLike, 10 | Prism, 11 | Review, 12 | Setter, 13 | Traversal, 14 | TrivialIso, 15 | TupleOptic, 16 | ) 17 | from .folds import IterableFold 18 | from .isomorphisms import DecodeIso, JsonIso, NormalisingIso 19 | from .prisms import FilteringPrism, InstancePrism, JustPrism 20 | from .setters import ForkedSetter 21 | from .traversals import ( 22 | EachTraversal, 23 | GetZoomAttrTraversal, 24 | ItemsTraversal, 25 | RecurTraversal, 26 | RegexTraversal, 27 | ZoomAttrTraversal, 28 | ZoomTraversal, 29 | ) 30 | from .true_lenses import ( 31 | ContainsLens, 32 | GetattrLens, 33 | GetitemLens, 34 | GetitemOrElseLens, 35 | ItemByValueLens, 36 | ItemLens, 37 | PartsLens, 38 | ) 39 | 40 | __all__ = [ 41 | "ComposedLens", 42 | "Equality", 43 | "ErrorIso", 44 | "Fold", 45 | "Getter", 46 | "Isomorphism", 47 | "Lens", 48 | "LensLike", 49 | "Prism", 50 | "Review", 51 | "Setter", 52 | "Traversal", 53 | "TrivialIso", 54 | "IterableFold", 55 | "DecodeIso", 56 | "JsonIso", 57 | "NormalisingIso", 58 | "FilteringPrism", 59 | "InstancePrism", 60 | "JustPrism", 61 | "ForkedSetter", 62 | "EachTraversal", 63 | "GetZoomAttrTraversal", 64 | "ItemsTraversal", 65 | "RecurTraversal", 66 | "RegexTraversal", 67 | "ZoomAttrTraversal", 68 | "ZoomTraversal", 69 | "ContainsLens", 70 | "GetattrLens", 71 | "GetitemLens", 72 | "GetitemOrElseLens", 73 | "ItemByValueLens", 74 | "ItemLens", 75 | "PartsLens", 76 | "TupleOptic", 77 | ] 78 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import setuptools 4 | 5 | here = os.path.abspath(os.path.dirname(__file__)) 6 | 7 | try: 8 | with open(os.path.join(here, "readme.rst")) as handle: 9 | long_desc = handle.read() 10 | except IOError: 11 | # the readme should get included in source tarballs, but it shouldn't 12 | # be in the wheel. I can't find a way to do both, so we'll just ignore 13 | # the long_description when installing from the source tarball. 14 | long_desc = None 15 | 16 | dependencies = [] 17 | 18 | documentation_dependencies = [ 19 | "sphinx", 20 | "sphinx_rtd_theme", 21 | ] 22 | 23 | optional_dependencies = [ 24 | "pyrsistent", 25 | ] 26 | 27 | test_dependencies = optional_dependencies + [ 28 | "pytest", 29 | "pytest-sugar", 30 | "coverage", 31 | "pytest-coverage", 32 | "hypothesis", 33 | ] 34 | 35 | lint_dependencies = optional_dependencies + [ 36 | "ufmt", 37 | "flake8", 38 | 'mypy;implementation_name=="cpython"', 39 | ] 40 | 41 | setuptools.setup( 42 | name="lenses", 43 | version="1.2.0", 44 | description="A lens library for python", 45 | long_description=long_desc, 46 | url="https://github.com/ingolemo/python-lenses", 47 | author="Adrian Room", 48 | author_email="ingolemo@gmail.com", 49 | license="GPLv3+", 50 | classifiers=[ 51 | "Development Status :: 5 - Production/Stable", 52 | "Intended Audience :: Developers", 53 | "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", 54 | "Natural Language :: English", 55 | "Programming Language :: Python :: 3", 56 | "Programming Language :: Python :: 3.7", 57 | "Programming Language :: Python :: 3.8", 58 | "Programming Language :: Python :: 3.9", 59 | "Programming Language :: Python :: 3.10", 60 | "Programming Language :: Python :: 3.11", 61 | "Programming Language :: Python :: Implementation :: CPython", 62 | "Programming Language :: Python :: Implementation :: PyPy", 63 | "Topic :: Software Development :: Libraries", 64 | ], 65 | keywords="lens lenses immutable functional optics", 66 | packages=setuptools.find_packages(exclude=["tests"]), 67 | python_requires=">=3.7, <4", 68 | install_requires=dependencies, 69 | tests_require=test_dependencies, 70 | extras_require={ 71 | "docs": documentation_dependencies, 72 | "optional": optional_dependencies, 73 | "tests": test_dependencies, 74 | "lints": lint_dependencies, 75 | }, 76 | ) 77 | -------------------------------------------------------------------------------- /tests/test_maybe.py: -------------------------------------------------------------------------------- 1 | import lenses 2 | import pytest 3 | from lenses.maybe import Just, Nothing 4 | from lenses.typeclass import mempty 5 | 6 | 7 | def test_Nothing_map(): 8 | assert Nothing().map(str) == Nothing() 9 | 10 | 11 | def test_Nothing_add_Nothing(): 12 | assert Nothing() + Nothing() == Nothing() 13 | 14 | 15 | def test_Nothing_add_Just(): 16 | obj = object() 17 | assert Nothing() + Just(obj) == Just(obj) 18 | 19 | 20 | def test_Nothing_repr_invariant(): 21 | assert repr(Nothing()) == repr(Nothing()) 22 | 23 | 24 | def test_Nothing_iter(): 25 | assert list(Nothing()) == [] 26 | 27 | 28 | def test_Nothing_from_iter(): 29 | assert lenses.hooks.from_iter(Just(1), []) == Nothing() 30 | 31 | 32 | def test_Nothing_not_equals_Just(): 33 | assert Nothing() != Just(object()) 34 | 35 | 36 | def test_Nothing_maybe(): 37 | assert Nothing().maybe(None) is None 38 | 39 | 40 | def test_Nothing_unwrap(): 41 | with pytest.raises(ValueError): 42 | Nothing().unwrap() 43 | 44 | 45 | def test_Just_equals_Just_when_subobjects_equal(): 46 | # maybe hypothesis can make this more rigourous 47 | obj1 = object() 48 | obj2 = object() 49 | assert (Just(obj1) == Just(obj2)) is bool(obj1 == obj2) 50 | 51 | 52 | def test_Just_not_equals_Nothing(): 53 | assert Just(object()) != Nothing() 54 | 55 | 56 | def test_Just_not_equals_object(): 57 | obj = object 58 | assert Just(obj) != obj 59 | 60 | 61 | def test_Just_map(): 62 | assert Just(1).map(str) == Just(str(1)) 63 | 64 | 65 | def test_Just_add_Nothing(): 66 | obj = object() 67 | assert Just(obj) + Nothing() == Just(obj) 68 | 69 | 70 | def test_Just_add_Just(): 71 | assert Just([1]) + Just([2]) == Just([1, 2]) 72 | 73 | 74 | def test_Just_add_monoid_empty(): 75 | obj = object() 76 | value = Just(obj) 77 | assert value + mempty(value) == value 78 | 79 | 80 | def test_Just_repr_conatins_subobject(): 81 | obj = object() 82 | assert repr(obj) in repr(Just(obj)) 83 | 84 | 85 | def test_Just_iter(): 86 | obj = object() 87 | assert list(Just(obj)) == [obj] 88 | 89 | 90 | def test_Just_from_iter(): 91 | obj = object() 92 | assert lenses.hooks.from_iter(Nothing(), [obj]) == Just(obj) 93 | 94 | 95 | def test_Just_maybe(): 96 | obj = object() 97 | assert Just(obj).maybe(None) is obj 98 | 99 | 100 | def test_Just_unwrap(): 101 | obj = object() 102 | assert Just(obj).unwrap() is obj 103 | -------------------------------------------------------------------------------- /docs/tutorial/methods.rst: -------------------------------------------------------------------------------- 1 | Lens methods 2 | ============ 3 | 4 | So far we've seen lenses that extract data out of data-structures, but 5 | lenses are more powerful than that. Lenses can actually perform 6 | arbitrary computation on the data passing through them as long as that 7 | computation can be reversed. 8 | 9 | A simple example is that of the ``Item`` method which returns a lens that 10 | focuses on a single key of a dictionary but returns both the key and the 11 | value: 12 | 13 | >>> from lenses import lens 14 | 15 | >>> item_one = lens.Item('one') 16 | >>> item_one.get()({'one': 1}) 17 | ('one', 1) 18 | >>> item_one.set(('three', 3))({'one': 1}) 19 | {'three': 3} 20 | 21 | For a good example of a more complex lens, check out the ``Json`` method 22 | which gives you a lens that can focus a string as though it were a parsed 23 | json object. 24 | 25 | >>> data = '{"numbers":[1, 2, 3]}' 26 | >>> json_lens = lens.Json() 27 | >>> json_lens.get()(data) == {'numbers': [1, 2, 3]} 28 | True 29 | >>> json_lens['numbers'][1].set(4)(data) 30 | '{"numbers": [1, 4, 3]}' 31 | 32 | At their heart, lenses are really just souped-up getters and setters. If 33 | you have a getter and a setter for some data then you can turn those 34 | into a lens using the ``Lens`` method. Here is how you could recreate the 35 | ``Item('one')`` lens defined above in terms of ``Lens``: 36 | 37 | >>> def getter(current_state): 38 | ... return 'one', current_state['one'] 39 | ... 40 | >>> def setter(old_state, new_focus): 41 | ... key, value = new_focus 42 | ... new_state = old_state.copy() 43 | ... del new_state['one'] 44 | ... new_state[key] = value 45 | ... return new_state 46 | ... 47 | >>> item_one = lens.Lens(getter, setter) 48 | >>> item_one.get()({'one': 1}) 49 | ('one', 1) 50 | >>> item_one.set(('three', 3))({'one': 1}) 51 | {'three': 3} 52 | 53 | Recreating existing behaviour isn't very useful, but hopefully you can 54 | see how useful it is to be able to make your own lenses just by writing 55 | a pair of functions. 56 | 57 | If you use custom lenses frequently then you may want to look into the 58 | ``Iso`` method which is a less powerful but often more convenient version 59 | of ``Lens``. 60 | 61 | There are a number of such more complicated lenses defined on lens 62 | objects. To help avoid collision with accessing attributes on the state, 63 | their names are all in CamelCase. See ``help(lenses.UnboundLens)`` in 64 | the repl for more. If you need to access an attribute on the state 65 | that has been shadowed by one of lens' methods then you can use 66 | ``my_lens.GetAttr(attribute)``. 67 | -------------------------------------------------------------------------------- /lenses/optics/prisms.py: -------------------------------------------------------------------------------- 1 | from ..maybe import Just, Nothing 2 | from .base import Prism 3 | 4 | 5 | class FilteringPrism(Prism): 6 | """A prism that only focuses a value if the predicate returns `True` 7 | when called with that value as an argument. Best used when composed 8 | after a traversal. It only prevents the traversal from visiting foci, 9 | it does not filter out values the way that python's regular `filter` 10 | function does. 11 | 12 | >>> FilteringPrism(all) 13 | FilteringPrism() 14 | >>> import lenses 15 | >>> each = lenses.optics.EachTraversal() 16 | >>> state = [[1, 2], [0], ['a'], ['', 'b']] 17 | >>> (each & FilteringPrism(all)).to_list_of(state) 18 | [[1, 2], ['a']] 19 | >>> (each & FilteringPrism(all)).set(state, 2) 20 | [2, [0], 2, ['', 'b']] 21 | 22 | The filtering is done to foci before the lens' manipulation is 23 | applied. This means that the resulting foci can still violate the 24 | predicate if the manipulating function doesn't respect it: 25 | 26 | >>> (each & FilteringPrism(bool)).set(['', 2, ''], None) 27 | ['', None, ''] 28 | """ 29 | 30 | def __init__(self, predicate): 31 | self.predicate = predicate 32 | 33 | def unpack(self, state): 34 | if self.predicate(state): 35 | return Just(state) 36 | return Nothing() 37 | 38 | def pack(self, focus): 39 | return focus 40 | 41 | def __repr__(self): 42 | return "FilteringPrism({!r})".format(self.predicate) 43 | 44 | 45 | class InstancePrism(FilteringPrism): 46 | """A prism that focuses a value only when that value is an instance 47 | of `type_`. 48 | 49 | >>> InstancePrism(int) 50 | InstancePrism(...) 51 | >>> InstancePrism(int).to_list_of(1) 52 | [1] 53 | >>> InstancePrism(float).to_list_of(1) 54 | [] 55 | >>> InstancePrism(int).set(1, 2) 56 | 2 57 | >>> InstancePrism(float).set(1, 2) 58 | 1 59 | """ 60 | 61 | def __init__(self, type_): 62 | self.type = type_ 63 | 64 | def predicate(self, value): 65 | return isinstance(value, self.type) 66 | 67 | def __repr__(self): 68 | return "InstancePrism({!r})".format(self.type) 69 | 70 | 71 | class JustPrism(Prism): 72 | """A prism that focuses the value inside a `lenses.maybe.Just` 73 | object. 74 | 75 | >>> from lenses.maybe import Just, Nothing 76 | >>> JustPrism() 77 | JustPrism() 78 | >>> JustPrism().to_list_of(Just(1)) 79 | [1] 80 | >>> JustPrism().to_list_of(Nothing()) 81 | [] 82 | >>> JustPrism().set(Just(1), 2) 83 | Just(2) 84 | >>> JustPrism().set(Nothing(), 2) 85 | Nothing() 86 | """ 87 | 88 | def __init__(self): 89 | pass 90 | 91 | def unpack(self, a): 92 | return a 93 | 94 | def pack(self, a): 95 | return Just(a) 96 | 97 | def __repr__(self): 98 | return "JustPrism()" 99 | -------------------------------------------------------------------------------- /lenses/typeclass.py: -------------------------------------------------------------------------------- 1 | from functools import singledispatch 2 | from typing import Any, Callable, List, Tuple, TypeVar 3 | 4 | A = TypeVar("A") 5 | B = TypeVar("B") 6 | 7 | 8 | @singledispatch 9 | def mempty(monoid: Any) -> Any: 10 | return monoid.mempty() 11 | 12 | 13 | @singledispatch 14 | def mappend(monoid: Any, other: Any) -> Any: 15 | return monoid + other 16 | 17 | 18 | @mempty.register(int) 19 | def _mempty_int(self: int) -> int: 20 | return 0 21 | 22 | 23 | @mempty.register(str) 24 | def _mempty_str(string: str) -> str: 25 | return "" 26 | 27 | 28 | @mempty.register(list) 29 | def _mempty_list(lst: List[A]) -> List[A]: 30 | return [] 31 | 32 | 33 | @mempty.register(tuple) 34 | def _mempty_tuple(tup: Tuple[Any, ...]) -> Tuple[Any, ...]: 35 | return tuple(mempty(item) for item in tup) 36 | 37 | 38 | @mappend.register(tuple) 39 | def _mappend_tuple(tup: Tuple[Any, ...], other: Tuple[Any, ...]) -> Tuple[Any, ...]: 40 | if len(tup) != len(other): 41 | raise ValueError("Cannot mappend tuples of differing lengths") 42 | result = () # type: Tuple[Any, ...] 43 | for x, y in zip(tup, other): 44 | result += (mappend(x, y),) 45 | return result 46 | 47 | 48 | @mempty.register(dict) 49 | def _mempty_dict(dct: dict) -> dict: 50 | return {} 51 | 52 | 53 | @mappend.register(dict) 54 | def _mappend_dict(dct: dict, other: dict) -> dict: 55 | out = {} 56 | out.update(dct) 57 | out.update(other) 58 | return out 59 | 60 | 61 | # functor 62 | @singledispatch 63 | def fmap(functor: Any, func: Callable[[Any], Any]) -> Any: 64 | """Applies a function to the data 'inside' a functor. 65 | 66 | Uses functools.singledispatch so you can write your own functors 67 | for use with the library.""" 68 | return functor.map(func) 69 | 70 | 71 | @fmap.register(list) 72 | def _fmap_list(lst: List[A], func: Callable[[A], B]) -> List[B]: 73 | return [func(a) for a in lst] 74 | 75 | 76 | @fmap.register(tuple) 77 | def _fmap_tuple(tup: Tuple[A, ...], func: Callable[[A], B]) -> Tuple[B, ...]: 78 | return tuple(func(a) for a in tup) 79 | 80 | 81 | # applicative functor 82 | @singledispatch 83 | def pure(applicative: Any, item: B) -> Any: 84 | return applicative.pure(item) 85 | 86 | 87 | @singledispatch 88 | def apply(applicative: Any, func: Any) -> Any: 89 | return applicative.apply(func) 90 | 91 | 92 | @pure.register(list) 93 | def _pure_list(lst: List[A], item: B) -> List[B]: 94 | return [item] 95 | 96 | 97 | @apply.register(list) 98 | def _apply_list(lst: List[A], funcs: List[Callable[[A], B]]) -> List[B]: 99 | return [f(i) for i in lst for f in funcs] 100 | 101 | 102 | @pure.register(tuple) 103 | def _pure_tuple(tup: Tuple[A, ...], item: B) -> Tuple[B]: 104 | return (item,) 105 | 106 | 107 | @apply.register(tuple) 108 | def _apply_tuple( 109 | tup: Tuple[A, ...], funcs: Tuple[Callable[[A], B], ...] 110 | ) -> Tuple[B, ...]: 111 | return tuple(f(i) for i in tup for f in funcs) 112 | -------------------------------------------------------------------------------- /lenses/optics/isomorphisms.py: -------------------------------------------------------------------------------- 1 | from .base import Isomorphism 2 | 3 | 4 | class DecodeIso(Isomorphism): 5 | """An isomorphism that decodes and encodes its focus on the fly. 6 | Lets you focus a byte string as a unicode string. The arguments have 7 | the same meanings as `bytes.decode`. Analogous to `bytes.decode`. 8 | 9 | >>> DecodeIso() 10 | DecodeIso('utf-8', 'strict') 11 | >>> DecodeIso().view(b'hello') 12 | 'hello' 13 | >>> DecodeIso().set(b'hello', 'world') 14 | b'world' 15 | """ 16 | 17 | def __init__(self, encoding: str = "utf-8", errors: str = "strict") -> None: 18 | self.encoding = encoding 19 | self.errors = errors 20 | 21 | def forwards(self, state): 22 | return state.decode(self.encoding, self.errors) 23 | 24 | def backwards(self, focus): 25 | return focus.encode(self.encoding, self.errors) 26 | 27 | def __repr__(self): 28 | repr = "DecodeIso({!r}, {!r})" 29 | return repr.format(self.encoding, self.errors) 30 | 31 | 32 | class JsonIso(Isomorphism): 33 | """An isomorphism that focuses a string containing json data as its 34 | parsed equivalent. Analogous to `json.loads`. 35 | 36 | >>> JsonIso() 37 | JsonIso() 38 | >>> state = '[{"points": [4, 7]}]' 39 | >>> JsonIso().view(state) 40 | [{'points': [4, 7]}] 41 | >>> JsonIso().set(state, [{'points': [3]}]) 42 | '[{"points": [3]}]' 43 | """ 44 | 45 | def __init__(self) -> None: 46 | self.json_mod = __import__("json") 47 | 48 | def forwards(self, state): 49 | return self.json_mod.loads(state) 50 | 51 | def backwards(self, focus): 52 | return self.json_mod.dumps(focus) 53 | 54 | def __repr__(self): 55 | return "JsonIso()" 56 | 57 | 58 | class NormalisingIso(Isomorphism): 59 | """An isomorphism that applies a function as it sets a new focus 60 | without regard to the old state. It will get foci without 61 | transformation. This lens allows you to pre-process values before 62 | you set them, but still get values as they exist in the state. 63 | Useful for type conversions or normalising data. 64 | 65 | For best results, your normalisation function should be idempotent. 66 | That is, applying the function twice should have no effect: 67 | 68 | setter(setter(value)) == setter(value) 69 | 70 | Equivalent to `Isomorphism((lambda s: s), setter)`. 71 | 72 | >>> def real_only(num): 73 | ... return num.real 74 | ... 75 | >>> NormalisingIso(real_only) 76 | NormalisingIso() 77 | >>> NormalisingIso(real_only).view(1.0) 78 | 1.0 79 | >>> NormalisingIso(real_only).set(1.0, 4+7j) 80 | 4.0 81 | 82 | Types with constructors that do conversion are often good targets 83 | for this lens: 84 | 85 | >>> NormalisingIso(int).set(1, '4') 86 | 4 87 | """ 88 | 89 | def __init__(self, setter): 90 | self.backwards = setter 91 | 92 | def forwards(self, state): 93 | return state 94 | 95 | def __repr__(self): 96 | return "NormalisingIso({!r})".format(self.backwards) 97 | -------------------------------------------------------------------------------- /tests/test_typeclass_laws.py: -------------------------------------------------------------------------------- 1 | import hypothesis 2 | import hypothesis.strategies as strat 3 | 4 | import lenses 5 | import lenses.typeclass as tc 6 | 7 | 8 | def objects(): 9 | return strat.just(object()) 10 | 11 | 12 | def maybes(substrat): 13 | return substrat.flatmap( 14 | lambda a: strat.sampled_from( 15 | [ 16 | lenses.maybe.Nothing(), 17 | lenses.maybe.Just(a), 18 | ] 19 | ) 20 | ) 21 | 22 | 23 | # the free monoid should be good enough 24 | monoids = strat.one_of( 25 | strat.integers(), 26 | strat.text(), 27 | strat.lists(strat.integers()), 28 | strat.tuples(strat.integers(), strat.text()), 29 | strat.dictionaries(strat.text(), strat.integers()), 30 | ) 31 | 32 | 33 | def applicatives(substrat): 34 | return strat.one_of( 35 | strat.lists(substrat), 36 | strat.lists(substrat).map(tuple), 37 | substrat.map(lenses.identity.Identity), 38 | maybes(substrat), 39 | ) 40 | 41 | 42 | def functors(substrat): 43 | return applicatives(substrat) 44 | 45 | 46 | @hypothesis.given(monoids) 47 | def test_monoid_law_associativity(m1): 48 | # (a + b) + c = a + (b + c) 49 | add = tc.mappend 50 | assert add(add(m1, m1), m1) == add(m1, add(m1, m1)) 51 | 52 | 53 | @hypothesis.given(monoids) 54 | def test_monoid_law_left_identity(m): 55 | # mempty + a = a 56 | assert tc.mappend(tc.mempty(m), m) == m 57 | 58 | 59 | @hypothesis.given(monoids) 60 | def test_monoid_law_right_identity(m): 61 | # a + mempty = a 62 | assert tc.mappend(m, tc.mempty(m)) == m 63 | 64 | 65 | @hypothesis.given(functors(objects())) 66 | def test_functor_law_identity(data): 67 | # fmap id = id 68 | def identity(a): 69 | return a 70 | 71 | assert tc.fmap(data, identity) == identity(data) 72 | 73 | 74 | @hypothesis.given(functors(objects())) 75 | def test_functor_law_distributive(functor): 76 | # fmap (g . f) = fmap g . fmap f 77 | def f1(a): 78 | return [a] 79 | 80 | f2 = str 81 | 82 | def composed(a): 83 | return f1(f2(a)) 84 | 85 | assert tc.fmap(functor, composed) == tc.fmap(tc.fmap(functor, f2), f1) 86 | 87 | 88 | @hypothesis.given(applicatives(objects())) 89 | def test_applicative_law_identity(data): 90 | # pure id <*> v = v 91 | def identity(a): 92 | return a 93 | 94 | assert tc.apply(data, tc.pure(data, identity)) == data 95 | 96 | 97 | @hypothesis.given(applicatives(objects())) 98 | def test_applicative_law_homomorphism(appl): 99 | # pure f <*> pure x = pure (f x) 100 | x = object() 101 | f = id 102 | 103 | left = tc.apply(tc.pure(appl, x), tc.pure(appl, f)) 104 | right = tc.pure(appl, f(x)) 105 | assert left == right 106 | 107 | 108 | @hypothesis.given(applicatives(objects())) 109 | def test_applicative_law_interchange(appl): 110 | # u <*> pure y = pure ($ y) <*> u 111 | y = object() 112 | u = tc.pure(appl, str) 113 | 114 | left = tc.apply(tc.pure(appl, y), u) 115 | right = tc.apply(u, tc.pure(appl, lambda a: a(y))) 116 | assert left == right 117 | 118 | 119 | @hypothesis.given(applicatives(objects())) 120 | def test_applicative_law_composition(appl): 121 | # pure (.) <*> u <*> v <*> w = u <*> (v <*> w) 122 | u = tc.pure(appl, lambda a: [a]) 123 | v = tc.pure(appl, str) 124 | w = appl 125 | 126 | def compose(f1): 127 | return lambda f2: lambda a: f1(f2(a)) 128 | 129 | left = tc.apply(w, tc.apply(v, tc.apply(u, tc.pure(appl, compose)))) 130 | right = tc.apply(tc.apply(w, v), u) 131 | assert left == right 132 | -------------------------------------------------------------------------------- /examples/naughts_and_crosses.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | naughts_and_crosses.py 4 | 5 | Play a game of naughts and crosses (also known as tic-tac-toe) against 6 | the computer. To make a move choose a cell and type the number and 7 | letter of that cell. Your input must be exactly two characters long; 8 | do not include any spacing or punctionation. 9 | """ 10 | 11 | import enum 12 | import random 13 | 14 | from lenses import lens 15 | 16 | 17 | class Outcome(enum.Enum): 18 | "The possible outcomes of a game of naughts and crosses." 19 | win_for_crosses = enum.auto() 20 | win_for_naughts = enum.auto() 21 | draw = enum.auto() 22 | ongoing = enum.auto() 23 | 24 | def __bool__(self): 25 | "Returns whether the game has concluded." 26 | return self is not Outcome.ongoing 27 | 28 | def __str__(self): 29 | return { 30 | Outcome.win_for_crosses: "X is the winner!", 31 | Outcome.win_for_naughts: "O is the winner!", 32 | Outcome.draw: "The game is a draw!", 33 | }.get(self, "The winner is unknown.") 34 | 35 | 36 | class Board: 37 | "A noughts and crosses board." 38 | 39 | def __init__(self): 40 | self.board = ((" ",) * 3,) * 3 41 | 42 | def make_move(self, x, y): 43 | """Return a board with a cell filled in by the current player. If 44 | the cell is already occupied then return the board unchanged.""" 45 | if self.board[y][x] == " ": 46 | return self & lens.board[y][x].set(self.player) 47 | return self 48 | 49 | @property 50 | def player(self): 51 | "The player whose turn it currently is." 52 | return "X" if self._count("X") <= self._count("O") else "O" 53 | 54 | @property 55 | def winner(self): 56 | "The winner of this board if one exists." 57 | for potential_win in self._potential_wins(): 58 | if potential_win == tuple("XXX"): 59 | return Outcome.win_for_crosses 60 | elif potential_win == tuple("OOO"): 61 | return Outcome.win_for_naughts 62 | if self._count(" ") == 0: 63 | return Outcome.draw 64 | return Outcome.ongoing 65 | 66 | def _count(self, character): 67 | """Counts the number of cells in the board that contain a 68 | particular character.""" 69 | return sum(cell == character for cell in self._all_cells()) 70 | 71 | def _potential_wins(self): 72 | """Generates all the combinations of board positions that need 73 | to be checked for a win.""" 74 | yield from self.board 75 | yield from zip(*self.board) 76 | yield self.board[0][0], self.board[1][1], self.board[2][2] 77 | yield self.board[0][2], self.board[1][1], self.board[2][0] 78 | 79 | def __str__(self): 80 | result = [] 81 | for letter, row in zip("abc", self.board): 82 | result.append(letter + " " + (" │ ".join(row))) 83 | return " 1 2 3\n" + ("\n ───┼───┼───\n".join(result)) 84 | 85 | _all_cells = lens.board.Each().Each().collect() 86 | 87 | 88 | def player_move(board): 89 | """Shows the board to the player on the console and asks them to 90 | make a move.""" 91 | print(board, end="\n\n") 92 | x, y = input("Enter move (e.g. 2b): ") 93 | print() 94 | return int(x) - 1, ord(y) - ord("a") 95 | 96 | 97 | def random_move(board): 98 | "Makes a random move on the board." 99 | return random.choice(range(3)), random.choice(range(3)) 100 | 101 | 102 | def play(): 103 | "Play a game of naughts and crosses against the computer." 104 | ai = {"X": player_move, "O": random_move} 105 | board = Board() 106 | while not board.winner: 107 | x, y = ai[board.player](board) 108 | board = board.make_move(x, y) 109 | print(board, end="\n\n") 110 | print(board.winner) 111 | 112 | 113 | if __name__ == "__main__": 114 | play() 115 | -------------------------------------------------------------------------------- /lenses/maybe.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Callable, Generic, Iterator, TypeVar, Union 3 | 4 | from . import hooks, typeclass 5 | 6 | A = TypeVar("A") 7 | B = TypeVar("B") 8 | 9 | 10 | class Just(Generic[A]): 11 | """A class that can contain a value or not. If it contains a value 12 | then it will be an instance of Just. If it doesn't then it will be 13 | an instance of Nothing. You can wrap an existing value By calling 14 | the Just constructor: 15 | 16 | >>> from lenses.maybe import Just, Nothing 17 | >>> Just(1) 18 | Just(1) 19 | 20 | To extract it again you can use the `maybe` method: 21 | 22 | >>> Just(1).maybe(0) 23 | 1 24 | """ 25 | 26 | # The typing module in 3.5.2 is broken when using Generic with __slots__, 27 | # see https://github.com/python/typing/issues/332 28 | # We can just skip defining __slots__ and this will work fine for that 29 | # version, at a slight overhead expense. 30 | if sys.version_info[:3] != (3, 5, 2): 31 | __slots__ = ("item",) 32 | 33 | def __init__(self, item: A) -> None: 34 | self.item = item 35 | 36 | def __add__(self, other: "Just[A]") -> "Just[A]": 37 | if other.is_nothing(): 38 | return self 39 | return Just(typeclass.mappend(self.item, other.item)) 40 | 41 | def __eq__(self, other: object) -> bool: 42 | if not isinstance(other, Just): 43 | return False 44 | 45 | return bool(self.item == other.item) 46 | 47 | def __iter__(self) -> Iterator[A]: 48 | yield self.item 49 | 50 | def __repr__(self) -> str: 51 | return "Just({!r})".format(self.item) 52 | 53 | def map(self, fn: Callable[[A], B]) -> "Just[B]": 54 | """Apply a function to the value inside the Maybe.""" 55 | return Just(fn(self.item)) 56 | 57 | def maybe(self, guard: B) -> Union[A, B]: 58 | """Unwraps the value, returning it is there is one, else 59 | returning the guard.""" 60 | return self.item 61 | 62 | def unwrap(self) -> A: 63 | return self.item 64 | 65 | def is_nothing(self) -> bool: 66 | return False 67 | 68 | 69 | class Nothing(Just[A]): 70 | __slots__ = () 71 | 72 | def __init__(self) -> None: 73 | pass 74 | 75 | def __add__(self, other: Just[A]) -> Just[A]: 76 | return other 77 | 78 | def __eq__(self, other: object) -> bool: 79 | return isinstance(other, Nothing) 80 | 81 | def __iter__(self) -> Iterator[A]: 82 | return iter([]) 83 | 84 | def __repr__(self) -> str: 85 | return "Nothing()" 86 | 87 | def map(self, fn: Callable[[A], B]) -> Just[B]: 88 | """Apply a function to the value inside the Maybe.""" 89 | return Nothing() 90 | 91 | def maybe(self, guard: B) -> Union[A, B]: 92 | """Unwraps the value, returning it is there is one, else 93 | returning the guard.""" 94 | return guard 95 | 96 | def unwrap(self) -> A: 97 | raise ValueError("Cannot unwrap Nothing") 98 | 99 | def is_nothing(self) -> bool: 100 | return True 101 | 102 | 103 | @typeclass.mempty.register(Just) 104 | def _maybe_mempty(self: Just[A]) -> Nothing: 105 | return Nothing() 106 | 107 | 108 | @typeclass.fmap.register(Just) 109 | def _maybe_fmap(self: Just[A], fn: Callable[[A], B]) -> Just[B]: 110 | return self.map(fn) 111 | 112 | 113 | @typeclass.pure.register(Just) 114 | def _maybe_pure(self: Just, item: B) -> Just[B]: 115 | return Just(item) 116 | 117 | 118 | @typeclass.apply.register(Just) 119 | def _maybe_apply(self: Just[A], fn: Just[Callable[[A], B]]) -> Just[B]: 120 | if self.is_nothing() or fn.is_nothing(): 121 | return Nothing() 122 | return Just(fn.item(self.item)) 123 | 124 | 125 | @hooks.from_iter.register(Just) 126 | def _maybe_from_iter(self: Just, iter: Iterator[A]) -> Just[A]: 127 | i = list(iter) 128 | if i == []: 129 | return Nothing() 130 | return Just(i[0]) 131 | -------------------------------------------------------------------------------- /tests/test_hooks.py: -------------------------------------------------------------------------------- 1 | import collections 2 | 3 | import hypothesis 4 | import pytest 5 | from hypothesis import strategies as strats 6 | 7 | import lenses.hooks as s 8 | 9 | 10 | class Box(object): 11 | def __init__(self, value): 12 | self.value = value 13 | 14 | def __eq__(self, other): 15 | return self.value == other.value 16 | 17 | def __contains__(self, item): 18 | return item in self.value 19 | 20 | def _lens_contains_add(self, item): 21 | return Box(s.contains_add(self.value, item)) 22 | 23 | def _lens_contains_remove(self, item): 24 | return Box(s.contains_remove(self.value, item)) 25 | 26 | 27 | def test_setitem_imm_custom_class(): 28 | class C(object): 29 | def __init__(self, item): 30 | self.item = item 31 | 32 | def __eq__(self, other): 33 | return self.item == other.item 34 | 35 | def _lens_setitem(self, key, value): 36 | return C(value) 37 | 38 | assert s.setitem(C(1), 0, 2) == C(2) 39 | 40 | 41 | def test_setitem_imm_bytes(): 42 | assert s.setitem(b"hello", 0, ord(b"j")) == b"jello" 43 | 44 | 45 | def test_setitem_imm_list(): 46 | assert s.setitem([1, 2, 3], 0, 4) == [4, 2, 3] 47 | 48 | 49 | def test_setitem_imm_str(): 50 | assert s.setitem(u"hello", 0, u"j") == u"jello" 51 | 52 | 53 | def test_setitem_imm_tuple(): 54 | assert s.setitem((1, 2, 3), 0, 4) == (4, 2, 3) 55 | 56 | 57 | def test_setattr_imm_custom_class(): 58 | class C(object): 59 | def __init__(self, attr): 60 | self.attr = attr 61 | 62 | def __eq__(self, other): 63 | return self.attr == other.attr 64 | 65 | def _lens_setattr(self, name, value): 66 | if name == "fake_attr": 67 | return C(value) 68 | else: 69 | raise AttributeError(name) 70 | 71 | assert s.setattr(C(1), "fake_attr", 2) == C(2) 72 | 73 | 74 | def test_setattr_imm_custom_class_raw(): 75 | class C(object): 76 | def __init__(self, attr): 77 | self.attr = attr 78 | 79 | def __eq__(self, other): 80 | return self.attr == other.attr 81 | 82 | assert s.setattr(C(1), "attr", 2) == C(2) 83 | 84 | 85 | def test_setattr_imm_namedtuple(): 86 | Tup = collections.namedtuple("Tup", "attr") 87 | assert s.setattr(Tup(1), "attr", 2) == Tup(2) 88 | 89 | 90 | @hypothesis.given( 91 | strats.one_of( 92 | strats.lists(strats.integers()), 93 | strats.iterables(strats.integers()).map(tuple), 94 | strats.dictionaries(strats.text(), strats.integers()), 95 | strats.iterables(strats.integers()).map(set), 96 | strats.lists(strats.integers()).map(Box), 97 | ) 98 | ) 99 | def test_contains(container): 100 | item = object() 101 | added = s.contains_add(container, item) 102 | assert isinstance(added, type(container)) 103 | assert item in added 104 | removed = s.contains_remove(added, item) 105 | assert isinstance(removed, type(container)) 106 | assert item not in removed 107 | 108 | 109 | def test_contains_add_failure(): 110 | with pytest.raises(NotImplementedError): 111 | s.contains_add(True, object()) 112 | 113 | 114 | def test_contains_remove_failure(): 115 | with pytest.raises(NotImplementedError): 116 | s.contains_remove(True, object()) 117 | 118 | 119 | def test_to_iter_custom_class(): 120 | class C(object): 121 | def __init__(self, attr): 122 | self.attr = attr 123 | 124 | def __eq__(self, other): 125 | return self.attr == other.attr 126 | 127 | def _lens_to_iter(self): 128 | yield self.attr 129 | 130 | assert list(s.to_iter(C(1))) == [1] 131 | 132 | 133 | def test_from_iter_custom_class(): 134 | class C(object): 135 | def __init__(self, attr): 136 | self.attr = attr 137 | 138 | def __eq__(self, other): 139 | return self.attr == other.attr 140 | 141 | def _lens_from_iter(self, iterable): 142 | return C(next(iter(iterable))) 143 | 144 | assert s.from_iter(C(1), [2]) == C(2) 145 | 146 | 147 | def test_from_iter_bytes(): 148 | assert s.from_iter(b"", s.to_iter(b"123")) == b"123" 149 | 150 | 151 | def test_from_iter_list(): 152 | assert s.from_iter([], (1, 2, 3)) == [1, 2, 3] 153 | 154 | 155 | def test_from_iter_set(): 156 | assert s.from_iter(set(), [1, 2, 3]) == {1, 2, 3} 157 | 158 | 159 | def test_from_iter_str(): 160 | assert s.from_iter(u"", ["1", "2", "3"]) == u"123" 161 | 162 | 163 | def test_from_iter_tuple(): 164 | assert s.from_iter((), [1, 2, 3]) == (1, 2, 3) 165 | 166 | 167 | def test_from_iter_namedtuple(): 168 | Tup = collections.namedtuple("Tup", "attr1 attr2 attr3") 169 | iterTup = s.from_iter(Tup(1, 2, 3), [4, 5, 6]) 170 | assert iterTup == Tup(4, 5, 6) 171 | assert type(iterTup) is Tup 172 | 173 | 174 | def test_from_iter_dict(): 175 | data = {"jane": 5, "jim": 6, "joanne": 8} 176 | new_keys = [(k.capitalize(), v) for k, v in s.to_iter(data)] 177 | assert s.from_iter(data, new_keys) == {"Jane": 5, "Jim": 6, "Joanne": 8} 178 | 179 | 180 | def test_from_iter_unknown(): 181 | with pytest.raises(NotImplementedError): 182 | s.from_iter(object(), [1, 2, 3]) 183 | -------------------------------------------------------------------------------- /readme.rst: -------------------------------------------------------------------------------- 1 | 2 | Lenses 3 | ====== 4 | 5 | Lenses is a python library that helps you to manipulate large 6 | data-structures without mutating them. It is inspired by the lenses in 7 | Haskell, although it's much less principled and the api is more suitable 8 | for python. 9 | 10 | 11 | Installation 12 | ------------ 13 | 14 | You can install the latest version from pypi using pip like so:: 15 | 16 | pip install lenses 17 | 18 | You can uninstall similarly:: 19 | 20 | pip uninstall lenses 21 | 22 | 23 | Documentation 24 | ------------- 25 | 26 | The lenses library makes liberal use of docstrings, which you can access 27 | as normal with the ``pydoc`` shell command, the ``help`` function in 28 | the repl, or by reading the source yourself. 29 | 30 | Most users will only need the docs from ``lenses.UnboundLens``. If you 31 | want to add hooks to allow parts of the library to work with custom 32 | objects then you should check out the ``lenses.hooks`` module. Most of 33 | the fancy lens code is in the ``lenses.optics`` module for those who 34 | are curious how everything works. 35 | 36 | Some examples are given in the `examples`_ folder and the `documentation`_ 37 | is available on ReadTheDocs. 38 | 39 | .. _examples: examples 40 | .. _documentation: https://python-lenses.readthedocs.io/en/latest/ 41 | 42 | 43 | Example 44 | ------- 45 | 46 | .. code:: pycon 47 | 48 | >>> from pprint import pprint 49 | >>> from lenses import lens 50 | >>> 51 | >>> data = [{'name': 'Jane', 'scores': ['a', 'a', 'b', 'a']}, 52 | ... {'name': 'Richard', 'scores': ['c', None, 'd', 'c']}, 53 | ... {'name': 'Zoe', 'scores': ['f', 'f', None, 'f']}] 54 | ... 55 | >>> format_scores = lens.Each()['scores'].Each().Instance(str).call_upper() 56 | >>> cheat = lens[2]['scores'].Each().set('a') 57 | >>> 58 | >>> corrected = format_scores(data) 59 | >>> pprint(corrected) 60 | [{'name': 'Jane', 'scores': ['A', 'A', 'B', 'A']}, 61 | {'name': 'Richard', 'scores': ['C', None, 'D', 'C']}, 62 | {'name': 'Zoe', 'scores': ['F', 'F', None, 'F']}] 63 | >>> 64 | >>> cheated = format_scores(cheat(data)) 65 | >>> pprint(cheated) 66 | [{'name': 'Jane', 'scores': ['A', 'A', 'B', 'A']}, 67 | {'name': 'Richard', 'scores': ['C', None, 'D', 'C']}, 68 | {'name': 'Zoe', 'scores': ['A', 'A', 'A', 'A']}] 69 | 70 | 71 | The definition of ``format_scores`` means "for each item in the data take 72 | the value with the key of ``'scores'`` and then for each item in that list 73 | that is an instance of ``str``, call its ``upper`` method on it". That one 74 | line is the equivalent of this code: 75 | 76 | .. code:: python 77 | 78 | def format_scores(data): 79 | results = [] 80 | for entry in data: 81 | result = {} 82 | for key, value in entry.items(): 83 | if key == 'scores': 84 | new_value = [] 85 | for letter in value: 86 | if isinstance(letter, str): 87 | new_value.append(letter.upper()) 88 | else: 89 | new_value.append(letter) 90 | result[key] = new_value 91 | else: 92 | result[key] = value 93 | results.append(result) 94 | return results 95 | 96 | Now, this code can be simplified using comprehensions. But comprehensions 97 | only work with lists, dictionaries, and sets, whereas the lenses library 98 | can work with arbitrary python objects. 99 | 100 | Here's an example that shows off the full power of this library: 101 | 102 | .. code:: pycon 103 | 104 | >>> from lenses import lens 105 | >>> state = (("foo", "bar"), "!", 2, ()) 106 | >>> lens.Recur(str).Each().Filter(lambda c: c <= 'm').Parts().call_mut_reverse()(state) 107 | (('!oo', 'abr'), 'f', 2, ()) 108 | 109 | This is an example from the `Putting Lenses to Work`__ talk about the 110 | haskell lenses library by John Wiegley. We extract all the strings inside 111 | of ``state``, extract the characters, filter out any characters that 112 | come after ``'m'`` in the alphabet, treat these characters as if they 113 | were a list, reverse that list, before finally placing these characters 114 | back into the state in their new positions. 115 | 116 | .. _putting_lenses_to_work: https://www.youtube.com/watch?v=QZy4Yml3LTY&t=2250 117 | 118 | __ putting_lenses_to_work_ 119 | 120 | This example is obviously very contrived, but I can't even begin to 121 | imagine how you would do this in python code without lenses. 122 | 123 | 124 | License 125 | ------- 126 | 127 | python-lenses is free software: you can redistribute it and/or modify it 128 | under the terms of the GNU General Public License as published by the 129 | Free Software Foundation, either version 3 of the License, or (at your 130 | option) any later version. 131 | 132 | This program is distributed in the hope that it will be useful, but 133 | WITHOUT ANY WARRANTY; without even the implied warranty of 134 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General 135 | Public License for more details. 136 | 137 | You should have received a copy of the GNU General Public License along 138 | with this program. If not, see http://www.gnu.org/licenses/. 139 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Lenses documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Oct 20 16:24:32 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | "sphinx.ext.autodoc", 36 | "sphinx.ext.doctest", 37 | "sphinx.ext.coverage", 38 | "sphinx.ext.graphviz", 39 | "sphinx.ext.viewcode", 40 | "sphinx_rtd_theme", 41 | ] 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ["_templates"] 45 | 46 | # The suffix(es) of source filenames. 47 | # You can specify multiple suffix as a list of string: 48 | # 49 | # source_suffix = ['.rst', '.md'] 50 | source_suffix = ".rst" 51 | 52 | # The master toctree document. 53 | master_doc = "index" 54 | 55 | # General information about the project. 56 | project = "Lenses" 57 | copyright = "2017, Adrian Room" 58 | author = "Adrian Room" 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | # 64 | # The short X.Y version. 65 | version = "1.2" 66 | # The full version, including alpha/beta/rc tags. 67 | release = "1.2.0" 68 | 69 | # The language for content autogenerated by Sphinx. Refer to documentation 70 | # for a list of supported languages. 71 | # 72 | # This is also used if you do content translation via gettext catalogs. 73 | # Usually you set "language" from the command line for these cases. 74 | language = "en" 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | # This patterns also effect to html_static_path and html_extra_path 79 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 80 | 81 | # The name of the Pygments (syntax highlighting) style to use. 82 | pygments_style = "sphinx" 83 | 84 | # If true, `todo` and `todoList` produce output, else they produce nothing. 85 | todo_include_todos = False 86 | 87 | 88 | # -- Options for HTML output ---------------------------------------------- 89 | 90 | # The theme to use for HTML and HTML Help pages. See the documentation for 91 | # a list of builtin themes. 92 | # 93 | html_theme = "sphinx_rtd_theme" 94 | 95 | # Theme options are theme-specific and customize the look and feel of a theme 96 | # further. For a list of options available for each theme, see the 97 | # documentation. 98 | # 99 | # html_theme_options = {} 100 | 101 | # Add any paths that contain custom static files (such as style sheets) here, 102 | # relative to this directory. They are copied after the builtin static files, 103 | # so a file named "default.css" will overwrite the builtin "default.css". 104 | html_static_path = ["_static"] 105 | 106 | # Custom sidebar templates, must be a dictionary that maps document names 107 | # to template names. 108 | # 109 | # This is required for the alabaster theme 110 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 111 | html_sidebars = { 112 | "**": [ 113 | "about.html", 114 | "navigation.html", 115 | "relations.html", # needs 'show_related': True theme option to display 116 | "searchbox.html", 117 | "donate.html", 118 | ] 119 | } 120 | 121 | 122 | # -- Options for HTMLHelp output ------------------------------------------ 123 | 124 | # Output file base name for HTML help builder. 125 | htmlhelp_basename = "Lensesdoc" 126 | 127 | 128 | # -- Options for LaTeX output --------------------------------------------- 129 | 130 | latex_elements = { 131 | # The paper size ('letterpaper' or 'a4paper'). 132 | # 133 | # 'papersize': 'letterpaper', 134 | # The font size ('10pt', '11pt' or '12pt'). 135 | # 136 | # 'pointsize': '10pt', 137 | # Additional stuff for the LaTeX preamble. 138 | # 139 | # 'preamble': '', 140 | # Latex figure (float) alignment 141 | # 142 | # 'figure_align': 'htbp', 143 | } 144 | 145 | # Grouping the document tree into LaTeX files. List of tuples 146 | # (source start file, target name, title, 147 | # author, documentclass [howto, manual, or own class]). 148 | latex_documents = [ 149 | (master_doc, "Lenses.tex", "Lenses Documentation", "Adrian Room", "manual"), 150 | ] 151 | 152 | 153 | # -- Options for manual page output --------------------------------------- 154 | 155 | # One entry per manual page. List of tuples 156 | # (source start file, name, description, authors, manual section). 157 | man_pages = [(master_doc, "lenses", "Lenses Documentation", [author], 1)] 158 | 159 | 160 | # -- Options for Texinfo output ------------------------------------------- 161 | 162 | # Grouping the document tree into Texinfo files. List of tuples 163 | # (source start file, target name, title, author, 164 | # dir menu entry, description, category) 165 | texinfo_documents = [ 166 | ( 167 | master_doc, 168 | "Lenses", 169 | "Lenses Documentation", 170 | author, 171 | "Lenses", 172 | "One line description of project.", 173 | "Miscellaneous", 174 | ), 175 | ] 176 | -------------------------------------------------------------------------------- /examples/robots.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | robots.py 4 | 5 | You (`@`) are a person surrounded by killer robots (`O`). Dodge the 6 | robots with the vi-keys (`hjkl`, `.` to wait, and `yubn` for diagonals) 7 | and try to make them crash into each other. You win if all robots crash. 8 | You lose if a robot lands on your square. If you find yourself stuck you 9 | can press `t` to teleport to a new position. If you teleport next to a 10 | robot then they will kill you, so watch out. 11 | """ 12 | 13 | import sys 14 | import termios 15 | import tty 16 | from random import randint 17 | 18 | from lenses import lens 19 | 20 | MAXX = 40 21 | MAXY = 20 22 | ROBOTS = 12 23 | 24 | 25 | def get_single_char(): 26 | fd = sys.stdin.fileno() 27 | old_settings = termios.tcgetattr(fd) 28 | try: 29 | tty.setraw(sys.stdin.fileno()) 30 | return sys.stdin.read(1) 31 | finally: 32 | termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) 33 | 34 | 35 | def duplicates(items): 36 | seen_items = set() 37 | for item in items: 38 | if item in seen_items: 39 | yield item 40 | seen_items.add(item) 41 | 42 | 43 | class Vector(tuple): 44 | @classmethod 45 | def random(cls): 46 | "produces a random vector inside the play area." 47 | 48 | return cls((randint(0, MAXX), randint(0, MAXY))) 49 | 50 | def __add__(self, other): 51 | return Vector((self[0] + other[0], self[1] + other[1])) 52 | 53 | def inside(self): 54 | "checks if the vector is inside the play area." 55 | 56 | return all([0 <= self[0] <= MAXX, 0 <= self[1] <= MAXY]) 57 | 58 | def step_towards(self, other): 59 | """returns the vector moved one step in the direction of the 60 | other, potentially diagonally.""" 61 | 62 | return self + Vector( 63 | ( 64 | (self[0] < other[0]) - (self[0] > other[0]), 65 | (self[1] < other[1]) - (self[1] > other[1]), 66 | ) 67 | ) 68 | 69 | 70 | class GameState: 71 | def __init__(self): 72 | self.robots = list() 73 | self.crashes = set() 74 | self.player = Vector((MAXX // 2, MAXY // 2)) 75 | self.running = True 76 | self.message = None 77 | 78 | for _ in range(ROBOTS): 79 | self.robots.append(Vector.random()) 80 | 81 | def handle_input(self, input): 82 | """Takes a single character string as input and alters the game 83 | state according to that input. Mostly, this means moving the 84 | player around. Returns a new game state and boolean indicating 85 | whether the input had an effect on the state.""" 86 | 87 | dirs = { 88 | "h": (-1, 0), 89 | "j": (0, 1), 90 | "k": (0, -1), 91 | "l": (1, 0), 92 | "y": (-1, -1), 93 | "u": (1, -1), 94 | "n": (1, 1), 95 | "b": (-1, 1), 96 | } 97 | 98 | if input in dirs: 99 | new_self = self & lens.player + dirs[input] 100 | if not new_self.player.inside(): 101 | return self, False 102 | return new_self, True 103 | elif input == ".": 104 | return self, True 105 | elif input == "q": 106 | return self.end_game(), False 107 | elif input == "t": 108 | self &= lens.player.set(Vector.random()) 109 | return self, True 110 | else: 111 | return self, False 112 | 113 | def advance_robots(self): 114 | """Produces a new game state in which the robots have advanced 115 | towards the player by one step. Handles the robots crashing into 116 | one another too.""" 117 | 118 | # move the robots towards the player 119 | self &= lens.robots.Each().call_step_towards(self.player) 120 | # robots in the same place are crashes 121 | self &= lens.crashes.call_union(duplicates(self.robots)) 122 | # remove crashed robots 123 | self &= lens.robots.modify(lambda r: list(set(r) - self.crashes)) 124 | 125 | return self 126 | 127 | def check_game_end(self): 128 | """Checks for the game's win/lose conditions and 'alters' the 129 | game state to reflect the condition found. If the game has not 130 | been won or lost then it just returns the game state 131 | unaltered.""" 132 | 133 | if self.player in self.crashes.union(self.robots): 134 | return self.end_game("You Died!") 135 | elif not self.robots: 136 | return self.end_game("You Win!") 137 | else: 138 | return self 139 | 140 | def end_game(self, message=""): 141 | """Returns a completed game state object, setting an optional 142 | message to display after the game is over.""" 143 | 144 | return self & lens.running.set(False) & lens.message.set(message) 145 | 146 | def __str__(self): 147 | rows = [] 148 | for y in range(0, MAXY + 1): 149 | chars = [] 150 | for x in range(0, MAXX + 1): 151 | coord = Vector((x, y)) 152 | if coord == self.player: 153 | ch = "@" 154 | elif coord in self.crashes: 155 | ch = "#" 156 | elif coord in self.robots: 157 | ch = "O" 158 | else: 159 | ch = "." 160 | chars.append(ch) 161 | rows.append("".join(chars)) 162 | return "\n".join(rows) + "\n" 163 | 164 | 165 | def main(): 166 | """The main function. Instantiates a GameState object and then 167 | enters a REPL-like main loop, waiting for input, updating the state 168 | based on the input, then outputting the new state.""" 169 | 170 | state = GameState() 171 | print(state) 172 | while state.running: 173 | input = get_single_char() 174 | 175 | state, should_advance = state.handle_input(input) 176 | if should_advance: 177 | state = state.advance_robots() 178 | state = state.check_game_end() 179 | 180 | print(state) 181 | 182 | print(state.message) 183 | 184 | 185 | if __name__ == "__main__": 186 | main() 187 | -------------------------------------------------------------------------------- /docs/tutorial/compose.rst: -------------------------------------------------------------------------------- 1 | Composing Lenses 2 | ================ 3 | 4 | If we have two lenses, we can join them together using the ``&`` 5 | operator. Joining lenses means that the second lens is placed "inside" 6 | of the first so that the focus of the first lens is fed into the second 7 | one as its state: 8 | 9 | >>> from lenses import lens 10 | 11 | >>> index_zero = lens[0] 12 | >>> index_one = lens[1] 13 | >>> get_zero_then_one = (index_zero & index_one).get() 14 | >>> get_zero_then_one([[2, 3], [4, 5]]) 15 | 3 16 | >>> get_one_then_zero = (index_one & index_zero).get() 17 | >>> get_one_then_zero([[2, 3], [4, 5]]) 18 | 4 19 | 20 | When we call ``a & b``, ``b`` must be an unbound lens and the 21 | resulting lens will be bound to the same object as ``a``, if any. 22 | 23 | It is important to note that doing two operations on two different lenses 24 | and then composing them is the equivalent to doing those two operations 25 | on the same lens: 26 | 27 | >>> lens[0][1] 28 | UnboundLens(GetitemLens(0) & GetitemLens(1)) 29 | >>> lens[0] & lens[1] 30 | UnboundLens(GetitemLens(0) & GetitemLens(1)) 31 | 32 | The first is actually implemented in terms of the second, internally. 33 | 34 | When we need to do more than two operations on the same lens we will 35 | often refer to this as "composing" two lenses even though the ``&`` operator 36 | is nowhere in sight. 37 | 38 | State-first Syntax 39 | ------------------ 40 | 41 | You may have noticed that lenses split up the passing of state away from 42 | the action that is going to be applied to that state. For example, when 43 | you call `lens.set` you must first call it with a value to set and then 44 | later call the result with the state that you want to set that value on: 45 | 46 | >>> data = [1, 2, 3] 47 | >>> lens[0].set(4)(data) 48 | [4, 2, 3] 49 | 50 | This separation is a useful thing for a few reasons, but it does have 51 | the downside of a verbose syntax; two pairs of brackets. One remedy for 52 | this is that the "function" returned by `lens.set` and friends supports 53 | using the `&` operator to call it: 54 | 55 | >>> data & lens[1].set(5) 56 | [1, 5, 3] 57 | 58 | This syntax may look peculiar to you if you're not used to it, especially 59 | since the function and the state have swapped positions. But this operator 60 | is taken directly from haskell library. 61 | 62 | On it's own this operator is a minor improvement, but as with any operator 63 | python allows you to use it in an augmented assignment, so you don't 64 | have to write "data" twice. The following two code blocks do the same thing: 65 | 66 | >>> data = [1, 2, 3] 67 | >>> data = lens[0].set(6)(data) 68 | >>> data 69 | [6, 2, 3] 70 | 71 | >>> data = [1, 2, 3] 72 | >>> data &= lens[0].set(6) 73 | >>> data 74 | [6, 2, 3] 75 | 76 | This operator works on any of the lens methods that claim to return a function; 77 | `lens.get`, `lens.set`, `lens.modify`, `lens.call` and more. 78 | 79 | >>> data = [1, 2, 3, 4, 5] 80 | >>> data & lens[0].get() 81 | 1 82 | >>> data &= lens[1].set(7) 83 | >>> data 84 | [1, 7, 3, 4, 5] 85 | >>> data &= lens[2].modify(str) 86 | >>> data 87 | [1, 7, '3', 4, 5] 88 | >>> data &= lens[3] * 100 89 | >>> data 90 | [1, 7, '3', 400, 5] 91 | 92 | There are a couple of caveats. Firstly, it's important not to confuse 93 | this function-calling `&` operator with the lens-composition `&` operator 94 | in the previous section. I regret the similarity, but coming up with a 95 | syntax that is both readable to newbies and familiar to polyglots is hard. 96 | 97 | Secondly, the `&` operator is only defined with the "function" on 98 | the right hand side and so follows normal python rules for custom 99 | operators. When you write `state & setter` python will try to run 100 | `state.__and__(setter)` before it tries `setter.__rand__(state)`. If 101 | your `state` object defines the `__and__` method in an inflexable way 102 | then the lenses library can't do anything and the result you get will 103 | not be what you want. 104 | 105 | 106 | Early Binding 107 | ------------- 108 | 109 | The lenses library also exports a ``bind`` function: 110 | 111 | >>> from lenses import lens, bind 112 | 113 | The bind function takes a single argument — a state — and it will 114 | return a ``BoundLens`` object that has been bound to that state. 115 | 116 | >>> bind([1, 2, 3]) 117 | BoundLens([1, 2, 3], TrivialIso()) 118 | 119 | A bound lens is almost exactly like an unbound lens. It has almost all 120 | the same methods and they work in almost exactly the same way. The major 121 | difference is that those methods that would normally return a function 122 | expecting us to pass a state will instead act immediately: 123 | 124 | >>> bind([1, 2, 3])[1].get() 125 | 2 126 | 127 | Here, the ``get`` method is acting on the state that the lens was bound 128 | to originally. 129 | 130 | The methods that are affected are ``get``, ``set``, ``modify``, ``call``, 131 | and ``call_mut``. All of the operators are also affected. 132 | 133 | >>> bind([1, 2, 3])[1].set(4) 134 | [1, 4, 3] 135 | >>> bind([1, 2, 3])[1].modify(str) 136 | [1, '2', 3] 137 | >>> bind([1, 255, 3])[1].call('bit_length') 138 | [1, 8, 3] 139 | >>> bind([1, [4, 2, 3], 5])[1].call_mut('sort') 140 | [1, [2, 3, 4], 5] 141 | >>> bind([1, 2, 3])[1] + 10 142 | [1, 12, 3] 143 | >>> bind([1, 2, 3])[1] * 10 144 | [1, 20, 3] 145 | 146 | 147 | Descriptors 148 | ----------- 149 | 150 | The main place where we would use a bound lens is as part of a descriptor. 151 | 152 | When you set an unbound lens as a class attribute and you access that 153 | attribute from an instance, you will get a bound lens that has been 154 | bound to that instance. This allows you to conveniently store and access 155 | lenses that are likely to be used with particular classes as attributes 156 | of those classes. Attribute access is much more readable than requiring 157 | the user of a class to construct a lens themselves. 158 | 159 | Here we have a vector class that stores its data in a private ``_coords`` 160 | attribute, but allows access to parts of that data through ``x`` and ``y`` 161 | attributes. 162 | 163 | >>> class Vector(object): 164 | ... def __init__(self, x, y): 165 | ... self._coords = [x, y] 166 | ... def __repr__(self): 167 | ... return 'Vector({0!r}, {1!r})'.format(*self._coords) 168 | ... x = lens._coords[0] 169 | ... y = lens._coords[1] 170 | ... 171 | >>> my_vector = Vector(1, 2) 172 | >>> my_vector.x.set(3) 173 | Vector(3, 2) 174 | 175 | Here ``Vector.x`` and ``Vector.y`` are unbound lenses, but 176 | ``my_vector.x`` and ``my_vector.y`` are both bound lenses that are 177 | bound to ``my_vector``. A lens used in this way is similar to python's 178 | ``property`` decorator, except that the api is more powerful and the 179 | setter acts immutably. 180 | 181 | If you ever end up focusing an object with a sublens as one of its 182 | attributes, lenses are smart enough to follow that sublens to its focus. 183 | 184 | >>> data = [Vector(1, 2), Vector(3, 4)] 185 | >>> lens[1].y.set(5)(data) 186 | [Vector(1, 2), Vector(3, 5)] 187 | -------------------------------------------------------------------------------- /lenses/optics/true_lenses.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from .. import hooks 4 | from .base import Fold, Getter, Lens, Traversal 5 | 6 | 7 | class ContainsLens(Lens): 8 | """A lens that takes an item and focuses a bool based on whether 9 | the state contains that item. It's most useful when used with 10 | sets, but it can be used with other collections like lists and 11 | dictionaries. Analogous to the ``in`` operator. 12 | 13 | >>> ContainsLens(1) 14 | ContainsLens(1) 15 | >>> ContainsLens(1).view([2, 3]) 16 | False 17 | >>> ContainsLens(1).view([1, 2, 3]) 18 | True 19 | >>> ContainsLens(1).set([1, 2, 3], False) 20 | [2, 3] 21 | >>> ContainsLens(1).set([2, 3], True) 22 | [2, 3, 1] 23 | >>> ContainsLens(1).set([1, 2, 3], True) 24 | [1, 2, 3] 25 | 26 | In order to use this lens on custom data-types you must implement 27 | ``lenses.hooks.contains_add`` and ``lens.hooks.contains_remove``. 28 | """ 29 | 30 | def __init__(self, item): 31 | self.item = item 32 | 33 | def getter(self, state): 34 | return self.item in state 35 | 36 | def setter(self, state, focus): 37 | contains = self.item in state 38 | if focus and not contains: 39 | return hooks.contains_add(state, self.item) 40 | elif contains and not focus: 41 | return hooks.contains_remove(state, self.item) 42 | else: 43 | return state 44 | 45 | def __repr__(self): 46 | return "ContainsLens({!r})".format(self.item) 47 | 48 | 49 | class GetattrLens(Lens): 50 | """A lens that focuses an attribute of an object. Analogous to 51 | `getattr`. 52 | 53 | >>> GetattrLens('left') 54 | GetattrLens('left') 55 | >>> from collections import namedtuple 56 | >>> Pair = namedtuple('Pair', 'left right') 57 | >>> GetattrLens('left').view(Pair(1, 2)) 58 | 1 59 | >>> GetattrLens('right').set(Pair(1, 2), 3) 60 | Pair(left=1, right=3) 61 | """ 62 | 63 | def __init__(self, name: str) -> None: 64 | self.name = name 65 | 66 | def getter(self, state): 67 | return getattr(state, self.name) 68 | 69 | def setter(self, state, focus): 70 | return hooks.setattr(state, self.name, focus) 71 | 72 | def __repr__(self): 73 | return "GetattrLens({!r})".format(self.name) 74 | 75 | 76 | class GetitemLens(Lens): 77 | """A lens that focuses an item inside a container. Analogous to 78 | `operator.itemgetter`. 79 | 80 | >>> GetitemLens('foo') 81 | GetitemLens('foo') 82 | >>> GetitemLens('foo').view({'foo': 1}) 83 | 1 84 | >>> GetitemLens('foo').set({'foo': 1}, 2) 85 | {'foo': 2} 86 | """ 87 | 88 | def __init__(self, key: Any) -> None: 89 | self.key = key 90 | 91 | def getter(self, state): 92 | return state[self.key] 93 | 94 | def setter(self, state, focus): 95 | return hooks.setitem(state, self.key, focus) 96 | 97 | def __repr__(self): 98 | return "GetitemLens({!r})".format(self.key) 99 | 100 | 101 | class GetitemOrElseLens(GetitemLens): 102 | """A lens that focuses an item inside a container by calling its `get` 103 | method, allowing you to specify a default value for missing keys. 104 | Analogous to `dict.get`. 105 | 106 | >>> GetitemOrElseLens('foo', 0) 107 | GetitemOrElseLens('foo', default=0) 108 | >>> state = {'foo': 1} 109 | >>> GetitemOrElseLens('foo', 0).view(state) 110 | 1 111 | >>> GetitemOrElseLens('baz', 0).view(state) 112 | 0 113 | >>> GetitemOrElseLens('foo', 0).set(state, 2) 114 | {'foo': 2} 115 | >>> GetitemOrElseLens('baz', 0).over({}, lambda a: a + 10) 116 | {'baz': 10} 117 | """ 118 | 119 | def __init__(self, key: Any, default: Any = None) -> None: 120 | self.key = key 121 | self.default = default 122 | 123 | def getter(self, state): 124 | return state.get(self.key, self.default) 125 | 126 | def __repr__(self): 127 | message = "GetitemOrElseLens({!r}, default={!r})" 128 | return message.format(self.key, self.default) 129 | 130 | 131 | class ItemLens(Lens): 132 | """A lens that focuses a single item (key-value pair) in a 133 | dictionary by its key. Set an item to `None` to remove it from the 134 | dictionary. 135 | 136 | >>> ItemLens(1) 137 | ItemLens(1) 138 | >>> from collections import OrderedDict 139 | >>> state = OrderedDict([(1, 10), (2, 20)]) 140 | >>> ItemLens(1).view(state) 141 | (1, 10) 142 | >>> ItemLens(3).view(state) is None 143 | True 144 | >>> ItemLens(1).set(state, (1, 11)) 145 | OrderedDict([(1, 11), (2, 20)]) 146 | >>> ItemLens(1).set(state, None) 147 | OrderedDict([(2, 20)]) 148 | """ 149 | 150 | def __init__(self, key: Any) -> None: 151 | self.key = key 152 | 153 | def getter(self, state): 154 | try: 155 | return self.key, state[self.key] 156 | except KeyError: 157 | return None 158 | 159 | def setter(self, state, focus): 160 | data = state.copy() 161 | if focus is None: 162 | try: 163 | del data[self.key] 164 | except KeyError: 165 | pass 166 | return data 167 | if focus[0] != self.key: 168 | try: 169 | del data[self.key] 170 | except KeyError: 171 | pass 172 | data[focus[0]] = focus[1] 173 | return data 174 | 175 | def __repr__(self): 176 | return "ItemLens({!r})".format(self.key) 177 | 178 | 179 | class ItemByValueLens(Lens): 180 | """A lens that focuses a single item (key-value pair) in a 181 | dictionary by its value. Set an item to `None` to remove it from the 182 | dictionary. This lens assumes that there will only be a single key 183 | with that particular value. If you violate that assumption then 184 | you're on your own. 185 | 186 | >>> ItemByValueLens(10) 187 | ItemByValueLens(10) 188 | >>> from collections import OrderedDict 189 | >>> state = OrderedDict([(1, 10), (2, 20)]) 190 | >>> ItemByValueLens(10).view(state) 191 | (1, 10) 192 | >>> ItemByValueLens(30).view(state) is None 193 | True 194 | >>> ItemByValueLens(10).set(state, (3, 10)) 195 | OrderedDict([(2, 20), (3, 10)]) 196 | >>> ItemByValueLens(10).set(state, None) 197 | OrderedDict([(2, 20)]) 198 | """ 199 | 200 | def __init__(self, value): 201 | self.value = value 202 | 203 | def getter(self, state): 204 | for dkey, dvalue in state.items(): 205 | if dvalue == self.value: 206 | return dkey, dvalue 207 | 208 | def setter(self, state, focus): 209 | data = state.copy() 210 | for key, val in state.items(): 211 | if val == self.value: 212 | del data[key] 213 | if focus is not None: 214 | data[focus[0]] = focus[1] 215 | return data 216 | 217 | def __repr__(self): 218 | return "ItemByValueLens({!r})".format(self.value) 219 | 220 | 221 | class PartsLens(Lens): 222 | """An optic that takes the foci of a fold and packs them up together 223 | as a single list. The kind of this foci depends on what optic you 224 | give it. 225 | """ 226 | 227 | def __init__(self, optic): 228 | self.optic = optic 229 | 230 | def getter(self, state): 231 | return self.optic.to_list_of(state) 232 | 233 | def setter(self, old_state, value): 234 | return self.optic.iterate(old_state, value) 235 | 236 | def __repr__(self) -> str: 237 | return "PartsLens({!r})".format(self.optic) 238 | 239 | def kind(self): 240 | if self.optic.kind() == Traversal: 241 | return Lens 242 | elif self.optic.kind() == Fold: 243 | return Getter 244 | -------------------------------------------------------------------------------- /docs/tutorial/intro.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | For most users, the lenses library exports only one thing worth knowing 5 | about; a ``lens`` object: 6 | 7 | >>> from lenses import lens 8 | 9 | The ``lens`` object is an instance of ``lenses.UnboundLens``. An unbound 10 | lens represents a computation that you want to perform in order to access 11 | your data from within some data-structure. 12 | 13 | Here's some simple data: 14 | 15 | >>> data = [1, 2, 3] 16 | 17 | Suppose that we wanted to access that ``2`` in the middle. Ordinarily in 18 | python we would index into the list like so: 19 | 20 | >>> data[1] 21 | 2 22 | 23 | A bit of terminology; the data-structure that we are trying to pull 24 | information out of (in this case; ``[1, 2, 3]``) is referred to as the 25 | *state*. The piece of data inside the state that we are trying to access 26 | (in this case; ``2``) is called the *focus*. The lenses documentation uses 27 | these terms consistently. For those who are unaware, the word "focus" 28 | has an unusual plural; *foci*. 29 | 30 | We can represent this pattern of access using lenses by doing the same 31 | thing to ``lens``: 32 | 33 | >>> getitem_one = lens[1] 34 | 35 | The ``getitem_one`` variable is now a lens object that knows how to retrieve 36 | values from states by indexing them with a ``1``. All lenses have readable 37 | reprs, which means that you can always print a lens to see how it is 38 | structured: 39 | 40 | >>> getitem_one 41 | UnboundLens(GetitemLens(1)) 42 | 43 | Now that we have a representation of our data access we can use it to 44 | actually access our focus. We do this by calling the ``get`` method on the 45 | lens. The ``get`` method returns a function that that does the equivalent 46 | of indexing ``1``. The returned function takes one argument — the state. 47 | 48 | >>> getitem_one_getter = getitem_one.get() 49 | >>> getitem_one_getter(data) 50 | 2 51 | 52 | We ran through this code quite slowly for explanation purposes, but 53 | there's no reason you can't do all of this on one line without all those 54 | intermediate variables, if you find that more useful: 55 | 56 | >>> lens[1].get()(data) 57 | 2 58 | 59 | Now, the above code was an awful lot of work just to do the equivalent 60 | of ``data[1]``. However, we can use this same lens to do other tasks. One 61 | thing we can do is create a function that can set our focus to some 62 | other value. We can do that with the ``set`` method. The ``set`` method 63 | takes a single argument that is the new value you want to set and, 64 | again, it returns a function that can do the task of setting. 65 | 66 | >>> getitem_one_set_to_four = getitem_one.set(4) 67 | >>> getitem_one_set_to_four(data) 68 | [1, 4, 3] 69 | 70 | It may seem like our ``getitem_one_set_to_four`` function does the 71 | equivalent of ``data[1] = 4``, but this is not quite true. The setter 72 | function we produced is actually an immutable setter; it takes an 73 | old state and produces a new state with the focus set to a different 74 | value. The original state remains unchanged: 75 | 76 | >>> data 77 | [1, 2, 3] 78 | 79 | Lenses are especially well suited to working with nested data structures. 80 | Here we have a two dimensional list: 81 | 82 | >>> data = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] 83 | 84 | To access that ``8`` we simply create a lens and "walk" to the focus the same 85 | way we would without the lens: 86 | 87 | >>> two_one_lens = lens[2][1] 88 | >>> two_one_lens.get()(data) 89 | 8 90 | >>> two_one_lens.set(10)(data) 91 | [[1, 2, 3], [4, 5, 6], [7, 10, 9]] 92 | 93 | Lenses are smart enough to only make copies of the parts of our state 94 | that we need to change. The third sublist is a different list from the 95 | one in the original because it has different contents, but the first 96 | and second sublists are reused to save time and memory: 97 | 98 | >>> new_data = _ 99 | >>> data[0] is new_data[0] 100 | True 101 | >>> data[1] is new_data[1] 102 | True 103 | >>> data[2] is new_data[2] 104 | False 105 | 106 | Lenses support more than just lists. Any mutable python object that can 107 | by copied with ``copy.copy`` will work. Immutable objects need special 108 | support, but support for any python object can be added so long as you 109 | know how to construct a new version of that object with the appropriate 110 | data changed. ``tuples`` and ``namedtuples`` are supported out of the box. 111 | 112 | Here's an example using a tuple: 113 | 114 | >>> data = 1, 2, 3 115 | >>> lens[1].get()(data) 116 | 2 117 | >>> lens[1].set(4)(data) 118 | (1, 4, 3) 119 | 120 | Here's a dictionary: 121 | 122 | >>> data = {'hello': 'world'} 123 | >>> lens['hello'].get()(data) 124 | 'world' 125 | >>> lens['hello'].set('everyone')(data) 126 | {'hello': 'everyone'} 127 | 128 | So far we have only created lenses by indexing, but we can also access 129 | attributes. Here we focus the ``contents`` attribute of a custom ``Container`` 130 | class: 131 | 132 | >>> class Container(object): 133 | ... def __init__(self, contents): 134 | ... self.contents = contents 135 | ... def __repr__(self): 136 | ... return 'Container({!r})'.format(self.contents) 137 | >>> data = Container(1) 138 | >>> lens.contents.set(2)(data) 139 | Container(2) 140 | 141 | Of course, nesting all of these things also works. In this example we 142 | change a value in a dictionary, which is an attribute of our custom class, 143 | which is one of the elements in a tuple: 144 | 145 | >>> data = (0, Container({'hello': 'world'})) 146 | >>> lens[1].contents['hello'].set('everyone')(data) 147 | (0, Container({'hello': 'everyone'})) 148 | 149 | Getting and setting a focus inside a state is pretty neat. But most 150 | of the time, when you are accessing data, you want to set the new data 151 | based on the old value. You *could* get the value, do your computation, 152 | and the set the new value like this: 153 | 154 | >>> data = [1, 2, 3] 155 | >>> my_lens = lens[1] 156 | >>> value = my_lens.get()(data) 157 | >>> my_lens.set(value * 10)(data) 158 | [1, 20, 3] 159 | 160 | Fortunately, this kind of operation is so common that lenses support 161 | it natively. If you have a function that you want to call on your focus 162 | then you can do that with the ``modify`` method: 163 | 164 | >>> data = [1, -2, 3] 165 | >>> lens[1].modify(abs)(data) 166 | [1, 2, 3] 167 | 168 | You can, of course, use a ``lambda`` if you need a function on-demand: 169 | 170 | >>> data = [1, 2, 3] 171 | >>> lens[1].modify(lambda n: n * 10)(data) 172 | [1, 20, 3] 173 | 174 | Often times, the function that we want to call on our focus is actually 175 | one of the focus's methods. To call a method on the focus, we can use the 176 | ``call`` method. It takes a string with the name of the method to call. 177 | 178 | >>> data = ['one', 'two', 'three'] 179 | >>> lens[1].call('upper')(data) 180 | ['one', 'TWO', 'three'] 181 | 182 | The method that you are calling **must** return the new focus that you want 183 | to appear in the new state. Many methods work by mutating their data. 184 | Such methods will not work the way you expect with ``call``: 185 | 186 | >>> data = [1, [3, 4, 2], 5] 187 | >>> lens[1].call('sort')(data) 188 | [1, None, 5] 189 | 190 | Furthermore, any mutation that method performs will surface in the 191 | original state: 192 | 193 | >>> data 194 | [1, [2, 3, 4], 5] 195 | 196 | You can still call such methods safely by using lens's ``call_mut`` method. 197 | The ``call_mut`` method works by making a deep copy of the focus before 198 | calling anything on it. 199 | 200 | >>> data = [1, [3, 4, 2], 5] 201 | >>> lens[1].call_mut('sort')(data) 202 | [1, [2, 3, 4], 5] 203 | 204 | If you can be sure that the method you want to call will only mutate 205 | the focus itself and not any of its sub-data then you can pass a 206 | ``shallow=True`` keyword argument to ``call_mut`` and it will only make a 207 | shallow copy. 208 | 209 | >>> data = [1, [3, 4, 2], 5] 210 | >>> lens[1].call_mut('sort', shallow=True)(data) 211 | [1, [2, 3, 4], 5] 212 | 213 | You can pass extra arguments to both ``call`` and ``call_mut`` and they will 214 | be forwarded on: 215 | 216 | >>> data = [1, 2, 3] 217 | >>> lens[1].call('__mul__', 10)(data) 218 | [1, 20, 3] 219 | 220 | Since wanting to call an object's dunder methods is so common, lenses 221 | will also pass most operators through to the data they're focused on. This 222 | can make using lenses in your code much more readable: 223 | 224 | >>> data = [1, 2, 3] 225 | >>> index_one_times_ten = lens[1] * 10 226 | >>> index_one_times_ten(data) 227 | [1, 20, 3] 228 | 229 | The only operator that you can't use in this way is ``&`` (the *bitwise and* 230 | operator, magic method ``__and__``). Lenses reserve this for something else. 231 | If you wish to ``&`` your focus, you can use the ``bitwise_and`` method instead. 232 | 233 | Lenses work best when you have to manipulate highly nested data 234 | structures that hold a great deal of state, such as when programming 235 | games: 236 | 237 | >>> from collections import namedtuple 238 | >>> 239 | >>> GameState = namedtuple('GameState', 240 | ... 'current_world current_level worlds') 241 | >>> World = namedtuple('World', 'theme levels') 242 | >>> Level = namedtuple('Level', 'map enemies') 243 | >>> Enemy = namedtuple('Enemy', 'x y') 244 | >>> 245 | >>> data = GameState(1, 2, { 246 | ... 1: World('grassland', {}), 247 | ... 2: World('desert', { 248 | ... 1: Level({}, { 249 | ... 'goomba1': Enemy(100, 45), 250 | ... 'goomba2': Enemy(130, 45), 251 | ... 'goomba3': Enemy(160, 45), 252 | ... }), 253 | ... }), 254 | ... }) 255 | >>> 256 | >>> new_data = (lens.worlds[2].levels[1].enemies['goomba3'].x + 1)(data) 257 | 258 | With the structure above, that last line of code produces a new 259 | ``GameState`` object where the third enemy on the first level of the 260 | second world has been moved across by one pixel without any of the 261 | objects in the original state being mutated. Without lenses this would 262 | take a rather large amount of plumbing to achieve. 263 | -------------------------------------------------------------------------------- /lenses/optics/traversals.py: -------------------------------------------------------------------------------- 1 | from typing import Pattern 2 | 3 | from .. import hooks 4 | from .base import Traversal 5 | 6 | 7 | class EachTraversal(Traversal): 8 | """A traversal that iterates over its state, focusing everything it 9 | iterates over. It uses `lenses.hooks.fromiter` to reform the state 10 | afterwards so it should work with any iterable that function 11 | supports. Analogous to `iter`. 12 | 13 | >>> from lenses import lens 14 | >>> state = [1, 2, 3] 15 | >>> EachTraversal() 16 | EachTraversal() 17 | >>> EachTraversal().to_list_of(state) 18 | [1, 2, 3] 19 | >>> EachTraversal().over(state, lambda n: n + 1) 20 | [2, 3, 4] 21 | 22 | For technical reasons, this lens iterates over dictionaries by their 23 | items and not just their keys. 24 | 25 | >>> state = {'one': 1} 26 | >>> EachTraversal().to_list_of(state) 27 | [('one', 1)] 28 | """ 29 | 30 | def __init__(self): 31 | pass 32 | 33 | def folder(self, state): 34 | return hooks.to_iter(state) 35 | 36 | def builder(self, state, values): 37 | return hooks.from_iter(state, values) 38 | 39 | def __repr__(self): 40 | return "EachTraversal()" 41 | 42 | 43 | class GetZoomAttrTraversal(Traversal): 44 | """A traversal that focuses an attribute of an object, though if 45 | that attribute happens to be a lens it will zoom the lens. This 46 | is used internally to make lenses that are attributes of objects 47 | transparent. If you already know whether you are focusing a lens or 48 | a non-lens you should be explicit and use a ZoomAttrTraversal or a 49 | GetAttrLens respectively. 50 | """ 51 | 52 | def __init__(self, name): 53 | from lenses.optics import GetattrLens 54 | 55 | self.name = name 56 | self._getattr_cache = GetattrLens(name) 57 | 58 | def func(self, f, state): 59 | attr = getattr(state, self.name) 60 | try: 61 | sublens = attr._optic 62 | except AttributeError: 63 | sublens = self._getattr_cache 64 | return sublens.func(f, state) 65 | 66 | def __repr__(self): 67 | return "GetZoomAttrTraversal({!r})".format(self.name) 68 | 69 | 70 | class ItemsTraversal(Traversal): 71 | """A traversal focusing key-value tuples that are the items of a 72 | dictionary. Analogous to `dict.items`. 73 | 74 | >>> from collections import OrderedDict 75 | >>> state = OrderedDict([(1, 10), (2, 20)]) 76 | >>> ItemsTraversal() 77 | ItemsTraversal() 78 | >>> ItemsTraversal().to_list_of(state) 79 | [(1, 10), (2, 20)] 80 | >>> ItemsTraversal().over(state, lambda n: (n[0], n[1] + 1)) 81 | OrderedDict([(1, 11), (2, 21)]) 82 | """ 83 | 84 | def __init__(self): 85 | pass 86 | 87 | def folder(self, state): 88 | return state.items() 89 | 90 | def builder(self, state, values): 91 | data = state.copy() 92 | data.clear() 93 | data.update(v for v in values if v is not None) 94 | return data 95 | 96 | def __repr__(self): 97 | return "ItemsTraversal()" 98 | 99 | 100 | class RecurTraversal(Traversal): 101 | """A traversal that recurses through an object focusing everything it 102 | can find of a particular type. This traversal will probe arbitrarily 103 | deep into the contents of the state looking for sub-objects. It 104 | uses some naughty tricks to do this including looking at an object's 105 | `__dict__` attribute. 106 | 107 | It is somewhat analogous to haskell's uniplate optic. 108 | 109 | >>> RecurTraversal(int) 110 | RecurTraversal(<... 'int'>) 111 | >>> data = [[1, 2, 100.0], [3, 'hello', [{}, 4], 5]] 112 | >>> RecurTraversal(int).to_list_of(data) 113 | [1, 2, 3, 4, 5] 114 | >>> class Container(object): 115 | ... def __init__(self, contents): 116 | ... self.contents = contents 117 | ... def __repr__(self): 118 | ... return 'Container({!r})'.format(self.contents) 119 | >>> data = [Container(1), 2, Container(Container(3)), [4, 5]] 120 | >>> RecurTraversal(int).over(data, lambda n: n+1) 121 | [Container(2), 3, Container(Container(4)), [5, 6]] 122 | >>> RecurTraversal(Container).to_list_of(data) 123 | [Container(1), Container(Container(3))] 124 | 125 | Be careful with this; it can focus things you might not expect. 126 | """ 127 | 128 | def __init__(self, cls): 129 | self.cls = cls 130 | self._builder_cache = {} 131 | 132 | def folder(self, state): 133 | if isinstance(state, self.cls): 134 | yield state 135 | elif self.can_iter(state): 136 | for substate in hooks.to_iter(state): 137 | for focus in self.folder(substate): 138 | yield focus 139 | elif hasattr(state, "__dict__"): 140 | for attr in sorted(state.__dict__): 141 | substate = getattr(state, attr) 142 | for focus in self.folder(substate): 143 | yield focus 144 | 145 | def builder(self, state, values): 146 | assert self._builder_cache == {} 147 | result = self.build_object(state, values) 148 | self._builder_cache = {} 149 | return result 150 | 151 | def build_object(self, state, values): 152 | if not self.can_hash(state): 153 | return self.build_object_no_cache(state, values) 154 | 155 | guard = object() 156 | cache = self._builder_cache.get(state, guard) 157 | if cache is not guard: 158 | return cache 159 | result = self.build_object_no_cache(state, values) 160 | self._builder_cache[state] = result 161 | return result 162 | 163 | def build_object_no_cache(self, state, values): 164 | if isinstance(state, self.cls): 165 | assert len(values) == 1 166 | return values[0] 167 | elif self.can_iter(state): 168 | return self.build_from_iter(state, values) 169 | elif hasattr(state, "__dict__"): 170 | return self.build_dunder_dict(state, values) 171 | else: 172 | return state 173 | 174 | def build_from_iter(self, state, values): 175 | new_substates = [] 176 | for substate in hooks.to_iter(state): 177 | count = len(list(self.folder(substate))) 178 | new_substate = substate 179 | if count: 180 | subvalues, values = values[:count], values[count:] 181 | new_substate = self.build_object(substate, subvalues) 182 | new_substates.append(new_substate) 183 | 184 | assert len(values) == 0 185 | new_state = hooks.from_iter(state, new_substates) 186 | return new_state 187 | 188 | def build_dunder_dict(self, state, values): 189 | new_state = state 190 | for attr in sorted(state.__dict__): 191 | substate = getattr(state, attr) 192 | count = len(list(self.folder(substate))) 193 | if count: 194 | subvalues, values = values[:count], values[count:] 195 | new_substate = self.build_object(substate, subvalues) 196 | new_state = hooks.setattr(new_state, attr, new_substate) 197 | assert len(values) == 0 198 | return new_state 199 | 200 | @staticmethod 201 | def can_iter(state): 202 | # characters appear iterable because they are just strings, 203 | # but if we actually try to iterate over them then we enter 204 | # infinite recursion 205 | if isinstance(state, str) and len(state) == 1: 206 | return False 207 | 208 | from_types = set(hooks.from_iter.registry.keys()) - {object} 209 | can_from = any(isinstance(state, type_) for type_ in from_types) 210 | return can_from 211 | 212 | @staticmethod 213 | def can_hash(state): 214 | try: 215 | hash(state) 216 | except TypeError: 217 | return False 218 | else: 219 | return True 220 | 221 | def __repr__(self): 222 | return "RecurTraversal({!r})".format(self.cls) 223 | 224 | 225 | class RegexTraversal(Traversal): 226 | """A traversal that uses a regex to focus parts of a string.""" 227 | 228 | def __init__(self, pattern: Pattern, flags: int) -> None: 229 | self.pattern = pattern 230 | self.flags = flags 231 | 232 | def folder(self, state): 233 | import re 234 | 235 | for match in re.finditer(self.pattern, state, flags=self.flags): 236 | yield match.group(0) 237 | 238 | def builder(self, state, values): 239 | import re 240 | 241 | iterator = iter(values) 242 | return re.sub(self.pattern, lambda _: next(iterator), state, flags=self.flags) 243 | 244 | def __repr__(self) -> str: 245 | return f"RegexTraversal({self.pattern}, flags={self.flags})" 246 | 247 | 248 | class ZoomAttrTraversal(Traversal): 249 | """A lens that looks up an attribute on its target and follows it as 250 | if were a bound `Lens` object. Ignores the state, if any, of the 251 | lens that is being looked up. 252 | """ 253 | 254 | def __init__(self, name: str) -> None: 255 | self.name = name 256 | 257 | def func(self, f, state): 258 | optic = getattr(state, self.name)._optic 259 | return optic.func(f, state) 260 | 261 | def __repr__(self): 262 | return "ZoomAttrTraversal({!r})".format(self.name) 263 | 264 | 265 | class ZoomTraversal(Traversal): 266 | """Follows its state as if it were a bound `Lens` object. 267 | 268 | >>> from lenses import bind 269 | >>> ZoomTraversal() 270 | ZoomTraversal() 271 | >>> state = bind([1, 2])[1] 272 | >>> ZoomTraversal().view(state) 273 | 2 274 | >>> ZoomTraversal().set(state, 3) 275 | [1, 3] 276 | """ 277 | 278 | def __init__(self): 279 | pass 280 | 281 | def func(self, f, state): 282 | return state._optic.func(f, state._state) 283 | 284 | def __repr__(self): 285 | return "ZoomTraversal()" 286 | -------------------------------------------------------------------------------- /lenses/ui/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Iterable, List, Optional, overload, Type, TypeVar 2 | 3 | from .. import optics 4 | from .base import BaseUiLens 5 | from .state_func import StateFunction 6 | 7 | S = TypeVar("S") 8 | T = TypeVar("T") 9 | A = TypeVar("A") 10 | B = TypeVar("B") 11 | X = TypeVar("X") 12 | Y = TypeVar("Y") 13 | 14 | 15 | class UnboundLens(BaseUiLens[S, T, A, B]): 16 | "An unbound lens is a lens that has not been bound to any state." 17 | 18 | __slots__ = ("_optic",) 19 | 20 | def __init__(self, optic): 21 | self._optic = optic 22 | 23 | def __repr__(self) -> str: 24 | return "UnboundLens({!r})".format(self._optic) 25 | 26 | def get(self) -> StateFunction[S, B]: 27 | """Get the first value focused by the lens. 28 | 29 | >>> from lenses import lens 30 | >>> getter = lens.get() 31 | >>> getter([1, 2, 3]) 32 | [1, 2, 3] 33 | >>> zero_item_getter = lens[0].get() 34 | >>> zero_item_getter([1, 2, 3]) 35 | 1 36 | """ 37 | 38 | def getter(state): 39 | return self._optic.to_list_of(state)[0] 40 | 41 | return StateFunction(getter) 42 | 43 | def collect(self) -> StateFunction[S, List[B]]: 44 | """Get multiple values focused by the lens. Returns them as 45 | a list. 46 | 47 | >>> from lenses import lens 48 | >>> collect_each_first = lens.Each()[0].collect() 49 | >>> collect_each_first([(1, 2), (3, 4), (5, 6)]) 50 | [1, 3, 5] 51 | """ 52 | 53 | def getter(state): 54 | return self._optic.to_list_of(state) 55 | 56 | return StateFunction(getter) 57 | 58 | def get_monoid(self) -> StateFunction[S, B]: 59 | """Get the values focused by the lens, merging them together by 60 | treating them as a monoid. See `lenses.typeclass.mappend`. 61 | 62 | >>> from lenses import lens 63 | >>> get_each_monoidally = lens.Each().get_monoid() 64 | >>> get_each_monoidally([[], [1], [2, 3]]) 65 | [1, 2, 3] 66 | """ 67 | 68 | def getter(state): 69 | return self._optic.view(state) 70 | 71 | return StateFunction(getter) 72 | 73 | def set(self, newvalue: B) -> StateFunction[S, T]: 74 | """Set the focus to `newvalue`. 75 | 76 | >>> from lenses import lens 77 | >>> set_item_one_to_four = lens[1].set(4) 78 | >>> set_item_one_to_four([1, 2, 3]) 79 | [1, 4, 3] 80 | """ 81 | 82 | def setter(state): 83 | return self._optic.set(state, newvalue) 84 | 85 | return StateFunction(setter) 86 | 87 | def set_many(self, new_values: Iterable[B]) -> StateFunction[S, T]: 88 | """Set many foci to values taken by iterating over `new_values`. 89 | 90 | >>> from lenses import lens 91 | >>> lens.Each().set_many(range(4, 7))([0, 1, 2]) 92 | [4, 5, 6] 93 | """ 94 | 95 | def setter_many(state): 96 | return self._optic.iterate(state, new_values) 97 | 98 | return StateFunction(setter_many) 99 | 100 | def modify(self, func: Callable[[A], B]) -> StateFunction[S, T]: 101 | """Apply a function to the focus. 102 | 103 | >>> from lenses import lens 104 | >>> convert_item_one_to_string = lens[1].modify(str) 105 | >>> convert_item_one_to_string([1, 2, 3]) 106 | [1, '2', 3] 107 | >>> add_ten_to_item_one = lens[1].modify(lambda n: n + 10) 108 | >>> add_ten_to_item_one([1, 2, 3]) 109 | [1, 12, 3] 110 | """ 111 | 112 | def modifier(state): 113 | return self._optic.over(state, func) 114 | 115 | return StateFunction(modifier) 116 | 117 | def construct(self, focus: A) -> S: 118 | """Construct a state given a focus.""" 119 | return self._optic.re().view(focus) 120 | 121 | def flip(self) -> "UnboundLens[A, B, S, T]": 122 | """Flips the direction of the lens. The lens must be unbound 123 | and all the underlying operations must be isomorphisms. 124 | 125 | >>> from lenses import lens 126 | >>> json_encoder = lens.Decode().Json().flip() 127 | >>> json_encode = json_encoder.get() 128 | >>> json_encode(['hello', 'world']) 129 | b'["hello", "world"]' 130 | """ 131 | return UnboundLens(self._optic.re()) 132 | 133 | @overload 134 | def __and__(self, other: "UnboundLens[A, B, X, Y]") -> "UnboundLens[S, T, X, Y]": 135 | ... 136 | 137 | @overload 138 | def __and__(self, other: Callable[[A], B]) -> StateFunction[A, B]: 139 | ... 140 | 141 | def __and__(self, other): 142 | """Refine the current focus of this lens by composing it with 143 | another lens object. The other lens must be unbound. 144 | 145 | >>> from lenses import lens 146 | >>> first = lens[0] 147 | >>> second = lens[1] 148 | >>> second_first = second & first 149 | >>> get_second_then_first = second_first.get() 150 | >>> get_second_then_first([[0, 1], [2, 3]]) 151 | 2 152 | """ 153 | if callable(other): 154 | return self.modify(other) 155 | if not isinstance(other, UnboundLens): 156 | message = "Cannot compose lens of type {!r}." 157 | raise TypeError(message.format(type(other))) 158 | return self._compose_optic(other._optic) 159 | 160 | def __get__(self, instance: Optional[S], owner: Type) -> BaseUiLens[S, T, A, B]: 161 | if instance is None: 162 | return self 163 | return BoundLens(instance, self._optic) 164 | 165 | def _compose_optic(self, optic: optics.LensLike) -> "UnboundLens[S, T, X, Y]": 166 | """Internal method. Do not use.""" 167 | return UnboundLens(self._optic.compose(optic)) 168 | 169 | def _wrap_optic( 170 | self, optic: Callable[[optics.LensLike], optics.LensLike] 171 | ) -> "UnboundLens[S, T, X, Y]": 172 | """Internal method. Do not use.""" 173 | return UnboundLens(optic(self._optic)) 174 | 175 | def kind(self) -> str: 176 | 'Returns the "kind" of the lens.' 177 | return self._optic.kind().__name__ 178 | 179 | add_lens = __and__ 180 | 181 | 182 | class BoundLens(BaseUiLens[S, T, A, B]): 183 | "A bound lens is a lens that has been bound to a specific state." 184 | 185 | __slots__ = ("_state", "_optic") 186 | 187 | def __init__(self, state: S, optic: optics.LensLike) -> None: 188 | self._state = state 189 | self._optic = optic 190 | 191 | def __repr__(self) -> str: 192 | return "BoundLens({!r}, {!r})".format(self._state, self._optic) 193 | 194 | def get(self) -> B: 195 | """Get the first value focused by the lens. 196 | 197 | >>> from lenses import bind 198 | >>> bind([1, 2, 3]).get() 199 | [1, 2, 3] 200 | >>> bind([1, 2, 3])[0].get() 201 | 1 202 | """ 203 | return self._optic.to_list_of(self._state)[0] 204 | 205 | def collect(self) -> List[B]: 206 | """Get multiple values focused by the lens. Returns them as 207 | a list. 208 | 209 | >>> from lenses import bind 210 | >>> bind([(1, 2), (3, 4), (5, 6)]).Each()[0].collect() 211 | [1, 3, 5] 212 | """ 213 | return self._optic.to_list_of(self._state) 214 | 215 | def get_monoid(self) -> B: 216 | """Get the values focused by the lens, merging them together by 217 | treating them as a monoid. See `lenses.typeclass.mappend`. 218 | 219 | >>> from lenses import bind 220 | >>> bind([[], [1], [2, 3]]).Each().get_monoid() 221 | [1, 2, 3] 222 | """ 223 | return self._optic.view(self._state) 224 | 225 | def set(self, newvalue: B) -> T: 226 | """Set the focus to `newvalue`. 227 | 228 | >>> from lenses import bind 229 | >>> bind([1, 2, 3])[1].set(4) 230 | [1, 4, 3] 231 | """ 232 | return self._optic.set(self._state, newvalue) 233 | 234 | def set_many(self, new_values: Iterable[B]) -> T: 235 | """Set many foci to values taken by iterating over `new_values`. 236 | 237 | >>> from lenses import bind 238 | >>> bind([0, 1, 2]).Each().set_many(range(4, 7)) 239 | [4, 5, 6] 240 | """ 241 | 242 | return self._optic.iterate(self._state, new_values) 243 | 244 | def modify(self, func: Callable[[A], B]) -> T: 245 | """Apply a function to the focus. 246 | 247 | >>> from lenses import bind 248 | >>> bind([1, 2, 3])[1].modify(str) 249 | [1, '2', 3] 250 | >>> bind([1, 2, 3])[1].modify(lambda n: n + 10) 251 | [1, 12, 3] 252 | """ 253 | return self._optic.over(self._state, func) 254 | 255 | @overload 256 | def __and__(self, other: "UnboundLens[A, B, X, Y]") -> "UnboundLens[S, T, X, Y]": 257 | ... 258 | 259 | @overload 260 | def __and__(self, other: Callable[[A], B]) -> B: 261 | ... 262 | 263 | def __and__(self, other): 264 | """Refine the current focus of this lens by composing it with 265 | another lens object. The other lens must be unbound. 266 | 267 | >>> from lenses import lens, bind 268 | >>> first = lens[0] 269 | >>> second = bind([[0, 1], [2, 3]])[1] 270 | >>> (second & first).get() 271 | 2 272 | """ 273 | if callable(other): 274 | return self.modify(other) 275 | if not isinstance(other, UnboundLens): 276 | message = "Cannot compose lens of type {!r}." 277 | raise TypeError(message.format(type(other))) 278 | return self._compose_optic(other._optic) 279 | 280 | def _compose_optic(self, optic: optics.LensLike) -> "BoundLens[S, T, X, Y]": 281 | return BoundLens(self._state, self._optic.compose(optic)) 282 | 283 | def _wrap_optic( 284 | self, optic: Callable[[optics.LensLike], optics.LensLike] 285 | ) -> "BoundLens[S, T, X, Y]": 286 | return BoundLens(self._state, optic(self._optic)) 287 | 288 | def kind(self) -> str: 289 | 'Returns the "kind" of the lens.' 290 | return self._optic.kind().__name__ 291 | 292 | add_lens = __and__ 293 | 294 | 295 | __all__ = ["UnboundLens", "BoundLens"] 296 | -------------------------------------------------------------------------------- /tests/test_lens.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import dataclasses 3 | 4 | import pytest 5 | 6 | from lenses import bind, lens, maybe 7 | 8 | 9 | # Tests for using Lens' standard methods 10 | def test_lens_get(): 11 | assert lens.get()(10) == 10 12 | assert lens[1].get()([1, 2, 3]) == 2 13 | 14 | 15 | def test_lens_collect(): 16 | assert lens.Each()[1].collect()([[1, 2], [3, 4]]) == [2, 4] 17 | 18 | 19 | def test_lens_get_monoid(): 20 | assert lens.Each().get_monoid()([[1, 2], [3, 4]]) == [1, 2, 3, 4] 21 | 22 | 23 | def test_lens_set(): 24 | assert lens.set(5)(10) == 5 25 | assert lens[1].set(5)([1, 2, 3]) == [1, 5, 3] 26 | 27 | 28 | def test_lens_set_and(): 29 | assert (10 & lens.set(5)) == 5 30 | assert ([1, 2, 3] & lens[1].set(5)) == [1, 5, 3] 31 | 32 | 33 | def test_lens_set_and_assign(): 34 | state = [1, 2, 3] 35 | state &= lens[1].set(5) 36 | assert state == [1, 5, 3] 37 | 38 | 39 | def test_lens_modify(): 40 | assert lens.modify(lambda a: a + 1)(10) == 11 41 | assert lens[0].modify(lambda a: a + 5)([1, 2, 3]) == [6, 2, 3] 42 | 43 | 44 | def test_lens_modify_and(): 45 | assert (10 & lens.modify(lambda a: a + 1)) == 11 46 | assert ([1, 2, 3] & lens[0].modify(lambda a: a + 5)) == [6, 2, 3] 47 | 48 | 49 | def test_lens_modify_and_assign(): 50 | state = [1, -2, -3] 51 | state &= lens[1].modify(abs) 52 | assert state == [1, 2, -3] 53 | 54 | 55 | def test_lens_call(): 56 | assert lens.call("upper")("hello") == "HELLO" 57 | 58 | 59 | def test_lens_call_implicitly(): 60 | assert lens.call_upper()("hello") == "HELLO" 61 | 62 | 63 | def test_lens_call_args(): 64 | assert lens.call("center", 5)("h") == " h " 65 | 66 | 67 | def test_lens_call_args_implicitly(): 68 | assert lens.call_center(5)("h") == " h " 69 | 70 | 71 | def test_lens_call_kwargs(): 72 | assert lens.call("encode", encoding="utf-8")("h") == b"h" 73 | 74 | 75 | def test_lens_call_kwargs_implicitly(): 76 | assert lens.call_encode(encoding="utf-8")("h") == b"h" 77 | 78 | 79 | def test_lens_call_mut(): 80 | assert lens.call_mut("sort")([3, 1, 2]) == [1, 2, 3] 81 | 82 | 83 | def test_lens_call_mut_implicitly(): 84 | assert lens.call_mut_sort()([3, 1, 2]) == [1, 2, 3] 85 | 86 | 87 | def test_lens_call_mut_args(): 88 | assert lens.call_mut("append", 3)([1, 2]) == [1, 2, 3] 89 | 90 | 91 | def test_lens_call_mut_args_implicitly(): 92 | assert lens.call_mut_append(3)([1, 2]) == [1, 2, 3] 93 | 94 | 95 | def test_lens_call_mut_kwargs(): 96 | result = lens.call_mut("sort", key=len)(["eine", "un", "one"]) 97 | assert result == ["un", "one", "eine"] 98 | 99 | 100 | def test_lens_call_mut_kwargs_implicitly(): 101 | result = lens.call_mut_sort(key=len)(["eine", "un", "one"]) 102 | assert result == ["un", "one", "eine"] 103 | 104 | 105 | def test_lens_call_mut_deep(): 106 | state = [object(), object()] 107 | result = lens.call_mut("append", object())(state) 108 | assert result[0] is not state[0] 109 | 110 | 111 | def test_lens_call_mut_shallow(): 112 | state = [object(), object()] 113 | result = lens.call_mut("append", object(), shallow=True)(state) 114 | assert result[0] is state[0] 115 | 116 | 117 | def test_lens_construct(): 118 | obj = object() 119 | assert lens.Just().construct(obj) == maybe.Just(obj) 120 | 121 | 122 | def test_lens_construct_composed(): 123 | obj = object() 124 | assert lens.Just().Iso(int, str).construct(obj) == maybe.Just(str(obj)) 125 | 126 | 127 | def test_lens_add_lens_trivial_lens(): 128 | my_lens = bind([1, 2]) & lens 129 | assert my_lens + [3] == [1, 2, 3] 130 | 131 | 132 | def test_lens_add_lens_nontrivial_lens(): 133 | my_lens = bind([1, 2]) & lens[1] 134 | assert my_lens.set(3) == [1, 3] 135 | 136 | 137 | def test_lens_add_lens_bound_lens(): 138 | with pytest.raises(TypeError): 139 | bind([1, 2]) & bind(1) 140 | 141 | 142 | def test_lens_add_lens_invalid(): 143 | with pytest.raises(TypeError): 144 | bind([1, 2]) & 1 145 | 146 | 147 | def test_unbound_lens_add_lens_trivial_lens(): 148 | my_lens = lens & lens 149 | assert (my_lens + [3])([1, 2]) == [1, 2, 3] 150 | 151 | 152 | def test_unbound_lens_add_lens_nontrivial_lens(): 153 | my_lens = lens & lens[1] 154 | assert my_lens.set(3)([1, 2]) == [1, 3] 155 | 156 | 157 | def test_unbound_lens_add_lens_bound_lens(): 158 | with pytest.raises(TypeError): 159 | lens & bind(1) 160 | 161 | 162 | def test_unbound_lens_add_lens_invalid(): 163 | with pytest.raises(TypeError): 164 | lens & 1 165 | 166 | 167 | def test_lens_add_lens_bad_lens(): 168 | with pytest.raises(TypeError): 169 | bind([1, 2]) & 1 170 | 171 | 172 | def test_lens_flip(): 173 | flipped = lens.Iso(str, int).flip() 174 | assert flipped.get()("1") == 1 175 | 176 | 177 | def test_lens_flip_composed(): 178 | flipped = lens.Decode().Json().flip() 179 | assert flipped.get()([1, 2, 3]) == b"[1, 2, 3]" 180 | 181 | 182 | def test_lens_flip_composed_not_isomorphism(): 183 | with pytest.raises(TypeError): 184 | lens.Decode()[0].flip() 185 | 186 | 187 | def test_lens_flip_not_isomorphism(): 188 | with pytest.raises(TypeError): 189 | lens[1].flip() 190 | 191 | 192 | def test_lens_descriptor(): 193 | class MyClass(object): 194 | def __init__(self, items): 195 | self._private_items = items 196 | 197 | def __eq__(self, other): 198 | return self._private_items == other._private_items 199 | 200 | first = lens._private_items[0] 201 | 202 | assert MyClass([1, 2, 3]).first.set(4) == MyClass([4, 2, 3]) 203 | 204 | 205 | def test_lens_descriptor_doesnt_bind_from_class(): 206 | class MyClass(object): 207 | def __init__(self, items): 208 | self._private_items = items 209 | 210 | def __eq__(self, other): 211 | return self._private_items == other._private_items 212 | 213 | first = lens._private_items[0] 214 | 215 | import lenses 216 | 217 | assert isinstance(MyClass.first, lenses.ui.UnboundLens) 218 | 219 | 220 | def test_lens_descriptor_zoom(): 221 | class MyClass(object): 222 | def __init__(self, items): 223 | self._private_items = items 224 | 225 | def __eq__(self, other): 226 | return self._private_items == other._private_items 227 | 228 | def __repr__(self): 229 | return "M({!r})".format(self._private_items) 230 | 231 | first = lens._private_items[0] 232 | 233 | data = (MyClass([1, 2, 3]),) 234 | assert bind(data)[0].first.get() == 1 235 | assert bind(data)[0].first.set(4) == (MyClass([4, 2, 3]),) 236 | 237 | 238 | def test_lens_unbound_and_no_state(): 239 | assert lens[1].get()([1, 2, 3]) == 2 240 | 241 | 242 | # Testing that Lens properly passes though dunder methods 243 | def test_lens_add(): 244 | assert bind(2) + 1 == 3 245 | assert bind([[1, 2], 3])[0] + [4] == [[1, 2, 4], 3] 246 | 247 | 248 | def test_lens_add_different_types(): 249 | assert bind(2) + 1.0 == 3.0 250 | 251 | 252 | def test_lens_radd(): 253 | assert 1 + bind(2) == 3 254 | 255 | 256 | def test_lens_subtract(): 257 | assert bind(2) - 1 == 1 258 | 259 | 260 | def test_lens_multiply(): 261 | assert bind(2) * 2 == 4 262 | assert bind([[1, 2], [3]])[1] * 3 == [[1, 2], [3, 3, 3]] 263 | 264 | 265 | def test_lens_divide(): 266 | assert bind(10) / 2 == 5 267 | 268 | 269 | def test_lens_neg(): 270 | assert -bind(1) == -1 271 | 272 | 273 | # Testing that you can use sublenses through Lens properly 274 | def test_lens_trivial(): 275 | assert bind(3).get() == 3 276 | 277 | 278 | def test_lens_getitem(): 279 | assert bind([1, 2, 3]).GetItem(1).get() == 2 280 | 281 | 282 | def test_lens_getitem_direct(): 283 | assert bind([1, 2, 3])[1].get() == 2 284 | 285 | 286 | def test_lens_getattr(): 287 | nt = collections.namedtuple("nt", "attr") 288 | assert bind(nt(3)).GetAttr("attr").get() == 3 289 | 290 | 291 | def test_lens_getattr_direct(): 292 | nt = collections.namedtuple("nt", "attr") 293 | assert bind(nt(3)).attr.get() == 3 294 | 295 | 296 | # Tests for ensuring lenses work on different type of objects 297 | def test_type_tuple(): 298 | assert bind(((0, 0), (0, 0)))[0][1].set(1) == ((0, 1), (0, 0)) 299 | 300 | 301 | def test_type_namedtuple(): 302 | Tup = collections.namedtuple("Tup", "attr") 303 | assert bind(Tup(0)).attr.set(1) == Tup(1) 304 | 305 | 306 | def test_type_list(): 307 | assert bind([[0, 1], [2, 3]])[1][0].set(4) == [[0, 1], [4, 3]] 308 | with pytest.raises(AttributeError): 309 | assert bind([[0, 1], [2, 3]]).attr.set(4) 310 | 311 | 312 | def test_type_dict(): 313 | assert bind({1: 2, 3: 4})[1].set(5) == {1: 5, 3: 4} 314 | with pytest.raises(AttributeError): 315 | assert bind({1: 2, 3: 4}).attr.set(5) 316 | 317 | 318 | def test_type_custom_class_copy_and_mutate(): 319 | class C(object): 320 | def __init__(self, a, b): 321 | self.a = a 322 | self.b = b 323 | 324 | def __eq__(self, other): 325 | return self.a == other.a and self.b == other.b 326 | 327 | assert bind(C(C(0, 1), C(2, 3))).a.b.set(4) == C(C(0, 4), C(2, 3)) 328 | 329 | 330 | def test_type_custom_class_lens_setattr(): 331 | class C(object): 332 | def __init__(self, a): 333 | self._a = a 334 | 335 | @property 336 | def a(self): 337 | return self._a 338 | 339 | def __eq__(self, other): 340 | return self.a == other.a 341 | 342 | def _lens_setattr(self, key, value): 343 | if key == "a": 344 | return C(value) 345 | 346 | assert bind(C(C(9))).a.a.set(4) == C(C(4)) 347 | 348 | 349 | def test_type_custom_class_immutable(): 350 | class C(object): 351 | def __init__(self, a): 352 | self._a = a 353 | 354 | @property 355 | def a(self): 356 | return self._a 357 | 358 | with pytest.raises(AttributeError): 359 | bind(C(9)).a.set(7) 360 | 361 | 362 | def test_type_custom_class_dataclass(): 363 | @dataclasses.dataclass 364 | class C: 365 | a: int 366 | b: str 367 | 368 | assert bind(C(1, "hello")).a.set(2) == C(2, "hello") 369 | 370 | 371 | def test_type_custom_class_frozen_dataclass(): 372 | @dataclasses.dataclass(frozen=True) 373 | class C: 374 | a: int 375 | b: str 376 | 377 | assert bind(C(1, "hello")).a.set(2) == C(2, "hello") 378 | 379 | 380 | def test_type_unsupported_no_setitem(): 381 | with pytest.raises(TypeError): 382 | bind(object())[0].set(None) 383 | 384 | 385 | def test_type_unsupported_no_setattr(): 386 | with pytest.raises(AttributeError): 387 | bind(object()).attr.set(None) 388 | 389 | 390 | # test various kinds 391 | def test_trivial_kind(): 392 | assert lens.kind() == "Isomorphism" 393 | 394 | 395 | def test_compose_iso_and_lens_kind(): 396 | assert (lens & lens[0]).kind() == "Lens" 397 | 398 | 399 | def test_compose_iso_and_fold_kind(): 400 | assert (lens & lens.Iter()).kind() == "Fold" 401 | 402 | 403 | def test_compose_lens_and_fold_kind(): 404 | assert (lens[0] & lens.Iter()).kind() == "Fold" 405 | 406 | 407 | def test_compose_iso_and_setter_kind(): 408 | assert (lens & lens.Fork(lens, lens)) == "Setter" 409 | 410 | 411 | def test_compose_lens_and_setter_kind(): 412 | assert (lens[0] & lens.Fork(lens, lens)) == "Setter" 413 | 414 | 415 | def test_compose_fold_and_setter_kind_errors(): 416 | with pytest.raises(RuntimeError): 417 | lens.Iter() & lens.Fork(lens, lens) 418 | 419 | 420 | def test_bound_iso_kind(): 421 | assert bind(True).kind() == "Isomorphism" 422 | 423 | 424 | # misc Lens tests 425 | def test_lens_informative_repr(): 426 | obj = object() 427 | assert repr(obj) in repr(bind(obj)) 428 | -------------------------------------------------------------------------------- /lenses/hooks/hook_funcs.py: -------------------------------------------------------------------------------- 1 | """This module contains functions that you can hook into to allow various 2 | lenses to operate on your own custom data structures. 3 | 4 | You can hook into them by defining a method that starts with 5 | ``_lens_`` followed by the name of the hook function. So, for 6 | example: the hook for ``lenses.hooks.contains_add`` is a method called 7 | ``_lens_contains_add``. This is the preferred way of hooking into this 8 | library because it does not require you to have the lenses library as 9 | a hard dependency. 10 | 11 | These functions are all decorated with ``singledispatch``, allowing 12 | you to customise the behaviour of types that you did not write. Be 13 | warned that single dispatch functions are registered globally across 14 | your program and that your function also needs to be able to deal with 15 | subclasses of any types you register (or else register separate functions 16 | for each subclass). 17 | 18 | All of these hooks operate in the following order: 19 | 20 | * Use an implementation registered with ``singledispatch.register`` 21 | for the relevant type, if one exists. 22 | * Use the relevant ``_lens_*`` method on the first object that was passed 23 | in, if it exists. 24 | * Use a default implementation that is likely to work for most python 25 | objects, if one exists. 26 | * Raise ``NotImplementedError``. 27 | """ 28 | 29 | import copy 30 | import dataclasses 31 | from builtins import setattr as builtin_setattr 32 | from functools import singledispatch 33 | from typing import ( 34 | Any, 35 | Dict, 36 | FrozenSet, 37 | Iterable, 38 | Iterator, 39 | List, 40 | NamedTuple, 41 | Set, 42 | Tuple, 43 | TypeVar, 44 | ) 45 | 46 | A = TypeVar("A") 47 | B = TypeVar("B") 48 | 49 | 50 | @singledispatch 51 | def setitem(self: Any, key: Any, value: Any) -> Any: 52 | """Takes an object, a key, and a value and produces a new object 53 | that is a copy of the original but with ``value`` as the new value of 54 | ``key``. 55 | 56 | The following equality should hold for your definition: 57 | 58 | .. code-block:: python 59 | 60 | setitem(obj, key, obj[key]) == obj 61 | 62 | This function is used by many lenses (particularly GetitemLens) to 63 | set items on states even when those states do not ordinarily support 64 | ``setitem``. This function is designed to have a similar signature 65 | as python's built-in ``setitem`` except that it returns a new object 66 | that has the item set rather than mutating the object in place. 67 | 68 | It's what enables the ``lens[some_key]`` functionality. 69 | 70 | The corresponding method call for this hook is 71 | ``obj._lens_setitem(key, value)``. 72 | 73 | The default implementation makes a copy of the object using 74 | ``copy.copy`` and then mutates the new object by setting the item 75 | on it in the conventional way. 76 | """ 77 | try: 78 | self._lens_setitem 79 | except AttributeError: 80 | selfcopy = copy.copy(self) 81 | selfcopy[key] = value 82 | return selfcopy 83 | else: 84 | return self._lens_setitem(key, value) 85 | 86 | 87 | @setitem.register(bytes) 88 | def _bytes_setitem(self: bytes, key: int, value: int) -> bytes: 89 | data = bytearray(self) 90 | data[key] = value 91 | return bytes(data) 92 | 93 | 94 | @setitem.register(str) 95 | def _str_setitem(self: str, key: int, value: str) -> str: 96 | data = list(self) 97 | data[key] = value 98 | return "".join(data) 99 | 100 | 101 | @setitem.register(tuple) 102 | def _tuple_setitem(self: Tuple[A, ...], key: int, value: A) -> Tuple[A, ...]: 103 | return tuple(value if i == key else item for i, item in enumerate(self)) 104 | 105 | 106 | @singledispatch 107 | def setattr(self: Any, name: Any, value: Any) -> Any: 108 | """Takes an object, a string, and a value and produces a new object 109 | that is a copy of the original but with the attribute called ``name`` 110 | set to ``value``. 111 | 112 | The following equality should hold for your definition: 113 | 114 | .. code-block:: python 115 | 116 | setattr(obj, 'attr', obj.attr) == obj 117 | 118 | This function is used by many lenses (particularly GetattrLens) to set 119 | attributes on states even when those states do not ordinarily support 120 | ``setattr``. This function is designed to have a similar signature 121 | as python's built-in ``setattr`` except that it returns a new object 122 | that has the attribute set rather than mutating the object in place. 123 | 124 | It's what enables the ``lens.some_attribute`` functionality. 125 | 126 | The corresponding method call for this hook is 127 | ``obj._lens_setattr(name, value)``. 128 | 129 | The default implementation makes a copy of the object using 130 | ``copy.copy`` and then mutates the new object by calling python's 131 | built in ``setattr`` on it. 132 | """ 133 | if dataclasses.is_dataclass(self) and not isinstance(self, type): 134 | return dataclasses.replace(self, **{name: value}) 135 | 136 | try: 137 | self._lens_setattr 138 | except AttributeError: 139 | selfcopy = copy.copy(self) 140 | builtin_setattr(selfcopy, name, value) 141 | return selfcopy 142 | else: 143 | return self._lens_setattr(name, value) 144 | 145 | 146 | @setattr.register(tuple) 147 | def _tuple_setattr_immutable(self: NamedTuple, name: str, value: A) -> NamedTuple: 148 | # setting attributes on a tuple probably means we really have a 149 | # namedtuple so we can use self._fields to understand the names 150 | data = (value if field == name else item for field, item in zip(self._fields, self)) 151 | return type(self)(*data) 152 | 153 | 154 | @singledispatch 155 | def contains_add(self: Any, item: Any) -> Any: 156 | """Takes a collection and an item and returns a new collection 157 | of the same type that contains the item. The notion of "contains" 158 | is defined by the object itself; The following must be ``True``: 159 | 160 | .. code-block:: python 161 | 162 | item in contains_add(obj, item) 163 | 164 | This function is used by some lenses (particularly ContainsLens) 165 | to add new items to containers when necessary. 166 | 167 | The corresponding method call for this hook is 168 | ``obj._lens_contains_add(item)``. 169 | 170 | There is no default implementation. 171 | """ 172 | try: 173 | self._lens_contains_add 174 | except AttributeError: 175 | message = "Don't know how to add an item to {}" 176 | raise NotImplementedError(message.format(type(self))) 177 | else: 178 | return self._lens_contains_add(item) 179 | 180 | 181 | @contains_add.register(list) 182 | def _list_contains_add(self: List[A], item: A) -> List[A]: 183 | return self + [item] 184 | 185 | 186 | @contains_add.register(tuple) 187 | def _tuple_contains_add(self: Tuple[A, ...], item: A) -> Tuple[A, ...]: 188 | return self + (item,) 189 | 190 | 191 | @contains_add.register(dict) 192 | def _dict_contains_add(self: Dict[A, Any], item: A) -> Dict[A, Any]: 193 | result = self.copy() 194 | result[item] = None 195 | return result 196 | 197 | 198 | @contains_add.register(set) 199 | def _set_contains_add(self: Set[A], item: A) -> Set[A]: 200 | return self | {item} 201 | 202 | 203 | @singledispatch 204 | def contains_remove(self: Any, item: Any) -> Any: 205 | """Takes a collection and an item and returns a new collection 206 | of the same type with that item removed. The notion of "contains" 207 | is defined by the object itself; the following must be ``True``: 208 | 209 | .. code-block:: python 210 | 211 | item not in contains_remove(obj, item) 212 | 213 | This function is used by some lenses (particularly ContainsLens) 214 | to remove items from containers when necessary. 215 | 216 | The corresponding method call for this hook is 217 | ``obj._lens_contains_remove(item)``. 218 | 219 | There is no default implementation. 220 | """ 221 | try: 222 | self._lens_contains_remove 223 | except AttributeError: 224 | message = "Don't know how to remove an item from {}" 225 | raise NotImplementedError(message.format(type(self))) 226 | else: 227 | return self._lens_contains_remove(item) 228 | 229 | 230 | @contains_remove.register(list) 231 | def _list_contains_remove(self: List[A], item: A) -> List[A]: 232 | return [x for x in self if x != item] 233 | 234 | 235 | @contains_remove.register(tuple) 236 | def _tuple_contains_remove(self: Tuple[A, ...], item: A) -> Tuple[A, ...]: 237 | return tuple(x for x in self if x != item) 238 | 239 | 240 | @contains_remove.register(dict) 241 | def _dict_contains_remove(self: Dict[A, B], item: A) -> Dict[A, B]: 242 | result = self.copy() 243 | del result[item] 244 | return result 245 | 246 | 247 | @contains_remove.register(set) 248 | def _set_contains_remove(self: Set[A], item: A) -> Set[A]: 249 | return self - {item} 250 | 251 | 252 | @singledispatch 253 | def to_iter(self: Any) -> Any: 254 | """Takes an object and produces an iterable. It is intended as the 255 | inverse of the ``from_iter`` function. 256 | 257 | The reason this hook exists is to customise how dictionaries are 258 | iterated. In order to properly reconstruct a dictionary from an 259 | iterable you need access to both the keys and the values. So this 260 | function iterates over dictionaries by thier items instead. 261 | 262 | The corresponding method call for this hook is 263 | ``obj._lens_to_iter()``. 264 | 265 | The default implementation is to call python's built in ``iter`` 266 | function. 267 | """ 268 | try: 269 | self._lens_to_iter 270 | except AttributeError: 271 | return iter(self) 272 | else: 273 | return self._lens_to_iter() 274 | 275 | 276 | @to_iter.register(dict) 277 | def _dict_to_iter(self: Dict[A, B]) -> Iterator[Tuple[A, B]]: 278 | return iter(self.items()) 279 | 280 | 281 | @singledispatch 282 | def from_iter(self: Any, iterable: Any) -> Any: 283 | """Takes an object and an iterable and produces a new object that is 284 | a copy of the original with data from ``iterable`` reincorporated. It 285 | is intended as the inverse of the ``to_iter`` function. Any state in 286 | ``self`` that is not modelled by the iterable should remain unchanged. 287 | 288 | The following equality should hold for your definition: 289 | 290 | .. code-block:: python 291 | 292 | from_iter(self, to_iter(self)) == self 293 | 294 | This function is used by EachLens to synthesise states from iterables, 295 | allowing it to focus every element of an iterable state. 296 | 297 | The corresponding method call for this hook is 298 | ``obj._lens_from_iter(iterable)``. 299 | 300 | There is no default implementation. 301 | """ 302 | try: 303 | self._lens_from_iter 304 | except AttributeError: 305 | message = "Don't know how to create instance of {} from iterable" 306 | raise NotImplementedError(message.format(type(self))) 307 | else: 308 | return self._lens_from_iter(iterable) 309 | 310 | 311 | @from_iter.register(bytes) 312 | def _bytes_from_iter(self: bytes, iterable: Iterable[int]) -> bytes: 313 | return bytes(iterable) 314 | 315 | 316 | @from_iter.register(str) 317 | def _str_from_iter(self: str, iterable: Iterable[str]) -> str: 318 | return "".join(iterable) 319 | 320 | 321 | @from_iter.register(dict) 322 | def _dict_from_iter(self: Dict, iterable: Iterable[Tuple[A, B]]) -> Dict[A, B]: 323 | new = self.copy() 324 | new.clear() 325 | new.update(iterable) 326 | return new 327 | 328 | 329 | @from_iter.register(list) 330 | def _list_from_iter(self: List, iterable: Iterable[A]) -> List[A]: 331 | return list(iterable) 332 | 333 | 334 | @from_iter.register(set) 335 | def _set_from_iter(self: Set, iterable: Iterable[A]) -> Set[A]: 336 | return set(iterable) 337 | 338 | 339 | @from_iter.register(frozenset) 340 | def _frozenset_from_iter(self: FrozenSet, iterable: Iterable[A]) -> FrozenSet[A]: 341 | return frozenset(iterable) 342 | 343 | 344 | @from_iter.register(tuple) 345 | def _tuple_from_iter(self, iterable): 346 | if type(self) is tuple: 347 | return tuple(iterable) 348 | elif hasattr(self, "_make"): 349 | # this is probably a namedtuple 350 | return self._make(iterable) 351 | else: 352 | message = "Don't know how to create instance of {} from iterable" 353 | raise NotImplementedError(message.format(type(self))) 354 | -------------------------------------------------------------------------------- /tests/test_optics.py: -------------------------------------------------------------------------------- 1 | import collections 2 | 3 | import pytest 4 | 5 | from lenses import bind, lens, optics as b 6 | from lenses.maybe import Just, Nothing 7 | 8 | # import hypothesis 9 | # import hypothesis.strategies as strat 10 | 11 | 12 | class CustomException(Exception): 13 | pass 14 | 15 | 16 | class Pair(object): 17 | def __init__(self, left, right): 18 | self.left = left 19 | self.right = right 20 | 21 | east = lens.left 22 | west = lens.right 23 | 24 | def __eq__(self, other): 25 | return ( 26 | type(self) is type(other) 27 | and self.left == other.left 28 | and self.right == other.right 29 | ) 30 | 31 | def __hash__(self): 32 | return hash(self.left) ^ hash(self.right) 33 | 34 | def __repr__(self): 35 | return "Pair({!r}, {!r})".format(self.left, self.right) 36 | 37 | 38 | def timer(function, *args, **kwargs): 39 | import time 40 | 41 | start = time.time() 42 | function(*args, **kwargs) 43 | end = time.time() 44 | return end - start 45 | 46 | 47 | def test_LensLike(): 48 | with pytest.raises(TypeError): 49 | b.LensLike() 50 | 51 | 52 | def test_LensLike_no_focus_raises(): 53 | with pytest.raises(ValueError): 54 | b.EachTraversal().view([]) 55 | 56 | 57 | def test_cannot_preview_with_setter(): 58 | with pytest.raises(TypeError): 59 | b.ForkedSetter(b.GetitemLens(0), b.GetitemLens(1)).preview([1, 2]) 60 | 61 | 62 | def test_cannot_to_list_of_with_setter(): 63 | with pytest.raises(TypeError): 64 | b.ForkedSetter(b.GetitemLens(0), b.GetitemLens(1)).to_list_of([1, 2]) 65 | 66 | 67 | def test_cannot_over_with_fold(): 68 | with pytest.raises(TypeError): 69 | b.IterableFold().over([1, 2, 3], lambda a: a + 1) 70 | 71 | 72 | def test_cannot_set_with_fold(): 73 | with pytest.raises(TypeError): 74 | b.IterableFold().set([1, 2, 3], 4) 75 | 76 | 77 | def test_cannot_re_with_fold(): 78 | with pytest.raises(TypeError): 79 | b.IterableFold().re() 80 | 81 | 82 | def test_composition_of_fold_and_setter_is_invalid(): 83 | with pytest.raises(RuntimeError): 84 | b.IterableFold() & b.ForkedSetter() 85 | 86 | 87 | def test_lens_and(): 88 | my_lens = b.EachTraversal() & b.GetitemLens(1) 89 | assert my_lens.set([(0, 1), (2, 3)], 4) == [(0, 4), (2, 4)] 90 | 91 | 92 | def test_getter_folder(): 93 | assert list(b.Getter(abs).folder(-1)) == [1] 94 | 95 | 96 | def test_prism_folder_success(): 97 | obj = object 98 | assert list(b.JustPrism().folder(Just(obj))) == [obj] 99 | 100 | 101 | def test_prism_folder_failure(): 102 | assert list(b.JustPrism().folder(Nothing())) == [] 103 | 104 | 105 | def test_Getter_composes_correctly(): 106 | visited = [] 107 | 108 | def visit(item): 109 | visited.append(item) 110 | return item 111 | 112 | my_lens = b.EachTraversal() & b.Getter(visit) & b.EachTraversal() 113 | my_lens.to_list_of(([1, 2, 3], [4, 5, 6], [7, 8, 9])) 114 | assert visited == [[1, 2, 3], [4, 5, 6], [7, 8, 9]] 115 | 116 | 117 | def test_ComposedLens_nolenses_view(): 118 | obj = object() 119 | assert b.ComposedLens([]).view(obj) is obj 120 | 121 | 122 | def test_ComposedLens_nolenses_set(): 123 | obj1, obj2 = object(), object() 124 | assert b.ComposedLens([]).set(obj1, obj2) is obj2 125 | 126 | 127 | def test_ComposedLens_nesting_simplifies(): 128 | assert b.ComposedLens([b.ComposedLens([])]).lenses == [] 129 | 130 | 131 | def test_ComposedLens_compose_simplifies(): 132 | composition = b.ComposedLens([]) 133 | assert isinstance(composition & composition, b.TrivialIso) 134 | 135 | 136 | def test_DecodeIso_view(): 137 | assert b.DecodeIso().view(b"hello") == "hello" 138 | 139 | 140 | def test_DecodeIso_view_with_args(): 141 | assert b.DecodeIso("utf-8").view(b"caf\xc3\xa9") == "caf\xe9" 142 | 143 | 144 | def test_DecodeIso_set(): 145 | assert b.DecodeIso("ascii", "replace").set(b"", "\xe9") == b"?" 146 | 147 | 148 | def test_EachTraversal_to_list_of(): 149 | assert b.EachTraversal().to_list_of([1, 2, 3]) == [1, 2, 3] 150 | 151 | 152 | def test_EachTraversal_set(): 153 | assert b.EachTraversal().set([1, 2, 3], 4) == [4, 4, 4] 154 | 155 | 156 | def test_EachTraversal_to_list_of_on_set(): 157 | assert sorted(b.EachTraversal().to_list_of({1, 2, 3})) == [1, 2, 3] 158 | 159 | 160 | def test_EachTraversal_set_on_set(): 161 | assert b.EachTraversal().set({1, 2, 3}, 4) == {4} 162 | 163 | 164 | def test_EachTraversal_over_on_set(): 165 | assert b.EachTraversal().over({1, 2, 3}, lambda a: a + 1) == {2, 3, 4} 166 | 167 | 168 | def test_EachTraversal_to_list_of_empty(): 169 | assert b.EachTraversal().to_list_of([]) == [] 170 | 171 | 172 | def test_EachTraversal_set_empty(): 173 | assert b.EachTraversal().set([], 4) == [] 174 | 175 | 176 | def test_ErrorLens_view(): 177 | with pytest.raises(CustomException): 178 | b.ErrorIso(CustomException("a message")).view(object()) 179 | 180 | 181 | def test_ErrorLens_set(): 182 | with pytest.raises(CustomException): 183 | b.ErrorIso(CustomException("a message")).set(object(), object()) 184 | 185 | 186 | def test_ErrorLens_repr_with_seperate_message(): 187 | lens = b.ErrorIso("test", "a message") 188 | assert repr(lens) == "ErrorIso('test', 'a message')" 189 | 190 | 191 | def test_FilteringPrism_to_list_of(): 192 | each_filter = b.EachTraversal() & b.FilteringPrism(lambda a: a > 0) 193 | assert each_filter.to_list_of([1, -1, 1]) == [1, 1] 194 | 195 | 196 | def test_FilteringPrism_set(): 197 | each_filter = b.EachTraversal() & b.FilteringPrism(lambda a: a > 0) 198 | assert each_filter.set([1, -1, 1], 3) == [3, -1, 3] 199 | 200 | 201 | def test_GetattrLens_view(): 202 | Tup = collections.namedtuple("Tup", "attr") 203 | assert b.GetattrLens("attr").view(Tup(1)) == 1 204 | 205 | 206 | def test_GetattrLens_set(): 207 | Tup = collections.namedtuple("Tup", "attr") 208 | assert b.GetattrLens("attr").set(Tup(1), 2) == Tup(2) 209 | 210 | 211 | def test_GetZoomAttrTraversal_view_attr(): 212 | obj = object() 213 | state = Pair(obj, "red herring") 214 | assert b.GetZoomAttrTraversal("left").view(state) is obj 215 | 216 | 217 | def test_GetZoomAttrTraversal_set_attr(): 218 | obj = object() 219 | state = Pair("initial value", "red herring") 220 | new_state = Pair(obj, "red herring") 221 | assert b.GetZoomAttrTraversal("left").set(state, obj) == new_state 222 | 223 | 224 | def test_GetZoomAttrTraversal_view_zoom(): 225 | obj = object() 226 | state = Pair(obj, "red herring") 227 | assert b.GetZoomAttrTraversal("east").view(state) is obj 228 | 229 | 230 | def test_GetZoomAttrTraversal_set_zoom(): 231 | obj = object() 232 | state = Pair("initial value", "red herring") 233 | new_state = Pair(obj, "red herring") 234 | assert b.GetZoomAttrTraversal("east").set(state, obj) == new_state 235 | 236 | 237 | def test_GetitemLens_view(): 238 | assert b.GetitemLens(0).view([1, 2, 3]) == 1 239 | 240 | 241 | def test_GetitemLens_set(): 242 | assert b.GetitemLens(0).set([1, 2, 3], 4) == [4, 2, 3] 243 | 244 | 245 | def test_Lens_view(): 246 | my_lens = b.Lens(lambda a: a[:-1], lambda s, a: a + "!") 247 | state = "hello!" 248 | assert my_lens.view(state) == "hello" 249 | 250 | 251 | def test_Lens_set(): 252 | my_lens = b.Lens(lambda a: a[:-1], lambda s, a: a + "!") 253 | state = "hello!" 254 | assert my_lens.set(state, "bye") == "bye!" 255 | 256 | 257 | def test_Lens_over(): 258 | my_lens = b.Lens(lambda a: a[:-1], lambda s, a: a + "!") 259 | state = "hello!" 260 | assert my_lens.over(state, lambda a: a.replace("lo", "p")) == "help!" 261 | 262 | 263 | def test_Lens_meaningful_repr(): 264 | def getter(s): 265 | return s 266 | 267 | def setter(s, f): 268 | return f 269 | 270 | test_lens = b.Lens(getter, setter) 271 | assert repr(getter) in repr(test_lens) 272 | assert repr(setter) in repr(test_lens) 273 | 274 | 275 | def test_Isomorphism_view(): 276 | assert b.Isomorphism(int, str).view("1") == 1 277 | 278 | 279 | def test_IsomorphismLens_set(): 280 | assert b.Isomorphism(int, str).set("1", 2) == "2" 281 | 282 | 283 | def test_IsomorphismLens_getter(): 284 | assert b.Isomorphism(int, str).getter("1") == 1 285 | 286 | 287 | def test_IsomorphismLens_setter(): 288 | assert b.Isomorphism(int, str).setter(None, 1) == "1" 289 | 290 | 291 | def test_IsomorphismLens_unpack(): 292 | assert b.Isomorphism(int, str).unpack("1") == Just(1) 293 | 294 | 295 | def test_IsomorphismLens_pack(): 296 | assert b.Isomorphism(int, str).pack(1) == "1" 297 | 298 | 299 | def test_IsomorphismLens_view_re(): 300 | assert b.Isomorphism(int, str).re().view(1) == "1" 301 | 302 | 303 | def test_IsomorphismLens_set_re(): 304 | assert b.Isomorphism(int, str).re().set(1, "2") == 2 305 | 306 | 307 | def test_ItemLens_view(): 308 | data = {0: "hello", 1: "world"} 309 | assert b.ItemLens(1).view(data) == (1, "world") 310 | 311 | 312 | def test_ItemLens_view_nonexistent(): 313 | data = {0: "hello", 1: "world"} 314 | assert b.ItemLens(3).view(data) is None 315 | 316 | 317 | def test_ItemLens_set(): 318 | data = {0: "hello", 1: "world"} 319 | itemlens = b.ItemLens(1) 320 | assert itemlens.set(data, (2, "everyone")) == {0: "hello", 2: "everyone"} 321 | 322 | 323 | def test_ItemLens_unset_nonexistent(): 324 | data = {0: "hello"} 325 | itemlens = b.ItemLens(1) 326 | assert itemlens.set(data, None) == data 327 | 328 | 329 | def test_ItemByValueLens_view(): 330 | data = {"hello": 0, "world": 1} 331 | assert b.ItemByValueLens(1).view(data) == ("world", 1) 332 | 333 | 334 | def test_ItemByValueLens_view_nonexistent(): 335 | data = {"hello": 0, "world": 1} 336 | assert b.ItemByValueLens(2).view(data) is None 337 | 338 | 339 | def test_ItemByValueLens_set(): 340 | data = {"hello": 0, "world": 1} 341 | result = {"hello": 0, "everyone": 2} 342 | assert b.ItemByValueLens(1).set(data, ("everyone", 2)) == result 343 | 344 | 345 | def test_ItemByValueLens_set_nonexistent(): 346 | data = {"hello": 0, "world": 1} 347 | assert b.ItemByValueLens(2).set(data, ("test", 2)) == { 348 | "hello": 0, 349 | "world": 1, 350 | "test": 2, 351 | } 352 | 353 | 354 | def test_ItemsTraversal_to_list_of(): 355 | data = {0: "zero", 1: "one"} 356 | my_lens = b.ItemsTraversal() 357 | assert sorted(my_lens.to_list_of(data)) == [(0, "zero"), (1, "one")] 358 | 359 | 360 | def test_ItemsTraversal_to_list_of_empty(): 361 | my_lens = b.ItemsTraversal() 362 | assert sorted(my_lens.to_list_of({})) == [] 363 | 364 | 365 | def test_ItemsTraversal_over(): 366 | data = {0: "zero", 1: "one"} 367 | my_lens = b.ItemsTraversal() & b.GetitemLens(0) 368 | assert my_lens.over(data, lambda a: a + 1) == {1: "zero", 2: "one"} 369 | 370 | 371 | def test_ItemsTraversal_over_empty(): 372 | my_lens = b.ItemsTraversal() & b.GetitemLens(0) 373 | assert my_lens.over({}, lambda a: a + 1) == {} 374 | 375 | 376 | def test_JsonIso_view(): 377 | iso = b.JsonIso() 378 | data = '{"numbers":[1, 2, 3]}' 379 | assert iso.view(data) == {"numbers": [1, 2, 3]} 380 | 381 | 382 | def test_JsonIso_set(): 383 | iso = b.JsonIso() 384 | data = '{"numbers":[1, 2, 3]}' 385 | assert iso.set(data, {"numbers": []}) == '{"numbers": []}' 386 | 387 | 388 | def test_RecurTraversal_to_list_of(): 389 | data = [1, [2], [3, 4], [[5], 6, [7, [8, 9]]]] 390 | result = [1, 2, 3, 4, 5, 6, 7, 8, 9] 391 | assert b.RecurTraversal(int).to_list_of(data) == result 392 | 393 | 394 | def test_RecurTraversal_over(): 395 | data = [ 396 | 1, 397 | [], 398 | [2], 399 | Pair(3, 4), 400 | Pair("one", "two"), 401 | Pair([Pair(5, [6, 7]), 256.0], 8), 402 | Pair(["three", Pair(9, "four")], "five"), 403 | ] 404 | result = [ 405 | 2, 406 | [], 407 | [3], 408 | Pair(4, 5), 409 | Pair("one", "two"), 410 | Pair([Pair(6, [7, 8]), 256.0], 9), 411 | Pair(["three", Pair(10, "four")], "five"), 412 | ] 413 | assert b.RecurTraversal(int).over(data, lambda n: n + 1) == result 414 | 415 | 416 | def test_RecurTraversal_over_with_frozenset(): 417 | data = [1, frozenset([2, 3]), 4] 418 | result = [11, frozenset([12, 13]), 14] 419 | lens = b.RecurTraversal(int) 420 | assert lens.over(data, lambda n: n + 10) == result 421 | 422 | 423 | def test_RecurTraversal_no_change(): 424 | data = [ 425 | 1, 426 | [], 427 | [2], 428 | Pair(3, 4), 429 | Pair("one", "two"), 430 | Pair([Pair(5, [6, 7]), 256.0], 8), 431 | Pair(["three", Pair(9, "four")], "five"), 432 | ] 433 | lens = b.RecurTraversal(float) 434 | result = lens.over(data, lambda a: 512.0) 435 | assert data is not result 436 | for n in (0, 1, 2, 3, 4, 6): 437 | assert data[n] is result[n] 438 | 439 | 440 | def test_RecurTraversal_memoizes_hashable(): 441 | depth = 100 442 | width = 10 443 | lens = b.RecurTraversal(int) 444 | 445 | hashable_data = frozenset([0]) 446 | for _ in range(depth): 447 | hashable_data = hashable_data, None 448 | hashable_data = [hashable_data] * width 449 | 450 | unhashable_data = set([0]) 451 | for _ in range(depth): 452 | unhashable_data = unhashable_data, None 453 | unhashable_data = [unhashable_data] * width 454 | 455 | hashable_time = timer(lens.over, hashable_data, lambda n: 1) 456 | unhashable_time = timer(lens.over, unhashable_data, lambda n: 1) 457 | 458 | assert hashable_time < unhashable_time 459 | 460 | 461 | def test_TrivialIso_view(): 462 | obj = object() 463 | assert b.TrivialIso().view(obj) is obj 464 | 465 | 466 | def test_TrivialIso_set(): 467 | obj1, obj2 = object(), object() 468 | assert b.TrivialIso().set(obj1, obj2) is obj2 469 | 470 | 471 | def test_TupleOptic_view_with_LensLike(): 472 | data = {"hello": 0, "world": 1} 473 | get = b.GetitemLens 474 | my_lens = b.TupleOptic(get("hello"), get("world")) 475 | assert my_lens.view(data) == (0, 1) 476 | 477 | 478 | def test_TupleOptic_set_with_LensLike(): 479 | data = {"hello": 0, "world": 1} 480 | get = b.GetitemLens 481 | my_lens = b.TupleOptic(get("hello"), get("world")) 482 | assert my_lens.set(data, (3, 4)) == {"hello": 3, "world": 4} 483 | 484 | 485 | def test_TupleOptic_does_not_work_with_folds(): 486 | with pytest.raises(TypeError): 487 | b.TupleOptic(b.EachTraversal()) 488 | 489 | 490 | def test_TupleOptic_kind_varies(): 491 | assert b.TupleOptic(b.GetitemLens(0)).kind() is b.Lens 492 | assert b.TupleOptic(b.Getter(lambda a: a)).kind() is b.Getter 493 | assert b.TupleOptic(b.ForkedSetter()).kind() is b.Setter 494 | 495 | 496 | def test_ZoomTraversal_view(): 497 | zoomer = b.GetitemLens(0) & b.ZoomTraversal() 498 | data = [bind([1, 2, 3])[1]] 499 | assert zoomer.view(data) == 2 500 | 501 | 502 | def test_ZoomTraversal_set(): 503 | zoomer = b.GetitemLens(0) & b.ZoomTraversal() 504 | data = [bind([1, 2, 3])[1]] 505 | assert zoomer.set(data, 7) == [[1, 7, 3]] 506 | -------------------------------------------------------------------------------- /docs/tutorial/optics.rst: -------------------------------------------------------------------------------- 1 | Optics 2 | ====== 3 | 4 | Lenses are just one in a whole family of related objects called 5 | *optics*. Optics generalise the notion of accessing data. 6 | 7 | The heirarchy of optics looks like this: 8 | 9 | .. graphviz:: 10 | 11 | digraph { 12 | "Fold" -> "Getter"; 13 | "Fold" -> "Traversal"; 14 | "Getter" -> "Lens"; 15 | "Lens" -> "Isomorphism"; 16 | "Prism" -> "Isomorphism"; 17 | "Review" -> "Prism"; 18 | "Setter" -> "Traversal"; 19 | "Traversal" -> "Lens"; 20 | "Traversal" -> "Prism"; 21 | } 22 | 23 | An arrow pointing from A to B here means that all B are also A. For 24 | example, all Lenses are also Getters, and all Getters are also Folds. 25 | 26 | When we compose two optics together, the result is the most-recent 27 | common ancestor of the two. For example, if we compose a Getter and a 28 | Traversal then the optic we get back would be a Fold because Getters and 29 | Traversals are both kinds of Fold. We cannot compose two optics that do 30 | not share a common ancestor; e.g. we cannot compose a Fold with a Setter. 31 | 32 | You can find out the kind of a lens using the ``kind`` method: 33 | 34 | >>> from lenses import lens 35 | 36 | >>> my_lens = lens[0] 37 | >>> my_lens.kind() 38 | 'Lens' 39 | >>> my_prism = lens.Instance(str) 40 | >>> my_prism.kind() 41 | 'Prism' 42 | >>> my_traversal = my_lens & my_prism 43 | >>> my_traversal.kind() 44 | 'Traversal' 45 | 46 | 47 | Traversals 48 | ---------- 49 | 50 | All the optics that we have seen so far have been lenses, so they always 51 | focused a single object inside a state. But it is possible for an optic 52 | to have more than one focus. One such optic is the traversal. A simple 53 | traversal can be made with the ``Each`` method. ``lens.Each()`` will focus 54 | all of the items in a data-structure analogous to iterating over it 55 | using python's ``iter`` and ``next``. It supports most of the built-in 56 | iterables out of the box, but if we want to use it on our own objects 57 | then we will need to add a hook explicitly. 58 | 59 | One issue with multi-focus optics is that the ``get`` method only ever 60 | returns a single focus. It will return the *first* item focused by the 61 | optic. If we want to get all the items focused by that optic then we 62 | can use the ``collect`` method which will return those objects in a list: 63 | 64 | >>> data = [0, 1, 2, 3] 65 | >>> each = lens.Each() 66 | >>> each.get()(data) 67 | 0 68 | >>> each.collect()(data) 69 | [0, 1, 2, 3] 70 | 71 | Setting works with a traversal, though all foci will be set to the same 72 | object. 73 | 74 | >>> each.set(4)(data) 75 | [4, 4, 4, 4] 76 | 77 | Modifying is the most useful operation we can perform. The modification 78 | will be applied to all the foci independently. All the foci must be of 79 | the same type (or at least be of a type that supports the modification 80 | that we want to make). 81 | 82 | >>> each.modify(lambda a: a + 10)(data) 83 | [10, 11, 12, 13] 84 | >>> each.modify(str)([0, 1.0, 2, 3]) 85 | ['0', '1.0', '2', '3'] 86 | 87 | You can of course use the same shortcut for operators that single-focus 88 | lenses allow: 89 | 90 | >>> (each + 10)(data) 91 | [10, 11, 12, 13] 92 | 93 | Traversals can be composed with normal lenses. The result is a traversal 94 | with the lens applied to each of its original foci: 95 | 96 | >>> data = [[0, 1], [2, 3]] 97 | >>> each_then_zero = lens.Each()[0] 98 | >>> each_then_zero.collect()(data) 99 | [0, 2] 100 | >>> (each_then_zero + 10)(data) 101 | [[10, 1], [12, 3]] 102 | 103 | Traversals can also be composed with other traversals just fine. They 104 | will simply increase the number of foci targeted. Note that ``collect`` 105 | returns a flat list of foci; none of the structure of the state is 106 | preserved. 107 | 108 | >>> each_twice = lens.Each().Each() 109 | >>> each_twice.collect()(data) 110 | [0, 1, 2, 3] 111 | >>> (each_twice + 10)(data) 112 | [[10, 11], [12, 13]] 113 | 114 | The ``Values`` method returns a traversal that focuses all of the values 115 | in a dictionary. If we return to our ``GameState`` example from earlier, 116 | we can use ``Values`` to move *every* enemy in the same level 1 pixel 117 | over to the right in one line of code: 118 | 119 | >>> from collections import namedtuple 120 | >>> 121 | >>> GameState = namedtuple('GameState', 122 | ... 'current_world current_level worlds') 123 | >>> World = namedtuple('World', 'theme levels') 124 | >>> Level = namedtuple('Level', 'map enemies') 125 | >>> Enemy = namedtuple('Enemy', 'x y') 126 | >>> 127 | >>> data = GameState(1, 2, { 128 | ... 1: World('grassland', {}), 129 | ... 2: World('desert', { 130 | ... 1: Level({}, { 131 | ... 'goomba1': Enemy(100, 45), 132 | ... 'goomba2': Enemy(130, 45), 133 | ... 'goomba3': Enemy(160, 45), 134 | ... }), 135 | ... }), 136 | ... }) 137 | >>> 138 | >>> level_enemies_right = (lens.worlds[2] 139 | ... .levels[1] 140 | ... .enemies.Values().x + 1) 141 | >>> new_data = level_enemies_right(data) 142 | 143 | Or we could do the same thing to every enemy in the entire game 144 | (assuming that there were other enemies on other levels in the 145 | ``GameState``): 146 | 147 | >>> all_enemies_right = (lens.worlds.Values() 148 | ... .levels.Values() 149 | ... .enemies.Values().x + 1) 150 | >>> new_data = all_enemies_right(data) 151 | 152 | 153 | Getters 154 | ------- 155 | 156 | A Getter is an optic that knows how to retrieve a single focus from a 157 | state. You can think of a Getter as a Lens that does not have a setter 158 | function. Because it does not have a setter function, we cannot use a 159 | Getter to ``set`` values. You also cannot use ``modify``, ``call``, or 160 | ``call_mut`` because these all make use of the setting machinery. The 161 | only method we can meaningly perform on a Getter is ``get``. We can call 162 | ``collect``, but it will always give us a list containing a single focus. 163 | 164 | The simplest way to make a Getter is with the ``F`` method. This method 165 | takes a function and returns a Getter that just calls that function on 166 | the state in order and whatever that function returns is the focus. 167 | 168 | >>> data = 1 169 | >>> def get_negative(state): 170 | ... return -state 171 | >>> neg_getter = lens.F(get_negative) 172 | >>> neg_getter.get()(data) 173 | -1 174 | 175 | If we try to call ``set`` or any other invalid method on a Getter then 176 | we will get an exception: 177 | 178 | >>> neg_getter.set(2)(data) 179 | Traceback (most recent call last): 180 | File "", line 1, in ? 181 | TypeError: Must be an instance of Setter to .set() 182 | 183 | You might notice that ``lens.F(some_function).get()`` is exactly equivalent 184 | to using ``some_function`` by itself. For this reason Getters on their 185 | own are not particularly useful. The utility of Getters comes when we 186 | compose them with other optics. 187 | 188 | >>> data = [1, 2, 3] 189 | >>> each_neg = lens.Each().F(get_negative) 190 | >>> each_neg.collect()(data) 191 | [-1, -2, -3] 192 | 193 | Getters allow you to *inject* arbitrary behaviour into the middle of an 194 | optic at the cost of not being able to set anything: 195 | 196 | >>> data = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] 197 | >>> lens.Each().F(reversed).Each().collect()(data) 198 | [3, 2, 1, 6, 5, 4, 9, 8, 7] 199 | 200 | 201 | Folds 202 | ----- 203 | 204 | A Fold is to a Getter what a Traversal is to a Lens. That is, a Fold is 205 | a Getter that can get multiple foci. Just like Getters, you cannot set 206 | anything with a Fold. Just like Traversals, when using a Fold, you will 207 | want to prefer the ``collect`` method over ``get``. 208 | 209 | A Fold can be constructed from any function that returns an iterator 210 | using the ``Fold`` method. Generator functions are particularly useful 211 | for making Folds. 212 | 213 | >>> def ends(state): 214 | ... yield state[0] 215 | ... yield state[-1] 216 | >>> data = [1, 2, 3] 217 | >>> lens.Fold(ends).collect()(data) 218 | [1, 3] 219 | 220 | A useful Fold is ``Iter``. This Fold just iterates over the state 221 | directly. It's very similar to the ``Each`` Traversal, but ``Each`` 222 | needs explicit support for custom types precisely because it needs to 223 | know how to set new values on objects of that type. ``Iter`` is a Fold, 224 | so doesn't need to set anything, and so can just use python's built-in 225 | iterator protocol. ``lens.Iter()`` is equivalent to ``lens.Fold(iter)`` 226 | 227 | Just as with Getters, Folds don't do much on their own; you will want 228 | to compose them: 229 | 230 | >>> data = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] 231 | >>> lens.Iter().Fold(ends).F(get_negative).collect()(data) 232 | [-1, -3, -4, -6, -7, -9] 233 | 234 | 235 | Setters 236 | ------- 237 | 238 | If a Getter is like a Lens that lacks the ability to set, then a Setter 239 | is like a Lens that lacks the ability to get. You cannot call ``get`` 240 | on a setter, though you can use ``set``, ``modify``, ``call``, and 241 | ``call_mut``. 242 | 243 | The only setter available is the ForkedSetter which you can create with 244 | the ``Fork`` method. This method allows you to create a setter that can 245 | set at two different places at once. You pass it some optics and the 246 | ForkedSetter will use the set functionality from all of those optics 247 | at once: 248 | 249 | >>> set_inner_ends = lens.Each().Fork(lens[0], lens[-1]) 250 | >>> set_inner_ends.set(0)(data) 251 | [[0, 2, 0], [0, 5, 0], [0, 8, 0]] 252 | >>> (set_inner_ends + 10)(data) 253 | [[11, 2, 13], [14, 5, 16], [17, 8, 19]] 254 | 255 | 256 | Isomorphisms 257 | ------------ 258 | 259 | An Isomorphism is an optic that can be flipped around; it is 260 | reversable. 261 | 262 | An ordinary Lens can be thought of as a wrapper around a 263 | pair of functions:: 264 | 265 | def getter(state) -> focus: 266 | def setter(old_state, focus) -> new_state: 267 | 268 | Notice the asymmetry here; the setter function requires access to the 269 | previous state in order to construct a new state. With an Isomorphism 270 | the setter function no longer takes this argument; it can construct a 271 | new state by looking only at the focus:: 272 | 273 | def getter(state) -> focus: 274 | def setter(focus) -> state: 275 | 276 | These two functions are inverses of one another; converting back and 277 | forth between a state and a focus without any loss of information. A 278 | good example of an isomorphism is the equivalency between a unicode string 279 | and a byte string; if you know the encoding (and the encoding is capable 280 | enough, and the bytestring is valid) you can freely convert between the 281 | two. This isomorphism can be constructed using the ``Decode`` method:: 282 | 283 | >>> utf8_decoder = lens.Decode('utf8') 284 | >>> utf8_decoder.get()(b'Hello, \xe4\xb8\x96\xe7\x95\x8c') 285 | 'Hello, 世界' 286 | 287 | You can use ``set`` with an iso, but it will completely ignore the old 288 | state that you pass in:: 289 | 290 | >>> utf8_decoder.set('Hello, 世界')(b'ignored') 291 | b'Hello, \xe4\xb8\x96\xe7\x95\x8c' 292 | 293 | The value of an isomorphism is that you can flip them; you can turn the 294 | old getter into a setter and the old setter into a getter:: 295 | 296 | >>> utf8_encoder = utf8_decoder.flip() 297 | >>> utf8_encoder.get()('Hello, 世界') 298 | b'Hello, \xe4\xb8\x96\xe7\x95\x8c' 299 | >>> utf8_encoder.set(b'Hello, \xe4\xb8\x96\xe7\x95\x8c')('ignored') 300 | 'Hello, 世界' 301 | 302 | The flipped version of an isomorphism is still an isomorphism. 303 | 304 | If you have two functions that are inverses of one another then you can 305 | create an isomorphism using the ``Iso`` method. 306 | 307 | >>> state = 1, 2, 3 308 | >>> list_converter = lens.Iso(list, tuple) 309 | >>> list_converter.get()(state) 310 | [1, 2, 3] 311 | >>> (list_converter + [4])(state) 312 | (1, 2, 3, 4) 313 | 314 | 315 | Prisms 316 | ------ 317 | 318 | A Prism is an like an isomorphism with the extra benefit that it can 319 | choose whether or not it wants to have a focus. That is; a prism takes 320 | a state and only optionally returns a focus. 321 | 322 | Prisms are often used in other languages to unwrap sum-types, but since 323 | python does not have native sum-types their use is more limited. Because 324 | there are no good examples of sum-types in the python standard library 325 | we will have to simulate them. 326 | 327 | On your birthday you can recieve two kinds of things; Presents and 328 | Cards. A present is a wrapper around some other type that represents 329 | the actual gift, while a card is just a card. 330 | 331 | >>> class Card: 332 | ... def __repr__(self): 333 | ... return 'Card()' 334 | >>> class Present: 335 | ... def __init__(self, contents): 336 | ... self.contents = contents 337 | ... def __repr__(self): 338 | ... return 'Present({!r})'.format(self.contents) 339 | 340 | Say we have a list of all the things we got on our birthday: 341 | 342 | >>> state = [Present('doll'), Card(), Present('train set')] 343 | 344 | Because we are ungrateful children we want to be able to unwrap the 345 | presents in the list while leaving the cards untouched. A prism is exactly 346 | the sort of optic we need to write in this situation. We can create a 347 | prism using the ``Prism`` method. It takes two functions, *unwrap* and 348 | *wrap*, that will do the job of selecting and rebuilding the presents 349 | for us. The *wrap* function is easy because that is just the ``Present`` 350 | constructor that we already have. We can write an *unwrap* like this: 351 | 352 | >>> def unwrap_present(state): 353 | ... if isinstance(state, Present): 354 | ... return state.contents 355 | 356 | This function checks if we have a present, unwraps it if we do, and 357 | implicitly returns ``None`` if we don't. Now we can construct our 358 | prism. We need to tell the prism that our function signals the lack of 359 | a focus by returning a none value, so we set the ``ignore_none`` 360 | keyword argument. 361 | 362 | >>> Present_prism = lens.Prism(unwrap_present, Present, ignore_none=True) 363 | >>> each_present = lens.Each() & Present_prism 364 | 365 | Now we are ready to get at our presents: 366 | 367 | >>> each_present.collect()(state) 368 | ['doll', 'train set'] 369 | 370 | And break them: 371 | 372 | >>> ('broken ' + each_present)(state) 373 | [Present('broken doll'), Card(), Present('broken train set')] 374 | 375 | There are a couple of useful prisms available. ``Instance`` is a 376 | prism that only focuses something when it is of a particular type, and 377 | ``Filter`` allows you to supply an arbitrary predicate function to select 378 | the focus. Technically, ``Instance`` and ``Filter`` are something called 379 | an *affine traversal* and not true prisms, because they don't actually 380 | do any wrapping and unwrapping; their wrap functions are both no-ops. But 381 | they act enough like prisms that the lenses library uses them as though 382 | they were. 383 | 384 | 385 | Reviews 386 | ------- 387 | 388 | A Review is to a Prism as a Setter is to a Traversal. 389 | 390 | When we first looked at isomorphisms we saw that they have a special kind 391 | of setter that only takes one argument. Technically that function should 392 | not be called a "setter function" because it doesn't know about the old 393 | state and so it can't really set anything. This "setter that looks like 394 | a backwards getter" is actually called a "review function". A Review is 395 | any optic that contains a review function, but doesn't necessarily have 396 | a getter. 397 | 398 | There are no supported ways to create Reviews using the lenses library. 399 | But since all prisms (and isomorphisms) are also reviews, it's important 400 | to know what they can do. Reviews have two important features. 401 | 402 | The first is that they are flippable. You can flip a Review just like an 403 | isomorphism, but while isos flip in a way that is reversable, Reviews 404 | do not. What was previously the review function becomes a getter and 405 | there is no previous getter function to become the review. The flipped 406 | version of a Review has a getter function, but no review function (and 407 | no setter). When you flip a Review it becomes a Getter. 408 | 409 | >>> Present_getter = Present_prism.flip() 410 | >>> Present_getter.kind() 411 | 'Getter' 412 | >>> Present_getter.get()('lump of coal') 413 | Present('lump of coal') 414 | 415 | The second feature is that you can use them to construct states, 416 | given only a focus. Like isomorphisms, they do not require access to 417 | a previous state in order to construct a new one. If you have a Review 418 | you can construct states with the ``construct`` method; just pass the 419 | focus that you want to use. 420 | 421 | If we wanted to play the childrens game "pass the parcel" we would need 422 | a prize that has been wrapped up many times: 423 | 424 | >>> (Present_prism & Present_prism & Present_prism).construct('sweets') 425 | Present(Present(Present('sweets'))) 426 | 427 | Obviously, making a review like this just to construct values is 428 | inefficient and less readable than constructing the value directly. The 429 | utility comes when you have many different reviews, prisms, and isos 430 | composed together and you use the resulting optic to do many different 431 | tasks, not just constructing. 432 | -------------------------------------------------------------------------------- /lenses/optics/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import functools 3 | from typing import Callable, cast, Generic, Iterable, List, Optional, TypeVar 4 | 5 | from .. import typeclass 6 | from ..const import Const 7 | from ..functorisor import Functorisor 8 | from ..identity import Identity 9 | from ..maybe import Just, Nothing 10 | 11 | S = TypeVar("S") 12 | T = TypeVar("T") 13 | A = TypeVar("A") 14 | B = TypeVar("B") 15 | X = TypeVar("X") 16 | Y = TypeVar("Y") 17 | 18 | 19 | def multiap(func, *args): 20 | """Applies `func` to the data inside the `args` functors 21 | incrementally. `func` must be a curried function that takes 22 | `len(args)` arguments. 23 | 24 | >>> func = lambda a: lambda b: a + b 25 | >>> multiap(func, [1, 10], [100]) 26 | [101, 110] 27 | """ 28 | functor = typeclass.fmap(args[0], func) 29 | for arg in args[1:]: 30 | functor = typeclass.apply(arg, functor) 31 | return functor 32 | 33 | 34 | def collect_args(n): 35 | """Returns a function that can be called `n` times with a single 36 | argument before returning all the args that have been passed to it 37 | in a tuple. Useful as a substitute for functions that can't easily be 38 | curried. 39 | 40 | >>> collect_args(3)(1)(2)(3) 41 | (1, 2, 3) 42 | """ 43 | args = [] 44 | 45 | def arg_collector(arg): 46 | args.append(arg) 47 | if len(args) == n: 48 | return tuple(args) 49 | else: 50 | return arg_collector 51 | 52 | return arg_collector 53 | 54 | 55 | class LensLike(abc.ABC, Generic[S, T, A, B]): 56 | """A LensLike. Serves as the backbone of the lenses library. Acts as an 57 | object-oriented wrapper around a function (`LensLike.func`) that 58 | does all the hard work. This function is an uncurried form of the 59 | van Laarhoven lens and has the following type (in ML-style 60 | notation): 61 | 62 | func :: (value -> functor value), state -> functor state 63 | 64 | A LensLike has a kind that determines what operations are valid on 65 | that LensLike. Valid kinds are Equality, Isomorphism, Prism, Review, 66 | Lens, Traversal, Getter, Setter, Fold, and None. 67 | 68 | Fold 69 | 70 | : A Fold is an optic capable of getting, but not necessarily setting, 71 | multiple foci at once. You can think of a fold as kind of like an 72 | iterator; it allows you to view many subparts of a larger structure. 73 | 74 | Setter 75 | 76 | : A Setter is an optic that is capable of setting foci in a state 77 | but not necessarily getting them. 78 | 79 | Getter 80 | 81 | : A Getter is a Fold that is restricted to getting a single focus at 82 | a time. It can not necessarily set any foci. 83 | 84 | Traversal 85 | 86 | : A Traversal is both a Fold and a Setter. It is capable of both 87 | setting and getting multiple foci at once. 88 | 89 | Lens 90 | 91 | : A Lens is both a Getter and a Traversal. It is capable of getting 92 | and setting a single focus at a time. 93 | 94 | Review 95 | 96 | : A Review is an optic that is capable of being constructed 97 | from. Constructing allows you to supply a focus and get back a 98 | complete state. You cannot neccessarily use reviews to get or set 99 | any values. 100 | 101 | Prism 102 | 103 | : A Prism is both a Traversal and a Review. It is capable of getting 104 | and setting a single focus that may or may not exist. You can also 105 | use it to construct. 106 | 107 | Isomorphism 108 | 109 | : An Isomorphism is both a Lens and a Prism. They can be used to 110 | get, set, and construct. Isomorphisms have the property that they 111 | are reversable; You can take an isomorphism and flip it around 112 | so that getting the focus becomes setting the focus and setting 113 | becomes getting. 114 | 115 | Equality 116 | 117 | : An Equality is an Isomorphism. Currently unused. 118 | 119 | None 120 | 121 | : Here "None" is referring to the built-in python `None` object and 122 | not a custom class like the other kinds. An optic of kind None is 123 | an invalid optic. Optics of this kind may exist internally, but if 124 | you manage to create a None optic through normal means then this 125 | represents a bug in the library. 126 | """ 127 | 128 | __slots__ = () 129 | 130 | @abc.abstractmethod 131 | def func(self, f, state): 132 | """Intended to be overridden by subclasses. Raises 133 | NotImplementedError.""" 134 | message = "Tried to use unimplemented lens {}." 135 | raise NotImplementedError(message.format(type(self))) 136 | 137 | def apply(self, f, pure, state): 138 | """Runs the lens over the `state` applying `f` to all the foci 139 | collecting the results together using the applicative functor 140 | functions defined in `lenses.typeclass`. `f` must return an 141 | applicative functor. For the case when no focus exists you must 142 | also provide a `pure` which should take a focus and return the 143 | pure form of the functor returned by `f`. 144 | """ 145 | return self.func(Functorisor(pure, f), state) 146 | 147 | def preview(self, state: S) -> Just[B]: 148 | """Previews a potentially non-existant focus within 149 | `state`. Returns `Just(focus)` if it exists, Nothing otherwise. 150 | 151 | Requires kind Fold. 152 | """ 153 | if not self._is_kind(Fold): 154 | raise TypeError("Must be an instance of Fold to .preview()") 155 | 156 | def pure(a: X) -> Const[Just[X], Y]: 157 | return Const(Nothing()) 158 | 159 | def func(a: X) -> Const[Just[X], Y]: 160 | return Const(Just(a)) 161 | 162 | return self.apply(func, pure, state).unwrap() 163 | 164 | def view(self, state: S) -> B: 165 | """Returns the focus within `state`. If multiple items are 166 | focused then it will attempt to join them together as a monoid. 167 | See `lenses.typeclass.mappend`. 168 | 169 | Requires kind Fold. This method will raise TypeError if the 170 | optic has no way to get any foci. 171 | 172 | For technical reasons, this method requires there to be at least 173 | one foci at the end of the view. It will raise ValueError when 174 | there is none. 175 | """ 176 | if not self._is_kind(Fold): 177 | raise TypeError("Must be an instance of Fold to .view()") 178 | 179 | guard = object() 180 | result = self.preview(state).maybe(guard) 181 | if result is guard: 182 | raise ValueError("No focus to view") 183 | return cast(B, result) 184 | 185 | def to_list_of(self, state: S) -> List[B]: 186 | """Returns a list of all the foci within `state`. 187 | 188 | Requires kind Fold. This method will raise TypeError if the 189 | optic has no way to get any foci. 190 | """ 191 | if not self._is_kind(Fold): 192 | raise TypeError("Must be an instance of Fold to .to_list_of()") 193 | 194 | def pure(a: X) -> Const[List[X], Y]: 195 | return Const([]) 196 | 197 | def func(a: X) -> Const[List[X], Y]: 198 | return Const([a]) 199 | 200 | return self.apply(func, pure, state).unwrap() 201 | 202 | def over(self, state: S, fn: Callable[[A], B]) -> T: 203 | """Applies a function `fn` to all the foci within `state`. 204 | 205 | Requires kind Setter. This method will raise TypeError when the 206 | optic has no way to set foci. 207 | """ 208 | if not self._is_kind(Setter): 209 | raise TypeError("Must be an instance of Setter to .over()") 210 | 211 | def pure(a): 212 | return Identity(a) 213 | 214 | def func(a): 215 | return Identity(fn(a)) 216 | 217 | return self.apply(func, pure, state).unwrap() 218 | 219 | def set(self, state: S, value: B) -> T: 220 | """Sets all the foci within `state` to `value`. 221 | 222 | Requires kind Setter. This method will raise TypeError when the 223 | optic has no way to set foci. 224 | """ 225 | if not self._is_kind(Setter): 226 | raise TypeError("Must be an instance of Setter to .set()") 227 | 228 | def pure(a): 229 | return Identity(a) 230 | 231 | def func(a): 232 | return Identity(value) 233 | 234 | return self.apply(func, pure, state).unwrap() 235 | 236 | def iterate(self, state: S, iterable: Iterable[B]) -> T: 237 | """Sets all the foci within `state` to values taken from `iterable`. 238 | 239 | Requires kind Setter. This method will raise TypeError when the 240 | optic has no way to set foci. 241 | """ 242 | if not self._is_kind(Setter): 243 | raise TypeError("Must be an instance of Setter to .iterate()") 244 | 245 | i = iter(iterable) 246 | 247 | def pure(a): 248 | return Identity(a) 249 | 250 | def func(a): 251 | return Identity(next(i)) 252 | 253 | return self.apply(func, pure, state).unwrap() 254 | 255 | def compose(self, other: "LensLike") -> "LensLike": 256 | """Composes another lens with this one. The result is a lens 257 | that feeds the foci of `self` into the state of `other`. 258 | """ 259 | return ComposedLens([self]).compose(other) 260 | 261 | def re(self) -> "LensLike": 262 | raise TypeError("Must be an instance of Review to .re()") 263 | 264 | def kind(self): 265 | """Returns a class representing the 'kind' of optic.""" 266 | optics = [ 267 | Equality, 268 | Isomorphism, 269 | Prism, 270 | Review, 271 | Lens, 272 | Traversal, 273 | Getter, 274 | Setter, 275 | Fold, 276 | ] 277 | for optic in optics: 278 | if self._is_kind(optic): 279 | return optic 280 | 281 | def _is_kind(self, cls): 282 | return isinstance(self, cls) 283 | 284 | __and__ = compose 285 | 286 | 287 | class Fold(LensLike): 288 | """An optic that wraps a folder function. A folder function is a 289 | function that takes a single argument - the state - and returns 290 | an iterable containing all the foci that can be found in that 291 | state. Generator functions work particularly well here. 292 | 293 | >>> def iterate_2d_list(rows): 294 | ... for row in rows: 295 | ... for cell in row: 296 | ... yield cell 297 | >>> nested_fold = Fold(iterate_2d_list) 298 | >>> nested_fold 299 | Fold() 300 | >>> state = [[1], [2, 3], [4, 5, 6]] 301 | >>> nested_fold.to_list_of(state) 302 | [1, 2, 3, 4, 5, 6] 303 | 304 | Folds are incapable of setting anything. 305 | """ 306 | 307 | def __init__(self, folder): 308 | self.folder = folder 309 | 310 | def func(self, f, state): 311 | foci = list(self.folder(state)) 312 | if foci == []: 313 | return f.pure(state) 314 | collector = collect_args(len(foci)) 315 | applied = multiap(collector, *map(f, foci)) 316 | return applied 317 | 318 | def __repr__(self): 319 | return "Fold({!r})".format(self.folder) 320 | 321 | 322 | class Setter(LensLike): 323 | pass 324 | 325 | 326 | class Getter(Fold): 327 | """An optic that wraps a getter function. A getter function is one 328 | that takes a state and returns a value derived from that state. The 329 | function is called on the focus before it is returned. 330 | 331 | >>> Getter(abs) 332 | Getter() 333 | >>> Getter(abs).view(-1) 334 | 1 335 | """ 336 | 337 | def __init__(self, getter: Callable[[S], A]) -> None: 338 | self.getter = getter 339 | 340 | def func(self, f, state): 341 | return f(self.getter(state)) 342 | 343 | def folder(self, state): 344 | yield self.getter(state) 345 | 346 | def __repr__(self): 347 | return "Getter({!r})".format(self.getter) 348 | 349 | 350 | class Traversal(Fold, Setter): 351 | """An optic that wraps folder and builder functions. The folder 352 | function is a function that takes a single argument - the state - 353 | and returns an iterable containing all the foci that exist in that 354 | state. Generators are a good option for writing folder functions. 355 | 356 | A builder function takes the old state and an list of values and 357 | constructs a new state with the old state's values swapped out. The 358 | number of values passed to builder for any given state should always 359 | be the same as the number of values that the folder function would 360 | have returned for that same state. 361 | 362 | >>> def folder(state): 363 | ... 'Yields the first and last elements of a list' 364 | ... yield state[0] 365 | ... yield state[-1] 366 | >>> def builder(state, values): 367 | ... 'Sets the first and last elements of a list' 368 | ... result = list(state) 369 | ... result[0] = values[0] 370 | ... result[-1] = values[1] 371 | ... return result 372 | >>> both_ends = Traversal(folder, builder) 373 | >>> both_ends 374 | Traversal(, ) 375 | >>> both_ends.to_list_of([1, 2, 3, 4]) 376 | [1, 4] 377 | >>> both_ends.set([1, 2, 3, 4], 5) 378 | [5, 2, 3, 5] 379 | """ 380 | 381 | def __init__(self, folder, builder): 382 | self.folder = folder 383 | self.builder = builder 384 | 385 | def func(self, f, state): 386 | foci = list(self.folder(state)) 387 | if foci == []: 388 | return f.pure(state) 389 | collector = collect_args(len(foci)) 390 | applied = multiap(collector, *map(f, foci)) 391 | apbuilder = functools.partial(self.builder, state) 392 | return typeclass.fmap(applied, apbuilder) 393 | 394 | def __repr__(self): 395 | return "Traversal({!r}, {!r})".format(self.folder, self.builder) 396 | 397 | 398 | class Lens(Getter, Traversal): 399 | """An optic that wraps a pair of getter and setter functions. A getter 400 | function is one that takes a state and returns a value derived from 401 | that state. A setter function takes an old state and a new value 402 | and uses them to construct a new state. 403 | 404 | >>> def getter(state): 405 | ... 'Get the average of a list' 406 | ... return sum(state) // len(state) 407 | ... 408 | >>> def setter(old_state, value): 409 | ... 'Set the average of a list by changing the final value' 410 | ... target_sum = value * len(old_state) 411 | ... prefix = old_state[:-1] 412 | ... return prefix + [target_sum - sum(prefix)] 413 | ... 414 | >>> average = Lens(getter, setter) 415 | >>> average 416 | Lens(, ) 417 | >>> average.view([1, 2, 4, 5]) 418 | 3 419 | >>> average.set([1, 2, 3], 4) 420 | [1, 2, 9] 421 | """ 422 | 423 | def __init__(self, getter: Callable[[S], A], setter: Callable[[S, B], T]) -> None: 424 | self.getter = getter 425 | self.setter = setter 426 | 427 | def func(self, f, state): 428 | old_value = self.getter(state) 429 | fa = f(old_value) 430 | return typeclass.fmap(fa, lambda a: self.setter(state, a)) 431 | 432 | def __repr__(self): 433 | return "Lens({!r}, {!r})".format(self.getter, self.setter) 434 | 435 | 436 | class Review(LensLike): 437 | """A review is an optic that is capable of constructing states from 438 | a focus. 439 | 440 | >>> Review(abs) 441 | Review() 442 | >>> Review(abs).re().view(-1) 443 | 1 444 | """ 445 | 446 | def __init__(self, pack: Callable[[B], T]) -> None: 447 | self.pack = pack 448 | 449 | def func(self, f, state): 450 | return typeclass.fmap(f(state), self.pack) 451 | 452 | def re(self): 453 | return Getter(self.pack) 454 | 455 | def __repr__(self): 456 | return "Review({!r})".format(self.pack) 457 | 458 | 459 | class Prism(Traversal, Review): 460 | """A prism is an optic made from a pair of functions that pack and 461 | unpack a state where the unpacking process can potentially fail. 462 | 463 | `pack` is a function that takes a focus and returns that focus 464 | wrapped up in a new state. `unpack` is a function that takes a state 465 | and unpacks it to get a focus. The unpack function must return an 466 | instance of `lenses.maybe.Maybe`; `Just` if the unpacking succeeded 467 | and `Nothing` if the unpacking failed. 468 | 469 | Parsing strings is a common situation when prisms are useful: 470 | 471 | >>> from lenses.maybe import Nothing, Just 472 | >>> def pack(focus): 473 | ... return str(focus) 474 | >>> def unpack(state): 475 | ... try: 476 | ... return Just(int(state)) 477 | ... except ValueError: 478 | ... return Nothing() 479 | >>> Prism(unpack, pack) 480 | Prism(, ) 481 | >>> Prism(unpack, pack).preview('42') 482 | Just(42) 483 | >>> Prism(unpack, pack).preview('fourty two') 484 | Nothing() 485 | 486 | All prisms are also traversals that have exactly zero or one foci. 487 | """ 488 | 489 | def __init__(self, unpack, pack): 490 | self.unpack = unpack 491 | self.pack = pack 492 | 493 | def folder(self, state): 494 | result = self.unpack(state) 495 | if not result.is_nothing(): 496 | yield result.unwrap() 497 | 498 | def func(self, f, state): 499 | result = self.unpack(state) 500 | if result.is_nothing(): 501 | return f.pure(state) 502 | return typeclass.fmap(f(result.unwrap()), self.pack) 503 | 504 | def has(self, state): 505 | """Returns `True` when the state would be successfully focused 506 | by this prism, otherwise `False`. 507 | 508 | >>> from lenses.maybe import Nothing, Just 509 | >>> def pack(focus): 510 | ... return focus 511 | >>> def unpack(state): 512 | ... if state > 0: 513 | ... return Just(state) 514 | ... return Nothing() 515 | >>> positive = Prism(unpack, pack) 516 | >>> positive.has(-1) 517 | False 518 | >>> positive.has(1) 519 | True 520 | """ 521 | return not self.unpack(state).is_nothing() 522 | 523 | def __repr__(self): 524 | return "Prism({!r}, {!r})".format(self.unpack, self.pack) 525 | 526 | 527 | class Isomorphism(Lens, Prism): 528 | """A lens based on an isomorphism. An isomorphism can be formed by 529 | two functions that mirror each other; they can convert forwards 530 | and backwards between a state and a focus without losing 531 | information. The difference between this and a regular Lens is 532 | that here the backwards functions don't need to know anything about 533 | the original state in order to produce a new state. 534 | 535 | These equalities should hold for the functions you supply (given 536 | a reasonable definition for __eq__): 537 | 538 | backwards(forwards(state)) == state 539 | forwards(backwards(focus)) == focus 540 | 541 | These kinds of conversion functions are very common across the 542 | python ecosystem. For example, NumPy has `np.array` and 543 | `np.ndarray.tolist` for converting between python lists and its own 544 | arrays. Isomorphism makes it easy to store data in one form, but 545 | interact with it in a more convenient form. 546 | 547 | >>> Isomorphism(chr, ord) 548 | Isomorphism(, ) 549 | >>> Isomorphism(chr, ord).view(65) 550 | 'A' 551 | >>> Isomorphism(chr, ord).set(65, 'B') 552 | 66 553 | 554 | Due to their symmetry, isomorphisms can be flipped, thereby swapping 555 | thier forwards and backwards functions: 556 | 557 | >>> flipped = Isomorphism(chr, ord).re() 558 | >>> flipped 559 | Isomorphism(, ) 560 | >>> flipped.view('A') 561 | 65 562 | """ 563 | 564 | def __init__(self, forwards: Callable[[S], A], backwards: Callable[[B], T]) -> None: 565 | self.forwards = forwards 566 | self.backwards = backwards 567 | 568 | def getter(self, state): 569 | return self.forwards(state) 570 | 571 | def setter(self, old_state, focus): 572 | return self.backwards(focus) 573 | 574 | def unpack(self, state): 575 | return Just(self.forwards(state)) 576 | 577 | def pack(self, focus): 578 | return self.backwards(focus) 579 | 580 | def re(self): 581 | return Isomorphism(self.backwards, self.forwards) 582 | 583 | def func(self, f, state): 584 | return typeclass.fmap(f(self.forwards(state)), self.backwards) 585 | 586 | def __repr__(self): 587 | return "Isomorphism({!r}, {!r})".format(self.forwards, self.backwards) 588 | 589 | 590 | class Equality(Isomorphism): 591 | pass 592 | 593 | 594 | class ComposedLens(LensLike): 595 | """A lenses representing the composition of several sub-lenses. This 596 | class tries to just pass operations down to the sublenses without 597 | imposing any constraints on what can happen. The sublenses are in 598 | charge of what capabilities they support. 599 | 600 | >>> import lenses 601 | >>> gi = lenses.optics.GetitemLens 602 | >>> ComposedLens((gi(0), gi(1))) 603 | GetitemLens(0) & GetitemLens(1) 604 | 605 | (The ComposedLens is represented above by the `&` symbol) 606 | """ 607 | 608 | __slots__ = ("lenses",) 609 | 610 | def __init__(self, lenses: Iterable[LensLike] = ()) -> None: 611 | self.lenses = list(self._filter_lenses(lenses)) 612 | 613 | @staticmethod 614 | def _filter_lenses(lenses): 615 | for lens in lenses: 616 | lenstype = type(lens) 617 | if lenstype is TrivialIso: 618 | continue 619 | elif lenstype is ComposedLens: 620 | for lens in lens.lenses: 621 | yield lens 622 | else: 623 | yield lens 624 | 625 | def func(self, f, state): 626 | if not self.lenses: 627 | return TrivialIso().func(f, state) 628 | 629 | res = f 630 | for lens in reversed(self.lenses): 631 | res = res.update(lens.func) 632 | 633 | return res(state) 634 | 635 | def re(self): 636 | return ComposedLens([lens.re() for lens in reversed(self.lenses)]) 637 | 638 | def compose(self, other): 639 | result = ComposedLens(self.lenses + [other]) 640 | if len(result.lenses) == 0: 641 | return TrivialIso() 642 | elif len(result.lenses) == 1: 643 | return result.lenses[0] 644 | if result.kind() is None: 645 | raise RuntimeError("Optic has no valid type") 646 | return result 647 | 648 | def __repr__(self): 649 | return " & ".join(str(lens) for lens in self.lenses) 650 | 651 | def _is_kind(self, cls): 652 | return all(lens._is_kind(cls) for lens in self.lenses) 653 | 654 | 655 | class ErrorIso(Isomorphism): 656 | """An optic that raises an exception whenever it tries to focus 657 | something. If `message is None` then the exception will be raised 658 | unmodified. If `message is not None` then when the lens is asked 659 | to focus something it will run `message.format(state)` and the 660 | exception will be called with the resulting formatted message as 661 | it's only argument. Useful for debugging. 662 | 663 | >>> ErrorIso(Exception()) 664 | ErrorIso(Exception()) 665 | >>> ErrorIso(Exception, '{}') 666 | ErrorIso(, '{}') 667 | >>> ErrorIso(Exception).view(True) 668 | Traceback (most recent call last): 669 | File "", line 1, in ? 670 | Exception 671 | >>> ErrorIso(Exception('An error occurred')).set(True, False) 672 | Traceback (most recent call last): 673 | File "", line 1, in ? 674 | Exception: An error occurred 675 | >>> ErrorIso(ValueError, 'applied to {}').view(True) 676 | Traceback (most recent call last): 677 | File "", line 1, in ? 678 | ValueError: applied to True 679 | """ 680 | 681 | def __init__(self, exception: Exception, message: Optional[str] = None) -> None: 682 | self.exception = exception 683 | self.message = message 684 | 685 | def func(self, f, state): 686 | if self.message is None: 687 | raise self.exception 688 | raise self.exception(self.message.format(state)) 689 | 690 | def __repr__(self): 691 | if self.message is None: 692 | return "ErrorIso({!r})".format(self.exception) 693 | return "ErrorIso({!r}, {!r})".format(self.exception, self.message) 694 | 695 | 696 | class TrivialIso(Isomorphism): 697 | """A trivial isomorphism that focuses the whole state. It doesn't 698 | manipulate the state in any way. Mostly used as a "null" lens. 699 | Analogous to `lambda a: a`. 700 | 701 | >>> TrivialIso() 702 | TrivialIso() 703 | >>> TrivialIso().view(True) 704 | True 705 | >>> TrivialIso().set(True, False) 706 | False 707 | """ 708 | 709 | def __init__(self) -> None: 710 | pass 711 | 712 | def forwards(self, state): 713 | return state 714 | 715 | def backwards(self, focus): 716 | return focus 717 | 718 | def __repr__(self): 719 | return "TrivialIso()" 720 | 721 | 722 | class TupleOptic(Lens): 723 | """An optic that combines the focuses of other optics into a single 724 | tuple. The suboptics must not be Folds or Traversals. The kind of this 725 | optic depends on the kinds that it is supplied with. 726 | 727 | >>> from lenses.optics import GetitemLens 728 | >>> tl = TupleOptic(GetitemLens(0), GetitemLens(2)) 729 | >>> tl 730 | TupleOptic(GetitemLens(0), GetitemLens(2)) 731 | >>> tl.view([1, 2, 3, 4]) 732 | (1, 3) 733 | >>> tl.set([1, 2, 3, 4], (5, 6)) 734 | [5, 2, 6, 4] 735 | >>> tl.kind() 736 | 737 | 738 | >>> tg = TupleOptic(Getter(lambda a: a[0]), GetitemLens(2)) 739 | >>> tg 740 | TupleOptic(Getter(...), GetitemLens(2)) 741 | >>> tl.view([1, 2, 3, 4]) 742 | (1, 3) 743 | >>> tg.kind() 744 | 745 | 746 | This lens is particularly useful when immediately followed by 747 | an EachLens, allowing you to traverse data even when it comes 748 | from disparate locations within the state. 749 | 750 | >>> import lenses 751 | >>> each = lenses.optics.EachTraversal() 752 | >>> tee = tl & each & each 753 | >>> state = ([1, 2, 3], 4, [5, 6]) 754 | >>> tee.to_list_of(state) 755 | [1, 2, 3, 5, 6] 756 | """ 757 | 758 | def __init__(self, *lenses) -> None: 759 | self.lenses = lenses 760 | for lens in self.lenses: 761 | if lens._is_kind(Fold) and not lens._is_kind(Getter): 762 | raise TypeError("TupleOptic doesn't work with folds") 763 | 764 | def getter(self, state): 765 | return tuple(lens.view(state) for lens in self.lenses) 766 | 767 | def setter(self, state, focus): 768 | for lens, new_value in zip(self.lenses, focus): 769 | state = lens.set(state, new_value) 770 | return state 771 | 772 | def _is_kind(self, cls): 773 | return all(lens._is_kind(cls) for lens in self.lenses) 774 | 775 | def __repr__(self): 776 | args = ", ".join(repr(lens) for lens in self.lenses) 777 | return "TupleOptic({})".format(args) 778 | --------------------------------------------------------------------------------