Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Docs for 1.0 #498

Merged
merged 17 commits into from
Dec 16, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/python-ci.yml
Original file line number Diff line number Diff line change
@@ -32,13 +32,17 @@ jobs:
- name: selfie-lib - ruff
run: uv run ruff format --check && uv run ruff check
working-directory: python/selfie-lib
- name: pytest-selfie - pytest
run: uv run pytest -vv
working-directory: python/pytest-selfie
- name: pytest-selfie - pyright
run: uv run pyright
working-directory: python/pytest-selfie
- name: pytest-selfie - ruff
run: uv run ruff format --check && uv run ruff check
working-directory: python/pytest-selfie
- run: uv run pytest -vv
- name: example-pytest-selfie - pytest
run: uv run pytest -vv
working-directory: python/example-pytest-selfie
- name: example-pytest-selfie - pyright
run: uv run pyright
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -10,4 +10,5 @@ python/html/
python/latex/
python/selfie-lib/selfie_lib.egg-info/
python/pytest-selfie/pytest_selfie.egg-info/
node_modules/
node_modules/
.pytest_cache/
3 changes: 2 additions & 1 deletion python/example-pytest-selfie/app.py
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@

from flask import (
Flask,
abort,
jsonify,
make_response,
redirect,
@@ -86,7 +87,7 @@ def auth_user():
return None
email, signature = login_cookie.split("|")
if signature != sign_email(email):
return None
return abort(401)
return {"email": email}


Original file line number Diff line number Diff line change
@@ -13,7 +13,18 @@ def client():
yield client


def test_homepage(client):
def test_homepage_v1(client):
expect_selfie(client.get("/").data.decode()).to_be("""
<html><body>
<h1>Please login</h1>
<form action="/login" method="post">
<input type="text" name="email" placeholder="email">
<input type="submit" value="login">
</form>
</body></html>""")


def test_homepage_v2(client):
web_selfie(client.get("/")).to_be("""<html>
<body>
<h1>
@@ -32,25 +43,16 @@ def test_homepage(client):
200 OK""")


def test_T01_not_logged_in(client):
response = client.get("/")
expect_selfie(response.data.decode()).to_be("""
<html><body>
<h1>Please login</h1>
<form action="/login" method="post">
<input type="text" name="email" placeholder="email">
<input type="submit" value="login">
</form>
</body></html>""")
def test_login_flow(client):
web_selfie(client.get("/")).to_match_disk("1. not logged in").facet("md").to_be(
"Please login"
)

web_selfie(client.post("/login", data={"email": "user@domain.com"})).to_match_disk(
"2. post login form"
).facet("md").to_be("""Email sent!

def test_T02_login(client):
response = client.post("/login", data={"email": "user@domain.com"})
expect_selfie(response.data.decode()).to_be("""
<html><body>
<h1>Email sent!</h1>
<p>Check your email for your login link.</p>
</body></html>""")
Check your email for your login link.""")

email = wait_for_incoming_email()
expect_selfie(email).to_be(
@@ -61,19 +63,21 @@ def test_T02_login(client):
}
)

web_selfie(client.get("/login-confirm/2Yw4aCQ")).to_be("""REDIRECT 302 Found to /
╔═ [cookies] ═╗
login=user@domain.com|29Xwa32OsHUoHm4TRitwQMWpuynz3r1aw3BcB5pPGdY=; Path=/""")

def test_T03_login_confirm(client):
response = client.get("/login-confirm/erjchFY=", follow_redirects=False)
expect_selfie(headers_to_string(response)).to_be("""200 OK
Content-Type=text/html; charset=utf-8""")


def headers_to_string(response):
headers = [f"{response.status}"]
for name, value in response.headers.items():
if name.lower() not in ["server", "date", "content-length"]:
headers.append(f"{name}={value}")
return "\n".join(headers)
client.set_cookie(
"login", "user@domain.com|29Xwa32OsHUoHm4TRitwQMWpuynz3r1aw3BcB5pPGdY="
)
web_selfie(client.get("/")).to_match_disk("3. log in works with cookies").facet(
"md"
).to_be("Welcome back user@domain.com")

client.set_cookie("login", "user@domain.com|ABCDEF")
web_selfie(client.get("/")).to_match_disk(
"4. log in fails with fake cookies"
).facet("status").to_be("401 UNAUTHORIZED")


