-
Notifications
You must be signed in to change notification settings - Fork 22
Expand file tree
/
Copy pathpytest-helpful.html
More file actions
321 lines (288 loc) · 12.2 KB
/
Copy pathpytest-helpful.html
File metadata and controls
321 lines (288 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
<html>
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<head>
<title>
leontrolski - helpful pytest
</title>
<style>
body {margin: 5% auto; background: #fff7f7; color: #444444; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.8; max-width: 63%;}
@media screen and (max-width: 800px) {body {font-size: 14px; line-height: 1.4; max-width: 90%;}}
pre {width: 100%; border-top: 3px solid gray; border-bottom: 3px solid gray;}
a {border-bottom: 1px solid #444444; color: #444444; text-decoration: none; text-shadow: 0 1px 0 #ffffff; }
a:hover {border-bottom: 0;}
.inline {background: #b3b2b226; padding-left: 0.3em; padding-right: 0.3em; white-space: nowrap;}
blockquote {font-style: italic;color:black;background-color:#f2f2f2;padding:2em;}
details {border-bottom:solid 5px gray;}
img{max-width:80rem;}
.overflow{overflow:scroll;max-width:100%;box-shadow:0 0 5px 0 gray;}
</style>
<link href="https://unpkg.com/prism-themes@1.9.0/themes/prism-darcula.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.20.0/components/prism-core.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.20.0/plugins/autoloader/prism-autoloader.min.js"></script>
</head>
<body>
<a href="index.html">
<img src="pic.png" style="height:2em">
⇦
</a>
<p><i>2024-02-11</i></p>
<h1 id="helpful-pytest-plugins">Helpful pytest plugins</h1>
<p>These are some helpful pytest plugins that I've collected over the years. They're here as code rather than as a
library because it's useful to be able to modify them for a particular domain/codebase. Some of the hacks are
pretty ugly as they utilise fairly deep implementation details.</p>
<ul>
<li><a href="#nicer-comparisons">Nicer comparisons</a></li>
<li><a href="#prevent-network-calls">Prevent network calls</a></li>
<li><a href="#prettier-pretty-print-with-postgres-sql-support">Prettier pretty print with Postgres SQL support</a></li>
<li><a href="#quickly-clean-postgres-tables">Quickly clean Postgres tables</a></li>
<li><a href="#typed-monkeypatch">Typed monkeypatch</a></li>
</ul>
<h2 id="setup">Setup</h2>
<details>
<summary>
Imports for the subsequent code, external dependencies are: <code>icdiff</code>, <code>sqlparse</code>,
<code>rich</code>, <code>sqlalchemy</code>.
</summary>
<pre class="language-python"><code>import contextlib
import datetime
import enum
import fcntl
import importlib
import io
import os
import pdb
import socket
import struct
import termios
from dataclasses import is_dataclass
from socket import socket as original_socket
from decimal import Decimal
from typing import Any, cast
from unittest import mock
from uuid import UUID
import icdiff
import pytest
import rich.console
import sqlparse
from sqlalchemy import create_engine, event, sql, text
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import Query, Session
from _pytest.monkeypatch import MonkeyPatch
</code></pre>
</details>
<p>We're going to make a file: <code>some/dir/plugin.py</code>, you can make all the fixtures/plugins available by
adding the following to a <code>conftest.py</code>:</p>
<pre class="language-python"><code>pytest_plugins = ["some.dir.plugin"]
</code></pre>
<p>From now, all the code will be assumed to be in <code>plugin.py</code>.</p>
<h2 id="nicer-comparisons">Nicer comparisons</h2>
<p>Pytest's comparisons often leave a lot to be desired. Adding <code>pytest_assertrepr_compare</code> overrides
pytest's compare function, if the function returns <code>None</code>, it reverts to the default comparison. This
is basically just a minimal version of <a href="https://github.com/hjwp/pytest-icdiff">pytest-icdiff</a>, it's
often handy to add domain specific pretty printers to aid debugging, so its useful in "raw" form.
</p>
<p>Example output:</p>
<div class="overflow">
<img src="images/nice-diff.png"/>
</div>
<p>Code:</p>
<pre class="language-python"><code>def get_terminal_width() -> int: # from: https://gist.github.com/jtriley/1108174
fd = os.open(os.ctermid(), os.O_RDONLY)
cr = struct.unpack("hh", fcntl.ioctl(fd, termios.TIOCGWINSZ, "1234")) # type: ignore
os.close(fd)
return int(cr[1])
try:
WIDTH = get_terminal_width() - 10
except Exception:
WIDTH = 80
def pytest_assertrepr_compare(config: Any, op: str, left: Any, right: Any) -> list[str] | None:
very_verbose = config.option.verbose >= 2
if not very_verbose:
return None
if op != "==":
return None
try:
if abs(left + right) < 100:
return None
except TypeError:
pass
replace_mocked_fields(left, right)
try:
if isinstance(left, str) and isinstance(right, str):
pretty_left = left.splitlines()
pretty_right = right.splitlines()
else:
pretty_left = rich_repr(left).splitlines()
pretty_right = rich_repr(right).splitlines()
differ = icdiff.ConsoleDiff(cols=WIDTH, tabsize=4)
icdiff_lines = differ.make_table(pretty_left, pretty_right, context=True, numlines=10)
return (
["equals failed"]
+ ["ACTUAL".center(WIDTH // 2 - 1) + "|" + "EXPECTED".center(WIDTH // 2)]
+ ["-" * WIDTH]
+ [icdiff.color_codes["none"] + line for line in icdiff_lines]
)
except Exception: # if it breaks at all, just do a normal diff
return None
# Helpers
def rich_repr(o: Any) -> str:
string_io = io.StringIO()
rich.console.Console(
file=string_io,
width=WIDTH // 2,
tab_size=4,
no_color=True,
highlight=False,
log_time=False,
log_path=False,
).print(o)
string_io.seek(0)
return string_io.getvalue()
def replace_mocked_fields(left: Any, right: Any) -> None:
keys: set[str] | range
if is_dataclass(left) and is_dataclass(right):
left = left.__dict__
right = right.__dict__
keys = left.keys() & right.keys()
elif isinstance(left, list) and isinstance(right, list):
keys = range(min(len(left), len(right)))
else:
return
for key in keys:
if right[key] is mock.ANY:
left[key] = mock.ANY
else:
replace_mocked_fields(left[key], right[key])
</code></pre>
<h2 id="prevent-network-calls">Prevent network calls</h2>
<p>All your tests should run on the train, right? Adding the following will prevent network calls by default and allow
them <em>only</em> when provided with the <code>allow_network_calls</code> fixture.</p>
<pre class="language-python"><code>@pytest.fixture(scope="class", autouse=True)
def stop_network_calls(_get_event_loop):
mpatch = MonkeyPatch()
def _socket(*_, **__):
raise Exception("stop making network calls!")
yield mpatch
mpatch.undo()
@pytest.fixture
def allow_network_calls(monkeypatch):
monkeypatch.setattr(socket, "socket", original_socket)
</code></pre>
<h2 id="prettier-pretty-print-with-postgres-sql-support">Prettier pretty print with Postgres SQL support</h2>
<p>This adds a new function to the debugger - <code>ppp</code> - that can pretty print Postgres SQLAlchemy queries. The
use case is: slap an <code>assert False</code> after constructing a SQLAlchemy query in the application code, run
the tests with <code>--pdb</code>, then <code>ppp my_query</code>, copy paste into <code>psql</code>.</p>
<pre class="language-python"><code>class LiteralCompiler(postgresql.psycopg2.PGCompiler_psycopg2):
# see also https://stackoverflow.com/a/9898141/4865874
def visit_bindparam(
self,
bindparam,
within_columns_clause=False,
literal_binds=False,
**kwargs,
):
return super(LiteralCompiler, self).render_literal_bindparam(
bindparam,
within_columns_clause=within_columns_clause,
literal_binds=literal_binds,
**kwargs,
)
def render_literal_value(self, value, type_):
if isinstance(value, str):
value = value.replace("'", "''")
return f"'{value}'"
elif isinstance(value, UUID):
return f"'{value}'"
elif value is None:
return "NULL"
elif isinstance(value, (float, int, Decimal)):
return repr(value)
elif isinstance(value, datetime.datetime):
return f"'{value.isoformat()}'"
elif isinstance(value, enum.Enum):
return f"'{value.value}'"
else:
raise NotImplementedError(
f"Don't know how to literal-quote value {value}"
)
def pp_sql(qry: sql.expression.ClauseElement | Query) -> str:
if isinstance(qry, Query):
qry = qry.statement
qry = cast(sql.expression.ClauseElement, qry)
compiler = LiteralCompiler(postgresql.psycopg2.dialect(), qry)
raw_sql = compiler.process(qry)
indented = sqlparse.format(
raw_sql,
reindent=True,
keyword_case="upper",
indent_width=4,
indent_tabs=False,
wrap_after=20,
)
return f"<Raw SQL query:>\n{indented}"
def ppp(self: Any, arg: Any) -> str | None:
# try get the value from the current scope
try:
obj = self._getval(arg)
except Exception:
return None
# if it looks like a SQL query, try format it
if isinstance(obj, (sql.expression.ClauseElement, Query)):
try:
return pp_sql(obj)
except Exception:
pass
# else try nicely pprint it
try:
return rich_repr(obj)
except Exception as e:
return f">>> Failed to pretty print {obj} with exception {e}"
# call this at the end of plugin.py
def _set_up_prettyprinter() -> None:
# adds `ppp` to the debugger
pdb.Pdb.do_ppp = lambda self, arg: print(ppp(self, arg)) # type: ignore
</code></pre>
<h2 id="quickly-clean-postgres-tables">Quickly clean Postgres tables</h2>
<p>By convention, tests will often <code>DROP</code> and <code>CREATE</code> tables between tests. Normally, it should be
sufficient and faster to just delete the data (in the correct table order) and reset all the sequences. See <a href="https://github.com/leontrolski/pgtestdbpy">also</a>.</p>
<pre class="language-python"><code>def clean_tables(session, sqlalchemy_base):
tables = [t for t in reversed(sqlalchemy_base.metadata.sorted_tables)]
for table in tables:
session.execute(table.delete())
# reset all the sequences
sql = (
"SELECT sequencename FROM pg_sequences "
"WHERE schemaname IN (SELECT current_schema())"
)
for [sequence] in session.execute(text(sql)):
session.execute(text(f"ALTER SEQUENCE {sequence} RESTART WITH 1"))
session.commit()
</code></pre>
<h2 id="typed-monkeypatch">Typed monkeypatch</h2>
<p>This is a bit of a bodge under the hood, should really use ast rather than regex...</p>
<pre class="language-python"><code>T = TypeVar("T")
@dataclass
class _MonkeyPatchSetAttr(Generic[T]):
monkeypatch: Any
module: Any
attr: str
def to(self, to: T) -> None:
self.monkeypatch.setattr(self.module, self.attr, to)
@dataclass
class MonkeyPatch:
monkeypatch: Any
def __call__(self, from_: T) -> _MonkeyPatchSetAttr[T]:
call_site = inspect.stack()[1]
assert call_site.code_context is not None
code: str = call_site.code_context[0]
match = re.match(r".+patch\(([\w+.]+)\)", code)
assert match is not None
module_name, _, attr = match.groups()[0].rpartition(".")
module = eval(module_name, call_site.frame.f_globals, call_site.frame.f_locals)
return _MonkeyPatchSetAttr(self.monkeypatch, module, attr)
@pytest.fixture
def patch(monkeypatch: Any) -> Iterator[MonkeyPatch]:
yield MonkeyPatch(monkeypatch)
</code></pre>
</body>
</html>