nti.testing 4.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nti/__init__.py +0 -0
- nti/testing/__init__.py +107 -0
- nti/testing/base.py +574 -0
- nti/testing/layers/__init__.py +53 -0
- nti/testing/layers/cleanup.py +106 -0
- nti/testing/layers/postgres.py +693 -0
- nti/testing/layers/tests/__init__.py +0 -0
- nti/testing/layers/tests/test_postgres.py +17 -0
- nti/testing/layers/zope.py +122 -0
- nti/testing/matchers.py +370 -0
- nti/testing/mock.py +22 -0
- nti/testing/tests/__init__.py +0 -0
- nti/testing/tests/test_base.py +160 -0
- nti/testing/tests/test_component_cleanup_broken.txt +137 -0
- nti/testing/tests/test_layers.py +56 -0
- nti/testing/tests/test_main.py +47 -0
- nti/testing/tests/test_matchers.py +237 -0
- nti/testing/tests/test_time.py +157 -0
- nti/testing/tests/test_zodb.py +326 -0
- nti/testing/time.py +179 -0
- nti/testing/zodb.py +245 -0
- nti.testing-4.2.0.dist-info/LICENSE +11 -0
- nti.testing-4.2.0.dist-info/METADATA +423 -0
- nti.testing-4.2.0.dist-info/RECORD +27 -0
- nti.testing-4.2.0.dist-info/WHEEL +5 -0
- nti.testing-4.2.0.dist-info/top_level.txt +1 -0
- nti.testing-4.2.0.dist-info/zip-safe +1 -0
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Support for using :mod:`testgres` to create and use a Postgres
|
|
4
|
+
instance as a layer.
|
|
5
|
+
|
|
6
|
+
There is also support for benchmarking and saving databeses for
|
|
7
|
+
later examination.
|
|
8
|
+
|
|
9
|
+
.. versionadded:: 4.0.0
|
|
10
|
+
|
|
11
|
+
The APIs are preliminary and may change.
|
|
12
|
+
|
|
13
|
+
This is only supported on platforms that can install ``psycopg2``.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
import functools
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import unittest
|
|
21
|
+
from unittest.mock import patch
|
|
22
|
+
|
|
23
|
+
#import psycopg2
|
|
24
|
+
#import psycopg2.extras
|
|
25
|
+
#import psycopg2.pool
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from psycopg2 import ProgrammingError
|
|
29
|
+
except ImportError:
|
|
30
|
+
ThreadedConnectionPool = None
|
|
31
|
+
DictCursor = None
|
|
32
|
+
class IntegrityError(Exception):
|
|
33
|
+
"""Never thrown"""
|
|
34
|
+
ProgrammingError = InternalError = IntegrityError
|
|
35
|
+
else:
|
|
36
|
+
from psycopg2.pool import ThreadedConnectionPool
|
|
37
|
+
from psycopg2.extras import DictCursor
|
|
38
|
+
from psycopg2 import IntegrityError
|
|
39
|
+
from psycopg2 import InternalError
|
|
40
|
+
|
|
41
|
+
import testgres
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if 'PG_CONFIG' not in os.environ:
|
|
45
|
+
# Set up for macports and fedora, using files that exist.
|
|
46
|
+
# Otherwise, don't set it, assume things are on the path.
|
|
47
|
+
for option in (
|
|
48
|
+
'/opt/local/lib/postgresql11/bin/pg_config',
|
|
49
|
+
'/usr/pgsql-11/bin/pg_config',
|
|
50
|
+
):
|
|
51
|
+
if os.path.isfile(option):
|
|
52
|
+
# TODO: Check exec bit
|
|
53
|
+
os.environ['PG_CONFIG'] = option
|
|
54
|
+
break
|
|
55
|
+
|
|
56
|
+
# If True, save the database to a pg_dump
|
|
57
|
+
# file on teardown. The file name will be printed.''
|
|
58
|
+
SAVE_DATABASE_ON_TEARDOWN = False
|
|
59
|
+
SAVE_DATABASE_FILENAME = None
|
|
60
|
+
|
|
61
|
+
# If the path to a database dump file that exists, the database
|
|
62
|
+
# will be restored from this file on setUp.
|
|
63
|
+
LOAD_DATABASE_ON_SETUP = None
|
|
64
|
+
|
|
65
|
+
if 'NTI_SAVE_DB' in os.environ:
|
|
66
|
+
# NTI_SAVE_DB is either 1/on/true (case-insensitive)
|
|
67
|
+
# or a file name.
|
|
68
|
+
val = os.environ['NTI_SAVE_DB']
|
|
69
|
+
if val.lower() in {'0', 'off', 'false', 'no'}:
|
|
70
|
+
SAVE_DATABASE_ON_TEARDOWN = False
|
|
71
|
+
else:
|
|
72
|
+
SAVE_DATABASE_ON_TEARDOWN = True
|
|
73
|
+
if val.lower() not in {'1', 'on', 'true', 'yes'}:
|
|
74
|
+
SAVE_DATABASE_FILENAME = val
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if 'NTI_LOAD_DB_FILE' in os.environ:
|
|
78
|
+
LOAD_DATABASE_ON_SETUP = os.environ['NTI_LOAD_DB_FILE']
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def patched_get_pg_version(*args, **kwargs):
|
|
82
|
+
# We patch this in testgres.node, so its ok to import
|
|
83
|
+
# the original. In version 1.10, they changed the signature
|
|
84
|
+
# of this function, so be sure to accept whatever it does and
|
|
85
|
+
# pass it on.
|
|
86
|
+
from testgres.utils import get_pg_version
|
|
87
|
+
from testgres.node import PgVer
|
|
88
|
+
from packaging.version import InvalidVersion
|
|
89
|
+
|
|
90
|
+
# Some installs of postgres return
|
|
91
|
+
# strings that the version parser doesn't like;
|
|
92
|
+
# notably the Python images get a debian build that says
|
|
93
|
+
# "15.3-0+deb12u1" which get_pg_version() chops down to
|
|
94
|
+
# "15.3-0+". If it can't be parsed, then return a fake.
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
version = get_pg_version(*args, **kwargs)
|
|
98
|
+
PgVer(version)
|
|
99
|
+
except InvalidVersion:
|
|
100
|
+
print('testgres: Got invalid postgres version', version)
|
|
101
|
+
# The actual version string looks like "postgres (PostgreSQL) 15.4",
|
|
102
|
+
# and get_pg_version() processes that down to this
|
|
103
|
+
version = "15.4"
|
|
104
|
+
print('testgres: Substituting version', version)
|
|
105
|
+
|
|
106
|
+
return version
|
|
107
|
+
|
|
108
|
+
class DatabaseLayer(object):
|
|
109
|
+
"""
|
|
110
|
+
A test layer that creates the database, and sets each
|
|
111
|
+
test up in its own connection, aborting the transaction when
|
|
112
|
+
done.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
#: The name of the database within the node. We only create
|
|
116
|
+
#: the default databases, so this should be 'postgres'
|
|
117
|
+
DATABASE_NAME = 'postgres'
|
|
118
|
+
|
|
119
|
+
#: A `testgres.node.PostgresNode`, created for the layer.
|
|
120
|
+
#: A psycopg2 connection to it is located in the :attr:`connection`
|
|
121
|
+
#: attribute (similarly for :attr:`connection_pool`), while
|
|
122
|
+
#: a DSN connection string is in :attr:`postgres_dsn`
|
|
123
|
+
postgres_node = None
|
|
124
|
+
|
|
125
|
+
#: A string you can use to connect to Postgres.
|
|
126
|
+
postgres_dsn = None
|
|
127
|
+
|
|
128
|
+
#: A string you can pass to SQLAlchemy
|
|
129
|
+
postgres_uri = None
|
|
130
|
+
|
|
131
|
+
#: Set for each test.
|
|
132
|
+
connection = None
|
|
133
|
+
|
|
134
|
+
#: Set for each test.
|
|
135
|
+
cursor = None
|
|
136
|
+
|
|
137
|
+
connection_pool = None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
connection_pool_klass = ThreadedConnectionPool
|
|
141
|
+
connection_pool_minconn = 1
|
|
142
|
+
connection_pool_maxconn = 51
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def setUp(cls):
|
|
146
|
+
testgres.configure_testgres()
|
|
147
|
+
|
|
148
|
+
with patch('testgres.node.get_pg_version', new=patched_get_pg_version):
|
|
149
|
+
node = cls.postgres_node = testgres.get_new_node()
|
|
150
|
+
|
|
151
|
+
# init takes about about 2 -- 3 seconds
|
|
152
|
+
node.init(
|
|
153
|
+
# Use the encoding as UTF-8. Set the locale as POSIX
|
|
154
|
+
# instead of inheriting it (in JAM's environment, the locale
|
|
155
|
+
# and thus collation and ctype is en_US.UTF8; this turns out to be
|
|
156
|
+
# up to 40% slower than POSIX).
|
|
157
|
+
# We could explicitly specify 'en-x-icu' on each column, if we required
|
|
158
|
+
# ICU support, but it cannot be used as a default collation.
|
|
159
|
+
initdb_params=[
|
|
160
|
+
"-E", "UTF8",
|
|
161
|
+
'--locale', 'POSIX',
|
|
162
|
+
# Don't force to disk; this may save some minor init time.
|
|
163
|
+
'--no-sync',
|
|
164
|
+
],
|
|
165
|
+
log_statement='none',
|
|
166
|
+
# Disable unix sockets. Some platforms might try to put this
|
|
167
|
+
# in a directory we can't write to
|
|
168
|
+
unix_sockets=False
|
|
169
|
+
)
|
|
170
|
+
# Speed up bulk inserts
|
|
171
|
+
# These settings appeared to make no difference for the
|
|
172
|
+
# 2 million security insert or the 500K security mapping insert;
|
|
173
|
+
# likely because the final table sizes are < 300MB, so the default max size of
|
|
174
|
+
# 1GB is more than enough.
|
|
175
|
+
node.append_conf('fsync = off')
|
|
176
|
+
node.append_conf('full_page_writes = off')
|
|
177
|
+
node.append_conf('min_wal_size = 500MB')
|
|
178
|
+
node.append_conf('max_wal_size = 2GB')
|
|
179
|
+
# 'replica' is the default. If we use 'minimal' we could be
|
|
180
|
+
# a bit faster, but that's not exactly realistic. Plus,
|
|
181
|
+
# using 'minimal' disables the WAL backup functionality.
|
|
182
|
+
# If we set to 'minimal', we must also set 'max_wal_senders' to 0.
|
|
183
|
+
node.append_conf('wal_level = replica')
|
|
184
|
+
node.append_conf('wal_compression = on')
|
|
185
|
+
node.append_conf('wal_writer_delay = 10000ms')
|
|
186
|
+
node.append_conf('wal_writer_flush_after = 10MB')
|
|
187
|
+
|
|
188
|
+
node.append_conf('temp_buffers = 500MB')
|
|
189
|
+
node.append_conf('work_mem = 500MB')
|
|
190
|
+
node.append_conf('maintenance_work_mem = 500MB')
|
|
191
|
+
node.append_conf('shared_buffers = 500MB')
|
|
192
|
+
|
|
193
|
+
node.append_conf('max_connections = 100')
|
|
194
|
+
|
|
195
|
+
# auto-explain for slow queries
|
|
196
|
+
if 'benchmark' in ' '.join(sys.argv):
|
|
197
|
+
print("Enabling BENCHMARK SETTINGS")
|
|
198
|
+
node.append_conf('shared_preload_libraries = auto_explain')
|
|
199
|
+
node.append_conf('auto_explain.log_min_duration = 40ms')
|
|
200
|
+
node.append_conf('auto_explain.log_nested_statements = on')
|
|
201
|
+
node.append_conf('auto_explain.log_analyze = on')
|
|
202
|
+
node.append_conf('auto_explain.log_timing = on')
|
|
203
|
+
node.append_conf('auto_explain.log_triggers = on')
|
|
204
|
+
|
|
205
|
+
# PG 11 only, when --with-llvm was used to compile.
|
|
206
|
+
# It seems if it can't be used, it's ignored? It errors on 10 though,
|
|
207
|
+
# but we only support 11
|
|
208
|
+
node.append_conf('jit = on')
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
node.start()
|
|
212
|
+
cls.connection_pool = cls.connection_pool_klass(
|
|
213
|
+
cls.connection_pool_minconn,
|
|
214
|
+
cls.connection_pool_maxconn,
|
|
215
|
+
dbname=cls.DATABASE_NAME,
|
|
216
|
+
host='localhost',
|
|
217
|
+
port=cls.postgres_node.port,
|
|
218
|
+
cursor_factory=DictCursor,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
cls.postgres_dsn = "host=%s dbname=%s port=%s" % (
|
|
222
|
+
node.host, cls.DATABASE_NAME, node.port
|
|
223
|
+
)
|
|
224
|
+
cls.postgres_uri = "postgresql://%s:%s/%s" % (
|
|
225
|
+
cls.postgres_node.host,
|
|
226
|
+
cls.postgres_node.port,
|
|
227
|
+
cls.DATABASE_NAME
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
with cls.borrowed_connection() as conn:
|
|
231
|
+
with conn.cursor() as cur:
|
|
232
|
+
i = cls.__get_db_info(cur)
|
|
233
|
+
print(f"({i['version']} {i['current_database']}/{i['current_schema']} "
|
|
234
|
+
f"{i['Encoding']}-{i['Collate']}) ", end="")
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def tearDown(cls):
|
|
238
|
+
cls.connection_pool.closeall()
|
|
239
|
+
cls.connection_pool = None
|
|
240
|
+
|
|
241
|
+
cls.postgres_node.__exit__(None, None, None)
|
|
242
|
+
cls.postgres_node = None
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def testSetUp(cls):
|
|
246
|
+
# XXX: Errors here cause the tearDown method to not get called.
|
|
247
|
+
cls.connection = cls.connection_pool.getconn()
|
|
248
|
+
cls.cursor = cls.connection.cursor()
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def testTearDown(cls):
|
|
252
|
+
cls.connection.rollback() # Make sure we're able to execute
|
|
253
|
+
cls.cursor.execute('UNLISTEN *')
|
|
254
|
+
cls.cursor.close()
|
|
255
|
+
cls.cursor = None
|
|
256
|
+
cls.connection_pool.putconn(cls.connection)
|
|
257
|
+
cls.connection = None
|
|
258
|
+
|
|
259
|
+
@classmethod
|
|
260
|
+
def __get_db_info(cls, cur):
|
|
261
|
+
query = """
|
|
262
|
+
SELECT version() as version,
|
|
263
|
+
d.datname as "Name",
|
|
264
|
+
pg_catalog.pg_get_userbyid(d.datdba) as "Owner",
|
|
265
|
+
pg_catalog.pg_encoding_to_char(d.encoding) as "Encoding",
|
|
266
|
+
d.datcollate as "Collate",
|
|
267
|
+
d.datctype as "Ctype",
|
|
268
|
+
pg_catalog.array_to_string(d.datacl, E'\n') AS "Access privileges",
|
|
269
|
+
current_database() as "current_database",
|
|
270
|
+
current_schema() as "current_schema"
|
|
271
|
+
FROM pg_catalog.pg_database d
|
|
272
|
+
WHERE d.datname = %s
|
|
273
|
+
"""
|
|
274
|
+
cur.execute(query, (cls.DATABASE_NAME,))
|
|
275
|
+
row = cur.fetchone()
|
|
276
|
+
return dict(row)
|
|
277
|
+
|
|
278
|
+
@classmethod
|
|
279
|
+
@contextmanager
|
|
280
|
+
def borrowed_connection(cls):
|
|
281
|
+
"""
|
|
282
|
+
Context manager that returns a connection from the connection
|
|
283
|
+
pool.
|
|
284
|
+
"""
|
|
285
|
+
conn = cls.connection_pool.getconn()
|
|
286
|
+
try:
|
|
287
|
+
yield conn
|
|
288
|
+
finally:
|
|
289
|
+
cls.connection_pool.putconn(conn)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@classmethod
|
|
293
|
+
def truncate_table(cls, conn, table_name):
|
|
294
|
+
"""Transactionally truncate the given *table_name* using *conn*"""
|
|
295
|
+
try:
|
|
296
|
+
|
|
297
|
+
with conn.cursor() as cur:
|
|
298
|
+
cur.execute(
|
|
299
|
+
'TRUNCATE TABLE ' + table_name + ' CASCADE'
|
|
300
|
+
)
|
|
301
|
+
except (ProgrammingError, InternalError):
|
|
302
|
+
# Table doesn't exist, not a full schema,
|
|
303
|
+
# ignore.
|
|
304
|
+
# OR:
|
|
305
|
+
# Already aborted
|
|
306
|
+
import traceback
|
|
307
|
+
traceback.print_exc()
|
|
308
|
+
conn.rollback()
|
|
309
|
+
else:
|
|
310
|
+
# Is PostgreSQL, TRUNCATE is transactional!
|
|
311
|
+
# Awesome!
|
|
312
|
+
conn.commit()
|
|
313
|
+
|
|
314
|
+
@classmethod
|
|
315
|
+
def drop_relation(cls, relation, kind='TABLE', idempotent=False):
|
|
316
|
+
"""Drops the *relation* of type *kind* (default table), in new transaction."""
|
|
317
|
+
with cls.borrowed_connection() as conn:
|
|
318
|
+
with conn.cursor() as cur:
|
|
319
|
+
if idempotent:
|
|
320
|
+
cur.execute(f"DROP {kind} IF EXISTS {relation}")
|
|
321
|
+
else:
|
|
322
|
+
cur.execute(f"DROP {kind} {relation}")
|
|
323
|
+
conn.commit()
|
|
324
|
+
|
|
325
|
+
@classmethod
|
|
326
|
+
def vacuum(cls, *tables, **kwargs):
|
|
327
|
+
verbose = ''
|
|
328
|
+
if kwargs.pop('verbose', False):
|
|
329
|
+
verbose = ', VERBOSE'
|
|
330
|
+
|
|
331
|
+
with cls.borrowed_connection() as conn:
|
|
332
|
+
conn.autocommit = True
|
|
333
|
+
# FULL rewrites all tables and takes forever.
|
|
334
|
+
# FREEZE is simpler and compacts tables
|
|
335
|
+
stmt = f'VACUUM (FREEZE, ANALYZE {verbose}) '
|
|
336
|
+
tables = tables or ('',)
|
|
337
|
+
with conn.cursor() as cur:
|
|
338
|
+
# VACUUM cannot run inside a transaction block...
|
|
339
|
+
for t in tables:
|
|
340
|
+
cur.execute(stmt + t)
|
|
341
|
+
conn.autocommit = False
|
|
342
|
+
if verbose:
|
|
343
|
+
for n in conn.notices:
|
|
344
|
+
print(n)
|
|
345
|
+
del conn.notices[:]
|
|
346
|
+
if kwargs.pop('size_report', True):
|
|
347
|
+
cls.print_size_report()
|
|
348
|
+
|
|
349
|
+
ONLY_PRINT_SIZE_OF_TABLES = None
|
|
350
|
+
|
|
351
|
+
@classmethod
|
|
352
|
+
def print_size_report(cls):
|
|
353
|
+
extra_query = ''
|
|
354
|
+
if cls.ONLY_PRINT_SIZE_OF_TABLES:
|
|
355
|
+
t = cls.ONLY_PRINT_SIZE_OF_TABLES
|
|
356
|
+
extra_query = f"AND table_name = '{t}'"
|
|
357
|
+
query = f"""
|
|
358
|
+
SELECT table_name,
|
|
359
|
+
pg_size_pretty(total_bytes) AS total,
|
|
360
|
+
pg_size_pretty(index_bytes) AS INDEX,
|
|
361
|
+
pg_size_pretty(toast_bytes) AS toast,
|
|
362
|
+
pg_size_pretty(table_bytes) AS TABLE
|
|
363
|
+
FROM (
|
|
364
|
+
SELECT *, total_bytes-index_bytes-COALESCE(toast_bytes,0) AS table_bytes FROM (
|
|
365
|
+
SELECT c.oid,nspname AS table_schema, relname AS TABLE_NAME
|
|
366
|
+
, c.reltuples AS row_estimate
|
|
367
|
+
, pg_total_relation_size(c.oid) AS total_bytes
|
|
368
|
+
, pg_indexes_size(c.oid) AS index_bytes
|
|
369
|
+
, pg_total_relation_size(reltoastrelid) AS toast_bytes
|
|
370
|
+
FROM pg_class c
|
|
371
|
+
LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
372
|
+
WHERE relkind = 'r'
|
|
373
|
+
) a
|
|
374
|
+
) a
|
|
375
|
+
WHERE (table_name NOT LIKE 'pg_%' and table_name not like 'abstract_%'
|
|
376
|
+
{extra_query}
|
|
377
|
+
)
|
|
378
|
+
AND table_schema <> 'pg_catalog' and table_schema <> 'information_schema'
|
|
379
|
+
ORDER BY total_bytes DESC
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
with cls.borrowed_connection() as conn:
|
|
383
|
+
with conn.cursor() as cur:
|
|
384
|
+
cur.execute(query)
|
|
385
|
+
|
|
386
|
+
rows = [dict(row) for row in cur]
|
|
387
|
+
|
|
388
|
+
keys = ['table_name', 'total', 'index', 'toast', 'table']
|
|
389
|
+
|
|
390
|
+
rows.insert(0, {k: k for k in keys})
|
|
391
|
+
print()
|
|
392
|
+
fmt = "| {table_name:35s} | {total:10s} | {index:10s} | {toast:10s} | {table:10s}"
|
|
393
|
+
for row in rows:
|
|
394
|
+
if not extra_query and row['total'] in {
|
|
395
|
+
'72 kB', '32 kB', '24 kB', '16 kB', '8192 bytes'
|
|
396
|
+
}:
|
|
397
|
+
continue
|
|
398
|
+
print(fmt.format(
|
|
399
|
+
**{k: v if v else '<null>' for k, v in row.items()}
|
|
400
|
+
))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class SchemaDatabaseLayer(DatabaseLayer):
|
|
404
|
+
"""
|
|
405
|
+
A test layer that adds our schema.
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
SCHEMA_FILE = os.path.abspath(os.path.join(
|
|
409
|
+
os.path.dirname(os.path.abspath(__file__)),
|
|
410
|
+
'..', '..', '..', '..', '..',
|
|
411
|
+
'full_schema.sql'
|
|
412
|
+
))
|
|
413
|
+
|
|
414
|
+
@classmethod
|
|
415
|
+
def run_files(cls, *files):
|
|
416
|
+
for fname in files:
|
|
417
|
+
code, stdout, stderr = cls.postgres_node.psql(
|
|
418
|
+
filename=fname,
|
|
419
|
+
ON_ERROR_STOP=1
|
|
420
|
+
)
|
|
421
|
+
if code:
|
|
422
|
+
break
|
|
423
|
+
|
|
424
|
+
if code:
|
|
425
|
+
import subprocess
|
|
426
|
+
stdout = stdout.decode("utf-8")
|
|
427
|
+
stderr = stderr.decode('utf-8')
|
|
428
|
+
print(stdout)
|
|
429
|
+
print(stderr)
|
|
430
|
+
raise subprocess.CalledProcessError(
|
|
431
|
+
code,
|
|
432
|
+
'psql',
|
|
433
|
+
stdout,
|
|
434
|
+
stderr
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
@classmethod
|
|
438
|
+
def _tangle_schema_if_needed(cls):
|
|
439
|
+
# If the schema files do not exist, or db.org is newer
|
|
440
|
+
# than they are, run emacs to weave the files together.
|
|
441
|
+
# This requires a working emacs with org-mode available.
|
|
442
|
+
from pathlib import Path
|
|
443
|
+
import subprocess
|
|
444
|
+
|
|
445
|
+
cwd = Path(".")
|
|
446
|
+
# Each org file tangles to at least one sql file.
|
|
447
|
+
org_to_sql = {
|
|
448
|
+
org: org.with_suffix('.sql')
|
|
449
|
+
for org in cwd.glob("*.org")
|
|
450
|
+
}
|
|
451
|
+
org_to_sql[Path("db.org")] = Path("full_schema.sql")
|
|
452
|
+
|
|
453
|
+
for org, sql in org_to_sql.items():
|
|
454
|
+
if not org.exists():
|
|
455
|
+
continue
|
|
456
|
+
if sql.exists() and sql.stat().st_mtime >= org.stat().st_mtime:
|
|
457
|
+
continue
|
|
458
|
+
print(f"\nDatabase schema files outdated; tangling {org}")
|
|
459
|
+
ex = None
|
|
460
|
+
try:
|
|
461
|
+
output = subprocess.check_output([
|
|
462
|
+
"emacs",
|
|
463
|
+
"--batch",
|
|
464
|
+
"--eval",
|
|
465
|
+
f'''(progn
|
|
466
|
+
(package-initialize)
|
|
467
|
+
(require 'org)
|
|
468
|
+
(org-babel-tangle-file "{org}")
|
|
469
|
+
)'''
|
|
470
|
+
], stderr=subprocess.STDOUT)
|
|
471
|
+
except FileNotFoundError as e:
|
|
472
|
+
output = str(e).encode('utf-8')
|
|
473
|
+
ex = e
|
|
474
|
+
except subprocess.CalledProcessError as e:
|
|
475
|
+
output = ex.output
|
|
476
|
+
ex = e # pylint:disable=redefined-variable-type
|
|
477
|
+
|
|
478
|
+
output = output.decode('utf-8')
|
|
479
|
+
|
|
480
|
+
if ex is not None or 'Tangled 0' in output:
|
|
481
|
+
print("Failed to tangle database schema; "
|
|
482
|
+
"(check file paths):\n",
|
|
483
|
+
output,
|
|
484
|
+
file=sys.stderr)
|
|
485
|
+
sys.exit(1)
|
|
486
|
+
|
|
487
|
+
@classmethod
|
|
488
|
+
def setUp(cls):
|
|
489
|
+
cwd = os.getcwd()
|
|
490
|
+
try:
|
|
491
|
+
os.chdir(os.path.dirname(cls.SCHEMA_FILE))
|
|
492
|
+
# XXX: Do exceptions here prevent the super tearDown()
|
|
493
|
+
# from being called?
|
|
494
|
+
cls._tangle_schema_if_needed()
|
|
495
|
+
|
|
496
|
+
to_run = [cls.SCHEMA_FILE]
|
|
497
|
+
if os.path.exists("prereq.sql"):
|
|
498
|
+
to_run.insert(0, "prereq.sql")
|
|
499
|
+
cls.run_files(*to_run)
|
|
500
|
+
finally:
|
|
501
|
+
os.chdir(cwd)
|
|
502
|
+
|
|
503
|
+
@classmethod
|
|
504
|
+
def tearDown(cls):
|
|
505
|
+
pass
|
|
506
|
+
|
|
507
|
+
@classmethod
|
|
508
|
+
def testSetUp(cls):
|
|
509
|
+
pass
|
|
510
|
+
|
|
511
|
+
@classmethod
|
|
512
|
+
def testTearDown(cls):
|
|
513
|
+
pass
|
|
514
|
+
|
|
515
|
+
class DatabaseBackupLayerHelper:
|
|
516
|
+
"""
|
|
517
|
+
A layer helper that works with another layer to
|
|
518
|
+
|
|
519
|
+
* create a backup of the current database on `push`;
|
|
520
|
+
* make that backup active;
|
|
521
|
+
* switch the connection pool to that backup
|
|
522
|
+
* reverse all of that on layer `pop`
|
|
523
|
+
|
|
524
|
+
Note that this consists of modifying values in the `DatabaseLayer`,
|
|
525
|
+
so the *layer* parameter must extend that.
|
|
526
|
+
"""
|
|
527
|
+
|
|
528
|
+
_nodes = []
|
|
529
|
+
_pools = []
|
|
530
|
+
|
|
531
|
+
@classmethod
|
|
532
|
+
def push(cls, layer):
|
|
533
|
+
current_node = DatabaseLayer.postgres_node
|
|
534
|
+
cls._nodes.append(current_node)
|
|
535
|
+
cls._pools.append(DatabaseLayer.connection_pool)
|
|
536
|
+
|
|
537
|
+
with layer.borrowed_connection() as conn:
|
|
538
|
+
with conn.cursor() as cur:
|
|
539
|
+
# If we don't checkpoint here, then the backup waits
|
|
540
|
+
# for the next WAL checkpoint to happen. We may not have
|
|
541
|
+
# written much to the WAL, so we could wait until a time limit
|
|
542
|
+
# expires, which is ofter 30+ seconds. We don't want to wait.
|
|
543
|
+
cur.execute('CHECKPOINT')
|
|
544
|
+
|
|
545
|
+
# A streaming backup uses a replication slot, but it
|
|
546
|
+
# does the copy in parallel.
|
|
547
|
+
backup = current_node.backup(xlog_method='stream')
|
|
548
|
+
DatabaseLayer.postgres_node = new_node = backup.spawn_primary()
|
|
549
|
+
new_node.start()
|
|
550
|
+
DatabaseLayer.connection_pool = layer.connection_pool_klass(
|
|
551
|
+
layer.connection_pool_minconn,
|
|
552
|
+
layer.connection_pool_maxconn,
|
|
553
|
+
dbname=layer.DATABASE_NAME,
|
|
554
|
+
host='localhost',
|
|
555
|
+
port=new_node.port,
|
|
556
|
+
cursor_factory=DictCursor
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
@classmethod
|
|
560
|
+
def pop(cls, layer): # pylint:disable=unused-argument
|
|
561
|
+
DatabaseLayer.tearDown() # Closes the current node, and the connection pool
|
|
562
|
+
DatabaseLayer.postgres_node = cls._nodes.pop()
|
|
563
|
+
DatabaseLayer.connection_pool = cls._pools.pop()
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
_persistent_base = (
|
|
567
|
+
# If we're loading a file, it has the schema
|
|
568
|
+
# info.
|
|
569
|
+
|
|
570
|
+
SchemaDatabaseLayer
|
|
571
|
+
if not LOAD_DATABASE_ON_SETUP
|
|
572
|
+
else DatabaseLayer
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
class PersistentDatabaseLayer(_persistent_base):
|
|
576
|
+
"""
|
|
577
|
+
A layer that establishes persistent data visible to
|
|
578
|
+
all of its tests (and all of its sub-layers).
|
|
579
|
+
|
|
580
|
+
Sub-layers need to check whether they should
|
|
581
|
+
clean up or not, because we may be saving the database file.
|
|
582
|
+
|
|
583
|
+
It's important to have a fairly linear layer
|
|
584
|
+
setup, or layers that don't interfere with each other.
|
|
585
|
+
"""
|
|
586
|
+
|
|
587
|
+
@classmethod
|
|
588
|
+
def setUp(cls):
|
|
589
|
+
if LOAD_DATABASE_ON_SETUP:
|
|
590
|
+
print(f" (Loading database from {LOAD_DATABASE_ON_SETUP}) ",
|
|
591
|
+
end='',
|
|
592
|
+
flush=True)
|
|
593
|
+
cls.postgres_node.restore(LOAD_DATABASE_ON_SETUP)
|
|
594
|
+
cls.vacuum()
|
|
595
|
+
|
|
596
|
+
@classmethod
|
|
597
|
+
def testSetUp(cls):
|
|
598
|
+
pass
|
|
599
|
+
|
|
600
|
+
@classmethod
|
|
601
|
+
def testTearDown(cls):
|
|
602
|
+
pass
|
|
603
|
+
|
|
604
|
+
@classmethod
|
|
605
|
+
def persistent_layer_skip_teardown(cls):
|
|
606
|
+
"""
|
|
607
|
+
Should persistent layers, that write data intended to be
|
|
608
|
+
visible between tests (and in sub-layers) tear down that data
|
|
609
|
+
when the layer is torn down? If we're saving the database, we
|
|
610
|
+
don't want to do that.
|
|
611
|
+
|
|
612
|
+
Raising NotImplementedError causes the testrunner to assume
|
|
613
|
+
it's python resources that are the problem and continue in a new
|
|
614
|
+
subprocess, which doesn't help (and may hurt?). So you must check this as a
|
|
615
|
+
boolean.
|
|
616
|
+
"""
|
|
617
|
+
return SAVE_DATABASE_ON_TEARDOWN
|
|
618
|
+
|
|
619
|
+
@classmethod
|
|
620
|
+
def persistent_layer_skip_setup(cls):
|
|
621
|
+
"""
|
|
622
|
+
Should persistent layers skip their setup because
|
|
623
|
+
we loaded a save file?
|
|
624
|
+
"""
|
|
625
|
+
return LOAD_DATABASE_ON_SETUP
|
|
626
|
+
|
|
627
|
+
@classmethod
|
|
628
|
+
def tearDown(cls):
|
|
629
|
+
if SAVE_DATABASE_ON_TEARDOWN:
|
|
630
|
+
tmp_fname = cls.postgres_node.dump(format='custom')
|
|
631
|
+
result_fname = tmp_fname
|
|
632
|
+
if SAVE_DATABASE_FILENAME:
|
|
633
|
+
import shutil
|
|
634
|
+
result_fname = SAVE_DATABASE_FILENAME
|
|
635
|
+
while os.path.exists(result_fname):
|
|
636
|
+
result_fname += '.1'
|
|
637
|
+
shutil.move(tmp_fname, result_fname)
|
|
638
|
+
print(f" (Database dumped to {result_fname}) ", end='')
|
|
639
|
+
|
|
640
|
+
def persistent_skip_setup(func):
|
|
641
|
+
|
|
642
|
+
@functools.wraps(func)
|
|
643
|
+
def maybe_skip_setup(cls):
|
|
644
|
+
if cls.persistent_layer_skip_setup():
|
|
645
|
+
return
|
|
646
|
+
func(cls)
|
|
647
|
+
return maybe_skip_setup
|
|
648
|
+
|
|
649
|
+
def persistent_skip_teardown(func):
|
|
650
|
+
@functools.wraps(func)
|
|
651
|
+
def f(cls):
|
|
652
|
+
if cls.persistent_layer_skip_teardown():
|
|
653
|
+
return
|
|
654
|
+
func(cls)
|
|
655
|
+
return f
|
|
656
|
+
|
|
657
|
+
class DatabaseTestCase(unittest.TestCase):
|
|
658
|
+
"""
|
|
659
|
+
A helper test base containing some functions useful for both
|
|
660
|
+
benchmarking and unit testing.
|
|
661
|
+
"""
|
|
662
|
+
# pylint:disable=no-member
|
|
663
|
+
|
|
664
|
+
@contextmanager
|
|
665
|
+
def assertRaisesIntegrityError(self, match=None):
|
|
666
|
+
if match:
|
|
667
|
+
with self.assertRaisesRegex(IntegrityError, match) as exc:
|
|
668
|
+
yield exc
|
|
669
|
+
else:
|
|
670
|
+
with self.assertRaises(IntegrityError) as exc:
|
|
671
|
+
yield exc
|
|
672
|
+
|
|
673
|
+
# We can't do any queries after an error is raised
|
|
674
|
+
# until we rollback.
|
|
675
|
+
self.layer.connection.rollback()
|
|
676
|
+
return exc
|
|
677
|
+
|
|
678
|
+
def assert_row_count_in_query(self, expected_count, query):
|
|
679
|
+
cur = self.layer.cursor
|
|
680
|
+
|
|
681
|
+
cur.execute('SELECT COUNT(*) FROM ' + query)
|
|
682
|
+
row = cur.fetchone()
|
|
683
|
+
count = row[0]
|
|
684
|
+
|
|
685
|
+
self.assertEqual(expected_count, count, query)
|
|
686
|
+
|
|
687
|
+
def assert_row_count_in_table(self, expected_count, table_name):
|
|
688
|
+
__traceback_info__ = table_name
|
|
689
|
+
self.assert_row_count_in_query(expected_count, table_name)
|
|
690
|
+
|
|
691
|
+
def assert_row_count_in_cursor(self, rowcount, cursor=None):
|
|
692
|
+
cur = cursor if cursor is not None else self.layer.cursor
|
|
693
|
+
self.assertEqual(cur.rowcount, rowcount)
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Tests for postgres.py
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestBasic(unittest.TestCase):
|
|
11
|
+
|
|
12
|
+
def test_imports(self):
|
|
13
|
+
from .. import postgres
|
|
14
|
+
self.assertIsNotNone(postgres)
|
|
15
|
+
|
|
16
|
+
if __name__ == '__main__':
|
|
17
|
+
unittest.main()
|