if __name__ == "__main__":
71 changes: 71 additions & 0 deletions python/example-pytest-selfie/tests/facets_test.ss
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
╔═ test_login_flow/1. not logged in ═╗
<html>
<body>
<h1>
Please login
</h1>
<form action="/login" method="post">
<input name="email" placeholder="email" type="text"/>
<input type="submit" value="login"/>
</form>
</body>
</html>

╔═ test_login_flow/1. not logged in[md] ═╗
Please login
╔═ test_login_flow/1. not logged in[status] ═╗
200 OK
╔═ test_login_flow/2. post login form ═╗
<html>
<body>
<h1>
Email sent!
</h1>
<p>
Check your email for your login link.
</p>
</body>
</html>

╔═ test_login_flow/2. post login form[md] ═╗
Email sent!

Check your email for your login link.
╔═ test_login_flow/2. post login form[status] ═╗
200 OK
╔═ test_login_flow/3. log in works with cookies ═╗
<html>
<body>
<h1>
Welcome back user@domain.com
</h1>
</body>
</html>

╔═ test_login_flow/3. log in works with cookies[md] ═╗
Welcome back user@domain.com
╔═ test_login_flow/3. log in works with cookies[status] ═╗
200 OK
╔═ test_login_flow/4. log in fails with fake cookies ═╗
<!DOCTYPE html>
<html lang="en">
<title>
401 Unauthorized
</title>
<h1>
Unauthorized
</h1>
<p>
The server could not verify that you are authorized to access the URL requested. You either supplied the wrong credentials (e.g. a bad password), or your browser doesn't understand how to supply the credentials required.
</p>
</html>

╔═ test_login_flow/4. log in fails with fake cookies[md] ═╗
401 Unauthorized

Unauthorized

