Skip to content

Commit bfc192f

Browse files
authored
python: add lens and camera (#426)
2 parents 540d6cd + c9e5959 commit bfc192f

File tree

14 files changed

+246
-46
lines changed

14 files changed

+246
-46
lines changed

python/example-pytest-selfie/tests/cache_selfie_test.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,10 @@
33
from selfie_lib import cache_selfie
44

55

6+
def random_str() -> str:
7+
return str(random.random())
8+
9+
610
def test_cache_selfie():
7-
cache_selfie(lambda: str(random.random())).to_be("0.6623096709843852")
11+
cache_selfie(lambda: str(random.random())).to_be("0.46009462251400757")
12+
cache_selfie(random_str).to_be("0.6134874512330031")
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from selfie_lib import expect_selfie
2+
3+
4+
def test_quickstart():
5+
expect_selfie([1, 2, 3]).to_be([1, 2, 3])

python/fullstack-pytest.code-workspace

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
},
99
{
1010
"path": "selfie-lib"
11+
},
12+
{
13+
"path": "../.github/workflows"
14+
},
15+
{
16+
"path": "../selfie.dev"
1117
}
1218
],
1319
"settings": {}

python/selfie-lib/selfie_lib/CacheSelfie.py

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import base64
2-
from typing import Any, Generic, Optional, Protocol, TypeVar, Union, overload
2+
from typing import Any, Callable, Generic, Optional, TypeVar, Union, overload
33

44
from .Literals import LiteralString, LiteralValue, TodoStub
55
from .Roundtrip import Roundtrip
@@ -10,24 +10,18 @@
1010
T = TypeVar("T", covariant=True)
1111

1212

13-
class Cacheable(Protocol[T]):
14-
def __call__(self) -> T:
15-
"""Method to get the cached object."""
16-
raise NotImplementedError
17-
18-
1913
@overload
20-
def cache_selfie(to_cache: Cacheable[str]) -> "CacheSelfie[str]": ...
14+
def cache_selfie(to_cache: Callable[..., str]) -> "CacheSelfie[str]": ...
2115

2216

2317
@overload
2418
def cache_selfie(
25-
to_cache: Cacheable[T], roundtrip: Roundtrip[T, str]
19+
to_cache: Callable[..., T], roundtrip: Roundtrip[T, str]
2620
) -> "CacheSelfie[T]": ...
2721

2822

