Skip to content

Commit 2a5f269

Browse files
Add CLI command to update snapshots and refactor directory configuration (#3)
Introduce a new command for updating snapshots without rerunning tests and refactor the handling of directory names for improved clarity and consistency. Minor linting adjustments included.
2 parents 6b5476c + 1d3def3 commit 2a5f269

File tree

10 files changed

+185
-60
lines changed

10 files changed

+185
-60
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ All notable changes to this project will be documented in this file.
33

44
*NOTE:* Version 0.X.X might have breaking changes in bumps of the minor version number. This is because the project is still in early development and the API is not yet stable. It will still be marked clearly in the release notes.
55

6+
## [unreleased]
7+
- 🆕 Add new cli command for easy update of snapshots without the need to rerun tests
8+
69
## [0.4.0]
710
- 🆕 Add support for pandas DataFrame assertions (pandas is an optional dependency, and snappylapy can be used without it)
811
- 🆕 Add new simpler and more intuitive Expect API. We can use expect() calls and an overload will select the appropriate Expect class based on the type of the input. This is in contrast with expect.dict(), expect.list(), etc. This is not a breaking change, since the old API is still supported.

scripts/create_new_release.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def read_version():
1414
"""Read the version from the pyproject.toml file."""
1515
with open(PYPROJECT_TOML, "r", encoding="utf-8") as file:
1616
data = toml.load(file)
17-
return data["tool"]["poetry"]["version"]
17+
return data["project"]["version"]
1818

1919
def check_version(version: str):
2020
"""Check if the version is newer than the one in pypi."""

snappylapy/_cli.py

Lines changed: 85 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
"""Create cli using the typer library."""
2+
23
import re
34
import typer
45
import pathlib
5-
from snappylapy.constants import directory_names
6+
from enum import Enum
7+
from snappylapy._utils_directories import DirectoryNamesUtil
8+
from snappylapy.constants import DIRECTORY_NAMES
69

710
app = typer.Typer(
811
no_args_is_help=True,
912
help="""
10-
The CLI provides commands to initialize the repo and to clear test results and snapshots.
11-
In the future the future the CLI will be expanded with review and update commands.
13+
The CLI provides commands to initialize the repo and to update or clear test results and snapshots.
14+
In the future the future the CLI will be expanded with review.
1215
""",
1316
)
1417

@@ -24,39 +27,48 @@ def init() -> None:
2427
# Check if already in .gitignore
2528
with gitignore_path.open("r") as file:
2629
lines = file.readlines()
27-
regex = re.compile(
28-
rf"^{re.escape(directory_names.test_results_dir_name)}(/|$)")
30+
regex = re.compile(rf"^{re.escape(DIRECTORY_NAMES.test_results_dir_name)}(/|$)")
2931
if any(regex.match(line) for line in lines):
3032
typer.echo("Already in .gitignore.")
3133
return
3234
# Add to .gitignore to top of file
33-
line_to_add = f"# Ignore test results from snappylapy\n{directory_names.test_results_dir_name}/\n\n"
35+
line_to_add = f"# Ignore test results from snappylapy\n{DIRECTORY_NAMES.test_results_dir_name}/\n\n"
3436
with gitignore_path.open("w") as file:
3537
file.write(line_to_add)
3638
file.writelines(lines)
37-
typer.echo(
38-
f"Added {directory_names.test_results_dir_name}/ to .gitignore.")
39+
typer.echo(f"Added {DIRECTORY_NAMES.test_results_dir_name}/ to .gitignore.")
3940

4041

4142
@app.command()
42-
def clear(force: bool = typer.Option(
43-
False,
44-
"--force",
45-
"-f",
46-
help="Force deletion without confirmation",
47-
)) -> None:
43+
def clear(
44+
force: bool = typer.Option(
45+
False,
46+
"--force",
47+
"-f",
48+
help="Force deletion without confirmation",
49+
),
50+
) -> None:
4851
"""Clear all test results and snapshots, recursively, using pathlib."""
49-
list_of_files_to_delete = get_files_to_delete()
52+
directories_to_delete = DirectoryNamesUtil().get_all_directories_created_by_snappylapy()
53+
list_of_files_to_delete = DirectoryNamesUtil().get_all_file_paths_created_by_snappylapy()
5054
if not list_of_files_to_delete:
5155
typer.echo("No files to delete.")
5256
return
5357
if not force:
54-
# Ask for confirmation
58+
typer.echo("Deleting files:")
59+
for file in list_of_files_to_delete:
60+
typer.echo(f"- {file}")
61+
5562
typer.secho(
56-
"\nAre you sure you want to delete all test results and snapshots?",
57-
fg=typer.colors.BRIGHT_BLUE)
58-
response = typer.prompt(
59-
"Type 'yes' to confirm, anything else to abort.")
63+
f"Deleting {len(list_of_files_to_delete)} files from {len(directories_to_delete)} directories:",
64+
fg=typer.colors.BRIGHT_BLUE,
65+
)
66+
for directory in directories_to_delete:
67+
typer.echo(f"- {directory}")
68+
69+
# Ask for confirmation
70+
typer.secho("\nAre you sure you want to delete all test results and snapshots?", fg=typer.colors.BRIGHT_BLUE)
71+
response = typer.prompt("Type 'yes' to confirm, anything else to abort.", default="no")
6072
if response.lower() != "yes":
6173
typer.echo("Aborted.")
6274
return
@@ -65,19 +77,29 @@ def clear(force: bool = typer.Option(
6577
typer.echo(f"Deleted {len(list_of_files_to_delete)} files.")
6678

6779

68-
def get_files_to_delete() -> list[pathlib.Path]:
69-
"""Get list of files to delete."""
70-
list_of_files_to_delete: list[pathlib.Path] = []
71-
for dir_name in [
72-
directory_names.test_results_dir_name,
73-
directory_names.snapshot_dir_name,
74-
]:
75-
for root_dir in pathlib.Path().rglob(dir_name):
76-
for file in root_dir.iterdir():
77-
if file.is_file():
78-
list_of_files_to_delete.append(file)
79-
typer.echo(f"Found file to delete: {file}")
80-
return list_of_files_to_delete
80+
@app.command()
81+
def update() -> None:
82+
"""Update the snapshot files by copying the test results, to the snapshot directory."""
83+
files_test_results = DirectoryNamesUtil().get_all_file_paths_test_results()
84+
if not files_test_results:
85+
typer.echo("No files to update.")
86+
return
87+
file_statuses = check_file_statuses(files_test_results)
88+
files_to_update = [file for file, status in file_statuses.items() if status != FileStatus.UNCHANGED]
89+
count_up_to_date_files = len(files_test_results) - len(files_to_update)
90+
if not files_to_update:
91+
typer.echo(f"All snapshot files are up to date. {count_up_to_date_files} files are up to date.")
92+
return
93+
94+
typer.echo(
95+
f"Found {len(files_to_update)} files to update."
96+
+ (f" {count_up_to_date_files} files are up to date." if count_up_to_date_files > 0 else ""),
97+
)
98+
for file in files_to_update:
99+
snapshot_file = file.parent.parent / DIRECTORY_NAMES.snapshot_dir_name / file.name
100+
snapshot_file.parent.mkdir(parents=True, exist_ok=True)
101+
snapshot_file.write_bytes(file.read_bytes())
102+
typer.echo(f"Updated snapshot: {snapshot_file}")
81103

82104

83105
def delete_files(list_of_files_to_delete: list[pathlib.Path]) -> None:
@@ -87,12 +109,40 @@ def delete_files(list_of_files_to_delete: list[pathlib.Path]) -> None:
87109
file.unlink()
88110
# Delete directories
89111
for dir_name in [
90-
directory_names.test_results_dir_name,
91-
directory_names.snapshot_dir_name,
112+
DIRECTORY_NAMES.test_results_dir_name,
113+
DIRECTORY_NAMES.snapshot_dir_name,
92114
]:
93115
for root_dir in pathlib.Path().rglob(dir_name):
94116
root_dir.rmdir()
95117

96118

119+
class FileStatus(Enum):
120+
"""Enum to represent the status of a file."""
121+
122+
NOT_FOUND = "not_found"
123+
CHANGED = "changed"
124+
UNCHANGED = "unchanged"
125+
126+
127+
def check_file_statuses(
128+
file_paths: list[pathlib.Path],
129+
) -> dict[pathlib.Path, FileStatus]:
130+
"""Check the status of files in the snapshot directory."""
131+
file_statuses: dict[pathlib.Path, FileStatus] = {}
132+
for file_path in file_paths:
133+
snapshot_file = file_path.parent.parent / DIRECTORY_NAMES.snapshot_dir_name / file_path.name
134+
if not snapshot_file.exists():
135+
file_statuses[file_path] = FileStatus.NOT_FOUND
136+
elif snapshot_file.stat().st_size != file_path.stat().st_size:
137+
# TODO: This is not foolproof, does not catch content swaps and byte flips.
138+
file_statuses[file_path] = FileStatus.CHANGED
139+
elif snapshot_file.read_bytes() != file_path.read_bytes():
140+
# TODO: Expensive call, store hashes instead in a data file.
141+
file_statuses[file_path] = FileStatus.CHANGED
142+
else:
143+
file_statuses[file_path] = FileStatus.UNCHANGED
144+
return file_statuses
145+
146+
97147
if __name__ == "__main__":
98148
app()

snappylapy/_plugin.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
import _pytest.mark
1010
from collections.abc import Callable
1111
from snappylapy import Expect, LoadSnapshot
12-
from snappylapy.constants import DEFEAULT_SNAPSHOT_BASE_DIR
12+
from snappylapy._utils_directories import DirectoryNamesUtil
13+
from snappylapy.constants import DEFAULT_SNAPSHOT_BASE_DIR
1314
from snappylapy.exceptions import TestDirectoryNotParametrizedError
1415
from snappylapy.fixtures import Settings
1516
from snappylapy.session import SnapshotSession
@@ -80,7 +81,7 @@ def snappylapy_settings(request: pytest.FixtureRequest) -> Settings:
8081
path_output_dir = pathlib.Path(input_dir_from_depends)
8182
settings.depending_test_filename = depends[0].__module__
8283
settings.depending_test_function = depends[0].__name__
83-
settings.depending_snapshots_base_dir = path_output_dir or DEFEAULT_SNAPSHOT_BASE_DIR
84+
settings.depending_snapshots_base_dir = path_output_dir or DEFAULT_SNAPSHOT_BASE_DIR
8485
return settings
8586

8687

@@ -149,6 +150,9 @@ def pytest_addoption(parser: pytest.Parser) -> None:
149150
def pytest_sessionstart(session: pytest.Session) -> None:
150151
"""Initialize the snapshot session."""
151152
session.config.snappylapy_session = SnapshotSession() # type: ignore[attr-defined]
153+
list_of_files_to_delete = DirectoryNamesUtil().get_all_file_paths_test_results()
154+
for file in list_of_files_to_delete:
155+
file.unlink()
152156

153157

154158
class ExceptionDuringTestSetupError(Exception):

snappylapy/_utils_directories.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Utility functions for handling directories in snappylapy."""
2+
import pathlib
3+
from snappylapy.constants import DIRECTORY_NAMES
4+
5+
6+
def find_directories(directory_names: list[str]) -> list[pathlib.Path]:
7+
"""Find directories with the given names."""
8+
found_dirs: list[pathlib.Path] = []
9+
for dir_name in directory_names:
10+
found_dirs.extend(pathlib.Path().rglob(dir_name))
11+
return found_dirs
12+
13+
14+
def get_file_paths_from_directories(list_of_directories: list[pathlib.Path]) -> list[pathlib.Path]:
15+
"""Get file paths from directories."""
16+
list_of_files_to_delete: list[pathlib.Path] = []
17+
for directory in list_of_directories:
18+
if not directory.is_dir():
19+
error_msg = f"{directory} is not a directory."
20+
raise ValueError(error_msg)
21+
list_of_files_to_delete.extend(file for file in directory.iterdir() if file.is_file())
22+
return list_of_files_to_delete
23+
24+
25+
class DirectoryNamesUtil:
26+
"""
27+
Utility class to handle directory names and operations related to them.
28+
29+
This class extends the DirectoryNames class to provide methods for finding directories
30+
and file paths created by snappylapy.
31+
"""
32+
33+
def get_all_directory_names(self) -> list[str]:
34+
"""Get all directory names."""
35+
return [DIRECTORY_NAMES.snapshot_dir_name, DIRECTORY_NAMES.test_results_dir_name]
36+
37+
def get_all_directories_for_test_results(self) -> list[pathlib.Path]:
38+
"""Get all directories for test results."""
39+
return find_directories([DIRECTORY_NAMES.test_results_dir_name])
40+
41+
def get_all_directories_for_snapshots(self) -> list[pathlib.Path]:
42+
"""Get all directories for snapshots."""
43+
return find_directories([DIRECTORY_NAMES.snapshot_dir_name])
44+
45+
def get_all_file_paths_test_results(self) -> list[pathlib.Path]:
46+
"""Get all file paths in the test results directory."""
47+
directories = find_directories([DIRECTORY_NAMES.test_results_dir_name])
48+
return get_file_paths_from_directories(directories)
49+
50+
def get_all_file_paths_snapshots(self) -> list[pathlib.Path]:
51+
"""Get all file paths in the snapshot directory."""
52+
directories = find_directories([DIRECTORY_NAMES.snapshot_dir_name])
53+
return get_file_paths_from_directories(directories)
54+
55+
def get_all_directories_created_by_snappylapy(self) -> list[pathlib.Path]:
56+
"""Get all directories created by snappylapy."""
57+
return find_directories(self.get_all_directory_names())
58+
59+
def get_all_file_paths_created_by_snappylapy(self) -> list[pathlib.Path]:
60+
"""Get all file paths created by snappylapy."""
61+
return get_file_paths_from_directories(
62+
self.get_all_directories_created_by_snappylapy(),
63+
)

snappylapy/constants.py

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,19 @@
11
"""Read-only constants."""
22

33
import pathlib
4+
from typing import NamedTuple
45

5-
DEFEAULT_SNAPSHOT_BASE_DIR = pathlib.Path()
6+
DEFAULT_SNAPSHOT_BASE_DIR = pathlib.Path()
67
OUTPUT_JSON_INDENTATION_LEVEL = 2
78

89

9-
class DirectoryNames:
10-
"""Class to enforce immutable directory names, since there is side effect if they are changed."""
10+
class DirectoryNames(NamedTuple):
11+
"""Immutable directory names for snappylapy."""
1112

12-
@property
13-
def snapshot_dir_name(self) -> str:
14-
"""Snapshot directory name."""
15-
return "__snapshots__"
13+
snapshot_dir_name: str
14+
test_results_dir_name: str
1615

17-
@property
18-
def test_results_dir_name(self) -> str:
19-
"""Test results directory name."""
20-
return "__test_results__"
2116

22-
23-
directory_names = DirectoryNames()
17+
DIRECTORY_NAMES: DirectoryNames = DirectoryNames(
18+
snapshot_dir_name="__snapshots__", test_results_dir_name="__test_results__",
19+
)

snappylapy/fixtures.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
JsonPickleSerializer,
2525
StringSerializer,
2626
)
27-
from snappylapy.constants import directory_names
27+
from snappylapy.constants import DIRECTORY_NAMES
2828
from snappylapy.session import SnapshotSession
2929
from typing import Any, Protocol, overload
3030

@@ -71,6 +71,7 @@ def __init__(
7171

7272
self.dict = DictExpect(self.settings, snappylapy_session)
7373
"""DictExpect instance for configuring snapshot testing of dictionaries.
74+
7475
The instance is callable with the following parameters:
7576
7677
Parameters
@@ -97,6 +98,7 @@ def __init__(
9798

9899
self.list = ListExpect(self.settings, snappylapy_session)
99100
"""ListExpect instance for configuring snapshot testing of lists.
101+
100102
The instance is callable with the following parameters:
101103
102104
Parameters
@@ -122,6 +124,7 @@ def __init__(
122124

123125
self.string = StringExpect(self.settings, snappylapy_session)
124126
"""StringExpect instance for configuring snapshot testing of strings.
127+
125128
The instance is callable with the following parameters:
126129
127130
Parameters
@@ -147,6 +150,7 @@ def __init__(
147150

148151
self.bytes = BytesExpect(self.settings, snappylapy_session)
149152
"""BytesExpect instance for configuring snapshot testing of bytes.
153+
150154
The instance is callable with the following parameters:
151155
152156
Parameters
@@ -172,7 +176,9 @@ def __init__(
172176

173177
self.dataframe = DataframeExpect(self.settings, snappylapy_session)
174178
"""DataframeExpect instance for configuring snapshot testing of dataframes.
179+
175180
The instance is callable with the following parameters:
181+
176182
Parameters
177183
----------
178184
data_to_snapshot : pd.DataFrame
@@ -278,7 +284,7 @@ def _read_snapshot(self) -> bytes:
278284
raise ValueError(msg)
279285
return (
280286
self.settings.depending_snapshots_base_dir
281-
/ directory_names.snapshot_dir_name
287+
/ DIRECTORY_NAMES.snapshot_dir_name
282288
/ self.settings.depending_filename
283289
).read_bytes()
284290

0 commit comments

Comments
 (0)