The server could not verify that you are authorized to access the URL requested. You either supplied the wrong credentials (e.g. a bad password), or your browser doesn't understand how to supply the credentials required.
╔═ test_login_flow/4. log in fails with fake cookies[status] ═╗
401 UNAUTHORIZED
╔═ [end of file] ═╗
12 changes: 10 additions & 2 deletions python/example-pytest-selfie/tests/selfie_settings.py
Original file line number Diff line number Diff line change
@@ -16,12 +16,20 @@
def _web_camera(response: TestResponse) -> Snapshot:
redirect_reason = REDIRECTS.get(response.status_code)
if redirect_reason is not None:
return Snapshot.of(
snapshot = Snapshot.of(
f"REDIRECT {response.status_code} {redirect_reason} to "
+ response.headers.get("Location", "<unknown>")
)
else:
return Snapshot.of(response.data.decode()).plus_facet("status", response.status)
snapshot = Snapshot.of(response.data.decode()).plus_facet(
"status", response.status
)

if response.headers.get("Set-Cookie"):
snapshot = snapshot.plus_facet(
"cookies", response.headers.get("Set-Cookie", "")
)
return snapshot


def _pretty_print_html(html: str):
245 changes: 239 additions & 6 deletions python/example-pytest-selfie/uv.lock

Large diffs are not rendered by default.

Empty file.
2 changes: 2 additions & 0 deletions python/pytest-selfie/tests/placeholder_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def test_placeholder():
pass
79 changes: 79 additions & 0 deletions python/pytest-selfie/uv.lock
4 changes: 2 additions & 2 deletions python/selfie-lib/selfie_lib/CacheSelfie.py
Original file line number Diff line number Diff line change
@@ -171,7 +171,7 @@ def _to_be_file_impl(self, subpath: str, is_todo: bool) -> T:
return actual
else:
if is_todo:
raise Exception("Can't call `toBeFile_TODO` in read-only mode!")
raise Exception("Can't call `to_be_file_TODO` in read-only mode!")
else:
with open(subpath, "rb") as file:
serialized_data = file.read()
@@ -201,7 +201,7 @@ def _to_be_base64_impl(self, snapshot: Optional[str]) -> T:
return actual
else:
if snapshot is None:
raise Exception("Can't call `toBe_TODO` in read-only mode!")
raise Exception("Can't call `to_be_TODO` in read-only mode!")
else:
decoded_data = base64.b64decode(snapshot.encode("utf-8"))
return self.roundtrip.parse(decoded_data)
36 changes: 18 additions & 18 deletions python/selfie-lib/selfie_lib/SelfieImplementations.py
Original file line number Diff line number Diff line change
@@ -70,7 +70,7 @@ def to_match_disk(self, sub: str = "") -> "DiskSelfie":
if _selfieSystem().mode.can_write(False, call, _selfieSystem()):
self.disk.write_disk(self.actual, sub, call)
else:
_assertEqual(self.disk.read_disk(sub, call), self.actual, _selfieSystem())
_assert_equal(self.disk.read_disk(sub, call), self.actual, _selfieSystem())
return self

def to_match_disk_TODO(self, sub: str = "") -> "DiskSelfie":
@@ -100,13 +100,13 @@ def __init__(self, actual_before_repr: T, actual: Snapshot, disk: DiskStorage):
self.actual_before_repr = actual_before_repr

def to_be_TODO(self, _: Optional[T] = None) -> T:
return _toBeDidntMatch(None, self.actual_before_repr, LiteralRepr())
return _to_be_didnt_match(None, self.actual_before_repr, LiteralRepr())

def to_be(self, expected: T) -> T:
if self.actual_before_repr == expected:
return _checkSrc(self.actual_before_repr)
return _check_src(self.actual_before_repr)
else:
return _toBeDidntMatch(expected, self.actual_before_repr, LiteralRepr())
return _to_be_didnt_match(expected, self.actual_before_repr, LiteralRepr())


class StringSelfie(ReprSelfie[str], StringFacet):
@@ -154,19 +154,19 @@ def __actual(self) -> str:
else:
return only_value.value_string()
else:
return _serializeOnlyFacets(
return _serialize_only_facets(
self.actual, self.only_facets or ["", *list(self.actual.facets.keys())]
)

def to_be_TODO(self, _: Any = None) -> str:
return _toBeDidntMatch(None, self.__actual(), LiteralString())
return _to_be_didnt_match(None, self.__actual(), LiteralString())

def to_be(self, expected: str) -> str:
actual_string = self.__actual()
if actual_string == expected:
return _checkSrc(actual_string)
return _check_src(actual_string)
else:
return _toBeDidntMatch(
return _to_be_didnt_match(
expected,
actual_string,
LiteralString(),
@@ -198,16 +198,16 @@ def to_match_disk_TODO(self, sub: str = "") -> "BinarySelfie":
return self

def to_be_base64_TODO(self, _: Any = None) -> bytes:
_toBeDidntMatch(None, self._actual_string(), LiteralString())
_to_be_didnt_match(None, self._actual_string(), LiteralString())
return self._actual_bytes()

def to_be_base64(self, expected: str) -> bytes:
expected_bytes = base64.b64decode(expected)
actual_bytes = self._actual_bytes()
if actual_bytes == expected_bytes:
return _checkSrc(actual_bytes)
return _check_src(actual_bytes)
else:
_toBeDidntMatch(expected, self._actual_string(), LiteralString())
_to_be_didnt_match(expected, self._actual_string(), LiteralString())
return actual_bytes

def _actual_string(self) -> str:
@@ -253,12 +253,12 @@ def to_be_file(self, subpath: str) -> bytes:
return self._to_be_file_impl(subpath, False)


def _checkSrc(value: T) -> T:
def _check_src(value: T) -> T:
_selfieSystem().mode.can_write(False, recordCall(True), _selfieSystem())
return value


def _toBeDidntMatch(expected: Optional[T], actual: T, fmt: LiteralFormat[T]) -> T:
def _to_be_didnt_match(expected: Optional[T], actual: T, fmt: LiteralFormat[T]) -> T:
call = recordCall(False)
writable = _selfieSystem().mode.can_write(expected is None, call, _selfieSystem())
if writable:
@@ -267,7 +267,7 @@ def _toBeDidntMatch(expected: Optional[T], actual: T, fmt: LiteralFormat[T]) ->
else:
if expected is None:
raise _selfieSystem().fs.assert_failed(
f"Can't call `toBe_TODO` in {Mode.readonly} mode!"
f"Can't call `to_be_TODO` in {Mode.readonly} mode!"
)
else:
expectedStr = repr(expected)
@@ -286,7 +286,7 @@ def _toBeDidntMatch(expected: Optional[T], actual: T, fmt: LiteralFormat[T]) ->
)


def _assertEqual(
def _assert_equal(
expected: Optional[Snapshot], actual: Snapshot, storage: SnapshotSystem
):
if expected is None:
@@ -305,8 +305,8 @@ def _assertEqual(
),
)
)
expectedFacets = _serializeOnlyFacets(expected, mismatched_keys)
actualFacets = _serializeOnlyFacets(actual, mismatched_keys)
expectedFacets = _serialize_only_facets(expected, mismatched_keys)
actualFacets = _serialize_only_facets(actual, mismatched_keys)
raise storage.fs.assert_failed(
message=storage.mode.msg_snapshot_mismatch(
expected=expectedFacets, actual=actualFacets
@@ -316,7 +316,7 @@ def _assertEqual(
)


def _serializeOnlyFacets(snapshot: Snapshot, keys: list[str]) -> str:
def _serialize_only_facets(snapshot: Snapshot, keys: list[str]) -> str:
writer = []
for key in keys:
if not key:
70 changes: 35 additions & 35 deletions python/selfie-lib/tests/SourceFile_test.py
Original file line number Diff line number Diff line change
@@ -19,72 +19,72 @@ def python_test_error(source_raw, error_msg):


def todo():
python_test(".toBe_TODO()", ".toBe_TODO()", "")
python_test(" .toBe_TODO() ", ".toBe_TODO()", "")
python_test(" .toBe_TODO( ) ", ".toBe_TODO( )", "")
python_test(" .toBe_TODO( \n ) ", ".toBe_TODO( \n )", "")
python_test(".to_be_TODO()", ".to_be_TODO()", "")
python_test(" .to_be_TODO() ", ".to_be_TODO()", "")
python_test(" .to_be_TODO( ) ", ".to_be_TODO( )", "")
python_test(" .to_be_TODO( \n ) ", ".to_be_TODO( \n )", "")


def numeric():
python_test(".toBe(7)", ".toBe(7)", "7")
python_test(" .toBe(7)", ".toBe(7)", "7")
python_test(".toBe(7) ", ".toBe(7)", "7")
python_test(" .toBe(7) ", ".toBe(7)", "7")
python_test(" .toBe( 7 ) ", ".toBe( 7 )", "7")
python_test(" .toBe(\n7) ", ".toBe(\n7)", "7")
python_test(" .toBe(7\n) ", ".toBe(7\n)", "7")
python_test(".to_be(7)", ".to_be(7)", "7")
python_test(" .to_be(7)", ".to_be(7)", "7")
python_test(".to_be(7) ", ".to_be(7)", "7")
python_test(" .to_be(7) ", ".to_be(7)", "7")
python_test(" .to_be( 7 ) ", ".to_be( 7 )", "7")
python_test(" .to_be(\n7) ", ".to_be(\n7)", "7")
python_test(" .to_be(7\n) ", ".to_be(7\n)", "7")


def single_line_string():
python_test(".toBe('7')", "'7'")
python_test(".toBe('')", "''")
python_test(".toBe( '' )", "''")
python_test(".toBe( \n '' \n )", "''")
python_test(".toBe( \n '78' \n )", "'78'")
python_test(".toBe('\\'')", "'\\''")
python_test(".to_be('7')", "'7'")
python_test(".to_be('')", "''")
python_test(".to_be( '' )", "''")
python_test(".to_be( \n '' \n )", "''")
python_test(".to_be( \n '78' \n )", "'78'")
python_test(".to_be('\\'')", "'\\''")


def multi_line_string():
python_test(".toBe('''7''')", "'''7'''")
python_test(".toBe(''' 7 ''')", "''' 7 '''")
python_test(".toBe('''\n7\n''')", "'''\n7\n'''")
python_test(".toBe(''' ' '' ' ''')", "''' ' '' ' '''")
python_test(".to_be('''7''')", "'''7'''")
python_test(".to_be(''' 7 ''')", "''' 7 '''")
python_test(".to_be('''\n7\n''')", "'''\n7\n'''")
python_test(".to_be(''' ' '' ' ''')", "''' ' '' ' '''")


def error_unclosed():
python_test_error(
".toBe(", "Appears to be an unclosed function call `.toBe()` on line 1"
".to_be(", "Appears to be an unclosed function call `.to_be()` on line 1"
)
python_test_error(
".toBe( \n ", "Appears to be an unclosed function call `.toBe()` on line 1"
".to_be( \n ", "Appears to be an unclosed function call `.to_be()` on line 1"
)
python_test_error(
".toBe_TODO(",
"Appears to be an unclosed function call `.toBe_TODO()` on line 1",
".to_be_TODO(",
"Appears to be an unclosed function call `.to_be_TODO()` on line 1",
)
python_test_error(
".toBe_TODO( \n ",
"Appears to be an unclosed function call `.toBe_TODO()` on line 1",
".to_be_TODO( \n ",
"Appears to be an unclosed function call `.to_be_TODO()` on line 1",
)
python_test_error(
".toBe_TODO(')", 'Appears to be an unclosed string literal `"` on line 1'
".to_be_TODO(')", 'Appears to be an unclosed string literal `"` on line 1'
)
python_test_error(
".toBe_TODO(''')",
".to_be_TODO(''')",
'Appears to be an unclosed multiline string literal `"""` on line 1',
)


def error_non_primitive():
python_test_error(
".toBe(1 + 1)",
"Non-primitive literal in `.toBe()` starting at line 1: error for character `+` on line 1",
".to_be(1 + 1)",
"Non-primitive literal in `.to_be()` starting at line 1: error for character `+` on line 1",
)
python_test_error(
".toBe('1' + '1')",
"Non-primitive literal in `.toBe()` starting at line 1: error for character `+` on line 1",
".to_be('1' + '1')",
"Non-primitive literal in `.to_be()` starting at line 1: error for character `+` on line 1",
)
python_test_error(
".toBe('''1''' + '''1''')",
"Non-primitive literal in `.toBe()` starting at line 1: error for character `+` on line 1",
".to_be('''1''' + '''1''')",
"Non-primitive literal in `.to_be()` starting at line 1: error for character `+` on line 1",
)
173 changes: 173 additions & 0 deletions python/selfie-lib/uv.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion selfie.dev/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
480 changes: 288 additions & 192 deletions selfie.dev/package-lock.json

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions selfie.dev/src/pages/py/facets.mdx
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@ Assuming you have [installed selfie](/py/get-started#installation) and glanced t

## Our toy project

We'll be using the [`example-pytest-selfie`](https://github.com/diffplug/selfie/tree/main/python/example-pytest-selfie) project from the selfie GitHub repo. You can clone the code and follow along, but there's no need to. If you did clone the project, you could run `poetry run python app.py` and you'd have a little flask webapp running at `127.0.0.1:5000`.
We'll be using the [`example-pytest-selfie`](https://github.com/diffplug/selfie/tree/main/python/example-pytest-selfie) project from the selfie GitHub repo. You can clone the code and follow along, but there's no need to. If you did clone the project, you could run `uv run python app.py` and you'd have a little flask webapp running at `127.0.0.1:5000 ` (`localhost` might not work, make sure to use `127.0.0.1`!).

It has a homepage where we can login. We can go to `/email` to see the emails the server has sent and click our login link, and boom we've got some auth cookies.

@@ -28,7 +28,7 @@ def client():
with app.test_client() as client:
yield client

def test_homepage(client):
def test_homepage_v1(client):
expect_selfie(client.get("/").data.decode()).to_be("""
<html><body>
<h1>Please login</h1>
@@ -52,7 +52,7 @@ from werkzeug.test import TestResponse # this is what `app.test_client().get` re
def web_selfie(response: TestResponse) -> StringSelfie:
return expect_selfie(response.data.decode())

def test_homepage(client):
def test_homepage_v2(client):
web_selfie(client.get("/")).to_be("""
<html><body>
<h1>Please login</h1>
@@ -79,7 +79,7 @@ def web_selfie(response: TestResponse) -> StringSelfie:
And now our snapshot has `status` at the bottom, which we can use in both literal and disk snapshots.

```python
def test_homepage():
def test_homepage_v2():
expect_selfie(get("/")).toBe("""
<html><body>
<h1>Please login</h1>
@@ -243,7 +243,7 @@ login=user@domain.com|JclThw==;Path=/""")
status code: 401""")
```

We just wrote a high-level specification of a realistic login flow, and it only took TODO lines of python code — most of which were generated for us, and could be regenerated on a whim if we want to change our copywriting. The [corresponding disk snapshot TODO](https://github.com/diffplug/selfie/issues/322) gives us an exhaustive specification and description of the server's behavior.
We just wrote a high-level specification of a realistic login flow, and it only took 24 lines of python code — most of which were generated for us, and could be regenerated on a whim if we want to change our copywriting. The [corresponding disk snapshot](https://github.com/diffplug/selfie/blob/main/python/example-pytest-selfie/tests/facets_test.ss) gives us an exhaustive specification and description of the server's behavior.

Didn't think that adopting a bugfixed version of your internationalization lib would cause any changes to your website whatsever? Oops. Don't wade through failed assertions, get a diff in every failure. If you want, regenerate all the snapshots to get a full view of the problem across the whole codebase in your git client.

42 changes: 21 additions & 21 deletions selfie.dev/src/pages/py/get-started.mdx
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ To start snapshot testing in Python, all you need is to add a single dependency.

Selfie requires Python 3.9 or newer. It has plugins for the following Python test runners:

- Pytest via [pytest-selfie](https://pypi.org/project/pytest-selfie/).
- Pytest via `pytest-selfie` ([PyPI](https://pypi.org/project/pytest-selfie/), [changelog](https://github.com/diffplug/selfie/blob/main/python/CHANGELOG.md))
- PRs welcome for other test runners (see [here](https://github.com/diffplug/selfie/issues/350) for a guide)

## Quickstart
@@ -71,19 +71,19 @@ This will create a file called `SomethingOrOther.ss` in the same directory as yo
expect_selfie([1, 2, 3]).to_match_disk()
```

Just like inline literal snapshots, you can use `_TODO`, `//selfieonce`, and `//SELFIEWRITE` to control how the snapshots are written and updated. You don't have to use `_TODO` if you have the `//selfieonce` or `//SELFIEWRITE` comments in your file.
Just like inline literal snapshots, you can use `_TODO`, `#selfieonce`, and `#SELFIEWRITE` to control how the snapshots are written and updated. You don't have to use `_TODO` if you have the `#selfieonce` or `#SELFIEWRITE` comments in your file.

If you want the disk snapshots to live in a different folder, set `snapshotFolderName` using [SelfieSettings](https://pydoc.selfie.dev/namespacepytest__selfie_1_1SelfieSettingsAPI).

## CI

The nice thing about `//SELFIEWRITE` is that all of your snapshots will update on every run, which makes it easy to explore — like multiassert on steroids. **The bad thing about `//SELFIEWRITE` is that all of the tests always pass, even if the snapshots actually change on every run.**
The nice thing about `#SELFIEWRITE` is that all of your snapshots will update on every run, which makes it easy to explore — like multiassert on steroids. **The bad thing about `#SELFIEWRITE` is that all of the tests always pass, even if the snapshots actually change on every run.**

For example, you might have a realtime timestamp or a random port number embedded in a snapshot. Randomness and realtime cannot be present in a repeatable assertion, and you might not realize that a tiny part of a large snapshot is changing while you're in `//SELFIEWRITE` mode.
For example, you might have a realtime timestamp or a random port number embedded in a snapshot. Randomness and realtime cannot be present in a repeatable assertion, and you might not realize that a tiny part of a large snapshot is changing while you're in `#SELFIEWRITE` mode.

For this reason, it is critical that a CI server should always run in `readonly` mode. No action is needed on your part, selfie automatically puts itself into `readonly` mode if the `CI=true` environment variable is present, which is true for all popular CI systems.

When in `readonly` mode, selfie not only refuses to update any snapshots, it also fails the build if `_TODO`, `//selfieonce`, or `//SELFIEWRITE` are present in the sourcecode, even if the snapshots were correct. Writing snapshots is a strictly private affair 😏.
When in `readonly` mode, selfie not only refuses to update any snapshots, it also fails the build if `_TODO`, `#selfieonce`, or `#SELFIEWRITE` are present in the sourcecode, even if the snapshots were correct. Writing snapshots is a strictly private affair 😏.

## Overwrite everything

@@ -95,27 +95,27 @@ Selfie has three modes:

To set the mode, you set the `selfie` or `SELFIE` environment variable or system property to either `interactive`, `readonly`, or `overwrite`. But in the vast majority of cases, it's best to leave it alone and let the defaults do their thing.

**TODO: Change once available**
## Strings, repr, and more

```console
[MAVEN]
user@machine repo % mvn test -Dselfie=overwrite
If you call `repr(x)` on some variable `x`, Python will return a string that represents the underlying value. This string is what selfie uses to stuff a value inside the brackets of `to_be()`.

[GRADLE (only works if you followed the install instructions above re: environment)]
user@machine repo % ./gradlew test -Pselfie=overwrite
```

## Beyond toString

All of the examples so far have asserted on Strings. You can also do inline literal assertions on primitive values, and disk assertions on byte arrays:
All of the examples so far have asserted on the string returned by `repr`. But selfie also has special handling for byte arrays (`bytes`).

```python
expect_selfie(10/4).to_be(2.5)
expect_selfie((10/4) == 2.5).to_be(true)
expect_selfie((10/4) == 2.5).to_be(True)

# saves as base64 within a subsection of a `.ss` file, with garbage collection for unused sections
expect_selfie(bytearray(100)).to_match_disk()

# base64 assertion
expect_selfie(bytearray(100)).to_be_base64("blahblah")

# raw local file, no garbage collection
expect_selfie(generatePngFile()).to_be_file("you_can_open_image_locally.png")
```

But the real power of selfie is asserting on arbitrary objects using **facets**, which are covered in the [advanced section](/py/facets).
But the real power of selfie is asserting on arbitrary objects using **facets**, which are covered in more detail [in their own section](/py/facets).

## Reference

@@ -125,16 +125,16 @@ Full API documentation is available at [pydoc.selfie.dev](https://pydoc.selfie.d
- creates or updates an inline literal snapshot
- `.to_match_disk_TODO()`
- creates or updates a disk snapshot
- `//selfieonce`
- `#selfieonce`
- all snapshots in the file will be updated, whether they are `_TODO` or not
- selfie will remove the comment after the snapshots have updated
- `//SELFIEWRITE`
- `#SELFIEWRITE`
- all snapshots in the file will be updated, whether they are `_TODO` or not
- selfie will not remove the comment after the snapshots have updated
- mode is set by the `SELFIE` environment variable or system property
- `interactive`, default
- `readonly`, default if `CI` environment variable is `true`
- `overwrite`, all snapshots can be overwritten
- `Camera` and `Lens` are covered in the [advanced section](/py/facets).
- `Camera` and `Lens` are covered in the [facets section](/py/facets).

_Pull requests to improve the landing page and documentation are greatly appreciated, you can find the [source code here](https://github.com/diffplug/selfie)._
10 changes: 2 additions & 8 deletions selfie.dev/src/pages/py/index.mdx
Original file line number Diff line number Diff line change
@@ -43,8 +43,6 @@ And from now on it's a proper assertion, but you didn't have to spend any time w

<NavHeading text="like-a-filesystem" popout="/py/get-started#disk" />

## NOT READY YET - WIP

That `primes_below(100)` snapshot above is almost too long. Something bigger, such as `primes_below(10_000)` is definitely too big. To handle this, selfie lets you put your snapshots on disk.

```python
@@ -86,8 +84,6 @@ You can treat your snapshot files as an output deliverable of your code, and use

<NavHeading text="lensable" popout="/py/facets" />

## NOT READY YET - WIP

A problem with the snapshots we've shown so far is that they are one dimensional. What about headers and cookies? What about the content the user actually sees, without all the markup? What if we could do this?

```
@@ -116,7 +112,7 @@ def test_order_flow():
.facet("md").to_be("Thanks for your business!")
```

Selfie's faceting is built around [Camera](https://kdoc.selfie.dev/selfie-lib/com.diffplug.selfie/-camera/), [Lens](https://kdoc.selfie.dev/selfie-lib/com.diffplug.selfie/-lens/), and [Snapshot](https://kdoc.selfie.dev/selfie-lib/com.diffplug.selfie/-snapshot/), whose API is roughly:
Selfie's faceting is built around [Camera, Lens](https://pydoc.selfie.dev/Lens_8py_source), and [Snapshot](https://pydoc.selfie.dev/classselfie__lib_1_1Snapshot_1_1Snapshot/), whose API is roughly:

```python
class Snapshot:
@@ -134,12 +130,10 @@ class Camera(Generic [T]):
# returns a new Camera which applies the given lens to every snapshot
```

See the [facets section](/py/facets) for more details on how you can use Selfie for snapshot testing with Java, Kotlin, or any JVM language.
See the [facets section](/py/facets) for more details and example code.

<NavHeading text="cacheable" popout="/py/cache" />

## NOT READY YET - WIP

Sometimes a test has a component which is slow, expensive, or non-deterministic. In cases like this, it can be useful to save the result of a previous execution of the API call, and use that as a mock for future tests.

```python