Skip to content

Commit bfc192f

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

14 files changed

+246
-46
lines changed

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

+6-1
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")
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

+6
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

+9-12
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

+93
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

+7
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

+38-14
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

+2-2
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

+2-4
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

-4
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 = "╔═ "

python/selfie-lib/selfie_lib/__init__.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
from .ArrayMap import ArrayMap as ArrayMap
33
from .ArrayMap import ArraySet as ArraySet
44
from .Atomic import AtomicReference as AtomicReference
5-
from .CacheSelfie import Cacheable as Cacheable
65
from .CacheSelfie import cache_selfie as cache_selfie
76
from .CommentTracker import CommentTracker as CommentTracker
87
from .FS import FS as FS
8+
from .Lens import Camera as Camera
9+
from .Lens import CompoundLens as CompoundLens
10+
from .Lens import Lens as Lens
911
from .LineReader import LineReader as LineReader
1012
from .Literals import LiteralValue as LiteralValue
1113
from .ParseException import ParseException as ParseException
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from selfie_lib import CompoundLens, Snapshot
2+
3+
4+
def test_replace_all():
5+
replace_all = CompoundLens().replace_all("e", "E")
6+
assert (
7+
repr(replace_all(Snapshot.of("subject").plus_facet("key", "value")))
8+
== """Snapshot.of('subjEct')
9+
.plus_facet('key', 'valuE')"""
10+
)
11+
12+
13+
def test_replace_all_regex():
14+
replace_all_regex = CompoundLens().replace_all_regex(r"(\w+)", r"\1!")
15+
assert (
16+
repr(
17+
replace_all_regex(
18+
Snapshot.of("this is subject").plus_facet("key", "this is facet")
19+
)
20+
)
21+
== """Snapshot.of('this! is! subject!')
22+
.plus_facet('key', 'this! is! facet!')"""
23+
)
24+
25+
26+
def test_set_facet_from():
27+
set_facet_from = CompoundLens().set_facet_from("uppercase", "", lambda s: s.upper())
28+
assert (
29+
repr(set_facet_from(Snapshot.of("subject")))
30+
== """Snapshot.of('subject')
31+
.plus_facet('uppercase', 'SUBJECT')"""
32+
)
33+
34+
35+
def test_mutate_facet():
36+
mutate_facet = CompoundLens().mutate_facet("key", lambda s: s.upper())
37+
assert (
38+
repr(mutate_facet(Snapshot.of("subject").plus_facet("key", "facet")))
39+
== """Snapshot.of('subject')
40+
.plus_facet('key', 'FACET')"""
41+
)
+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from selfie_lib import Snapshot
2+
3+
4+
def test_items():
5+
undertest = (
6+
Snapshot.of("subject").plus_facet("key1", "value1").plus_facet("key2", "value2")
7+
)
8+
roundtrip = Snapshot.of_items(undertest.items())
9+
assert roundtrip == undertest
10+
11+
12+
def test_repr():
13+
assert repr(Snapshot.of("subject")) == "Snapshot.of('subject')"
14+
assert repr(Snapshot.of("subject\nline2")) == "Snapshot.of('subject\\nline2')"
15+
assert (
16+
repr(Snapshot.of("subject").plus_facet("apple", "green"))
17+
== "Snapshot.of('subject')\n .plus_facet('apple', 'green')"
18+
)
19+
assert (
20+
repr(
21+
Snapshot.of("subject")
22+
.plus_facet("orange", "peel")
23+
.plus_facet("apple", "green")
24+
)
25+
== "Snapshot.of('subject')\n .plus_facet('apple', 'green')\n .plus_facet('orange', 'peel')"
26+
)

0 commit comments

Comments
 (0)