2923
def cache_selfie(
30-
to_cache: Union[Cacheable[str], Cacheable[T]],
24+
to_cache: Union[Callable[..., str], Callable[..., T]],
3125
roundtrip: Optional[Roundtrip[T, str]] = None,
3226
) -> Union["CacheSelfie[str]", "CacheSelfie[T]"]:
3327
if roundtrip is None:
@@ -42,7 +36,10 @@ def cache_selfie(
4236

4337
class CacheSelfie(Generic[T]):
4438
def __init__(
45-
self, disk: DiskStorage, roundtrip: Roundtrip[T, str], generator: Cacheable[T]
39+
self,
40+
disk: DiskStorage,
41+
roundtrip: Roundtrip[T, str],
42+
generator: Callable[..., T],
4643
):
4744
self.disk = disk
4845
self.roundtrip = roundtrip
@@ -110,7 +107,7 @@ def __init__(
110107
self,
111108
disk: DiskStorage,
112109
roundtrip: Roundtrip[T, bytes],
113-
generator: Cacheable[T],
110+
generator: Callable[..., T],
114111
):
115112
self.disk = disk
116113
self.roundtrip = roundtrip

python/selfie-lib/selfie_lib/Lens.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import re
2+
from abc import ABC, abstractmethod
3+
from typing import Callable, Generic, Iterator, List, Optional, Protocol, TypeVar
4+
5+
from .Snapshot import Snapshot, SnapshotValue
6+
7+
T = TypeVar("T")
8+
9+
10+
class Lens(Protocol):
11+
def __call__(self, snapshot: Snapshot) -> Snapshot:
12+
raise NotImplementedError
13+
14+
15+
class CompoundLens(Lens):
16+
def __init__(self):
17+
self.lenses: List[Lens] = []
18+
19+
def __call__(self, snapshot: Snapshot) -> Snapshot:
20+
current = snapshot
21+
for lens in self.lenses:
22+
current = lens(current)
23+
return current
24+
25+
def add(self, lens: Lens) -> "CompoundLens":
26+
self.lenses.append(lens)
27+
return self
28+
29+
def mutate_all_facets(
30+
self, perString: Callable[[str], Optional[str]]
31+
) -> "CompoundLens":
32+
def _mutate_each(snapshot: Snapshot) -> Iterator[tuple[str, SnapshotValue]]:
33+
for entry in snapshot.items():
34+
if entry[1].is_binary:
35+
yield entry
36+
else:
37+
mapped = perString(entry[1].value_string())
38+
if mapped is not None:
39+
yield (entry[0], SnapshotValue.of(mapped))
40+
41+
return self.add(lambda snapshot: Snapshot.of_items(_mutate_each(snapshot)))
42+
43+
def replace_all(self, toReplace: str, replacement: str) -> "CompoundLens":
44+
return self.mutate_all_facets(lambda s: s.replace(toReplace, replacement))
45+
46+
def replace_all_regex(
47+
self, pattern: str | re.Pattern[str], replacement: str
48+
) -> "CompoundLens":
49+
return self.mutate_all_facets(lambda s: re.sub(pattern, replacement, s))
50+
51+
def set_facet_from(
52+
self, target: str, source: str, function: Callable[[str], Optional[str]]
53+
) -> "CompoundLens":
54+
def _set_facet_from(snapshot: Snapshot) -> Snapshot:
55+
source_value = snapshot.subject_or_facet_maybe(source)
56+
if source_value is None:
57+
return snapshot
58+
else:
59+
return self.__set_facet_of(
60+
snapshot, target, function(source_value.value_string())
61+
)
62+
63+
return self.add(_set_facet_from)
64+
65+
def __set_facet_of(
66+
self, snapshot: Snapshot, target: str, new_value: Optional[str]
67+
) -> Snapshot:
68+
if new_value is None:
69+
return snapshot
70+
else:
71+
return snapshot.plus_or_replace(target, SnapshotValue.of(new_value))
72+
73+
def mutate_facet(
74+
self, target: str, function: Callable[[str], Optional[str]]
75+
) -> "CompoundLens":
76+
return self.set_facet_from(target, target, function)
77+
78+
79+
class Camera(Generic[T], ABC):
80+
@abstractmethod
81+
def snapshot(self, subject: T) -> Snapshot:
82+
pass
83+
84+
def with_lens(self, lens: Lens) -> "Camera[T]":
85+
class WithLensCamera(Camera):
86+
def __init__(self, camera: Camera[T], lens: Callable[[Snapshot], Snapshot]):
87+
self.__camera = camera
88+
self.__lens = lens
89+
90+
def snapshot(self, subject: T) -> Snapshot:
91+
return self.__lens(self.__camera.snapshot(subject))
92+
93+
return WithLensCamera(self, lens)

python/selfie-lib/selfie_lib/LineReader.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import io
22

33

4+
def _to_unix(s: str) -> str:
5+
if s.find("\r\n") == -1:
6+
return s
7+
else:
8+
return s.replace("\r\n", "\n")
9+
10+
411
class LineReader:
512
def __init__(self, content: bytes):
613
self.__buffer = io.BytesIO(content)

python/selfie-lib/selfie_lib/Snapshot.py

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
from typing import Dict, Iterable, Union
1+
from typing import Iterator, Union
22

33
from .ArrayMap import ArrayMap
4+
from .LineReader import _to_unix
45
from .SnapshotValue import SnapshotValue
56

67

@@ -34,8 +35,23 @@ def plus_facet(
3435
) -> "Snapshot":
3536
if key == "":
3637
raise ValueError("The empty string is reserved for the subject.")
37-
new_facet_data = self._facet_data.plus(key, SnapshotValue.of(value))
38-
return Snapshot(self._subject, new_facet_data)
38+
return Snapshot(
39+
self._subject,
40+
self._facet_data.plus(_to_unix(key), SnapshotValue.of(value)),
41+
)
42+
43+
def plus_or_replace(
44+
self, key: str, value: Union[bytes, str, SnapshotValue]
45+
) -> "Snapshot":
46+
if key == "":
47+
return Snapshot(SnapshotValue.of(value), self._facet_data)
48+
else:
49+
return Snapshot(
50+
self._subject,
51+
self._facet_data.plus_or_noop_or_replace(
52+
_to_unix(key), SnapshotValue.of(value)
53+
),
54+
)
3955

4056
def subject_or_facet_maybe(self, key: str) -> Union[SnapshotValue, None]:
4157
return self._subject if key == "" else self._facet_data.get(key)
@@ -53,19 +69,27 @@ def of(data: Union[bytes, str, SnapshotValue]) -> "Snapshot":
5369
return Snapshot(data, ArrayMap.empty())
5470

5571
@staticmethod
56-
def of_entries(entries: Iterable[Dict[str, SnapshotValue]]) -> "Snapshot":
57-
root = None
72+
def of_items(items: Iterator[tuple[str, SnapshotValue]]) -> "Snapshot":
73+
subject = None
5874
facets = ArrayMap.empty()
59-
for entry in entries:
60-
key, value = entry["key"], entry["value"]
75+
for entry in items:
76+
(key, value) = entry
6177
if key == "":
62-
if root is not None:
63-
raise ValueError("Duplicate root snapshot detected")
64-
root = value
78+
if subject is not None:
79+
raise ValueError(
80+
"Duplicate root snapshot value.\n first: ${subject}\n second: ${value}"
81+
)
82+
subject = value
6583
else:
6684
facets = facets.plus(key, value)
67-
return Snapshot(root if root else SnapshotValue.of(""), facets)
85+
return Snapshot(subject if subject else SnapshotValue.of(""), facets)
6886

69-
@staticmethod
70-
def _unix_newlines(string: str) -> str:
71-
return string.replace("\\r\\n", "\\n")
87+
def items(self) -> Iterator[tuple[str, SnapshotValue]]:
88+
yield ("", self._subject)
89+
yield from self._facet_data.items()
90+
91+
def __repr__(self) -> str:
92+
pieces = [f"Snapshot.of({self.subject.value_string()!r})"]
93+
for e in self.facets.items():
94+
pieces.append(f"\n .plus_facet({e[0]!r}, {e[1].value_string()!r})") # noqa: PERF401
95+
return "".join(pieces)

python/selfie-lib/selfie_lib/SnapshotSystem.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,8 @@ def msg(self, headline: str) -> str:
130130
if self == Mode.interactive:
131131
return (
132132
f"{headline}\n"
133-
"- update this snapshot by adding '_TODO' to the function name\n"
134-
"- update all snapshots in this file by adding '# selfieonce' or '# SELFIEWRITE'"
133+
"- update this snapshot by adding `_TODO` to the function name\n"
134+
"- update all snapshots in this file by adding `#selfieonce` or `#SELFIEWRITE`"
135135
)
136136
elif self == Mode.readonly:
137137
return headline

python/selfie-lib/selfie_lib/SnapshotValue.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
from abc import ABC, abstractmethod
22
from typing import Union
33

4-
5-
def unix_newlines(string: str) -> str:
6-
return string.replace("\r\n", "\n")
4+
from .LineReader import _to_unix
75

86

97
class SnapshotValue(ABC):
@@ -24,7 +22,7 @@ def of(data: Union[bytes, str, "SnapshotValue"]) -> "SnapshotValue":
2422
if isinstance(data, bytes):
2523
return SnapshotValueBinary(data)
2624
elif isinstance(data, str):
27-
return SnapshotValueString(data)
25+
return SnapshotValueString(_to_unix(data))
2826
elif isinstance(data, SnapshotValue):
2927
return data
3028
else:

python/selfie-lib/selfie_lib/SnapshotValueReader.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,6 @@
77
from .SnapshotValue import SnapshotValue
88

99

10-
def unix_newlines(string: str) -> str:
11-
return string.replace("\r\n", "\n")
12-
13-
1410
class SnapshotValueReader:
1511
KEY_FIRST_CHAR = "╔"
1612
KEY_START = "╔═ "

0 commit comments

Comments
 (0)