pytest-neon 0.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.
- pytest_neon/__init__.py +20 -0
- pytest_neon/plugin.py +477 -0
- pytest_neon/py.typed +0 -0
- pytest_neon-0.2.0.dist-info/METADATA +314 -0
- pytest_neon-0.2.0.dist-info/RECORD +8 -0
- pytest_neon-0.2.0.dist-info/WHEEL +4 -0
- pytest_neon-0.2.0.dist-info/entry_points.txt +2 -0
- pytest_neon-0.2.0.dist-info/licenses/LICENSE +21 -0
pytest_neon/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Pytest plugin for Neon database branch isolation in tests."""
|
|
2
|
+
|
|
3
|
+
from pytest_neon.plugin import (
|
|
4
|
+
NeonBranch,
|
|
5
|
+
neon_branch,
|
|
6
|
+
neon_branch_shared,
|
|
7
|
+
neon_connection,
|
|
8
|
+
neon_connection_psycopg,
|
|
9
|
+
neon_engine,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__version__ = "0.2.0"
|
|
13
|
+
__all__ = [
|
|
14
|
+
"NeonBranch",
|
|
15
|
+
"neon_branch",
|
|
16
|
+
"neon_branch_shared",
|
|
17
|
+
"neon_connection",
|
|
18
|
+
"neon_connection_psycopg",
|
|
19
|
+
"neon_engine",
|
|
20
|
+
]
|
pytest_neon/plugin.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Pytest plugin providing Neon database branch fixtures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Generator
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
import requests
|
|
14
|
+
from neon_api import NeonAPI
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
# Default branch expiry in seconds (10 minutes)
|
|
20
|
+
DEFAULT_BRANCH_EXPIRY_SECONDS = 600
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class NeonBranch:
|
|
25
|
+
"""Information about a Neon test branch."""
|
|
26
|
+
|
|
27
|
+
branch_id: str
|
|
28
|
+
project_id: str
|
|
29
|
+
connection_string: str
|
|
30
|
+
host: str
|
|
31
|
+
parent_id: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
35
|
+
"""Add Neon-specific command line options."""
|
|
36
|
+
group = parser.getgroup("neon", "Neon database branching")
|
|
37
|
+
|
|
38
|
+
group.addoption(
|
|
39
|
+
"--neon-api-key",
|
|
40
|
+
dest="neon_api_key",
|
|
41
|
+
help="Neon API key (default: NEON_API_KEY env var)",
|
|
42
|
+
)
|
|
43
|
+
group.addoption(
|
|
44
|
+
"--neon-project-id",
|
|
45
|
+
dest="neon_project_id",
|
|
46
|
+
help="Neon project ID (default: NEON_PROJECT_ID env var)",
|
|
47
|
+
)
|
|
48
|
+
group.addoption(
|
|
49
|
+
"--neon-parent-branch",
|
|
50
|
+
dest="neon_parent_branch",
|
|
51
|
+
help="Parent branch ID to create test branches from (default: project default)",
|
|
52
|
+
)
|
|
53
|
+
group.addoption(
|
|
54
|
+
"--neon-database",
|
|
55
|
+
dest="neon_database",
|
|
56
|
+
default="neondb",
|
|
57
|
+
help="Database name (default: neondb)",
|
|
58
|
+
)
|
|
59
|
+
group.addoption(
|
|
60
|
+
"--neon-role",
|
|
61
|
+
dest="neon_role",
|
|
62
|
+
default="neondb_owner",
|
|
63
|
+
help="Database role (default: neondb_owner)",
|
|
64
|
+
)
|
|
65
|
+
group.addoption(
|
|
66
|
+
"--neon-keep-branches",
|
|
67
|
+
action="store_true",
|
|
68
|
+
dest="neon_keep_branches",
|
|
69
|
+
help="Don't delete branches after tests (useful for debugging)",
|
|
70
|
+
)
|
|
71
|
+
group.addoption(
|
|
72
|
+
"--neon-branch-expiry",
|
|
73
|
+
dest="neon_branch_expiry",
|
|
74
|
+
type=int,
|
|
75
|
+
default=DEFAULT_BRANCH_EXPIRY_SECONDS,
|
|
76
|
+
help=(
|
|
77
|
+
f"Branch auto-expiry in seconds "
|
|
78
|
+
f"(default: {DEFAULT_BRANCH_EXPIRY_SECONDS}). Set to 0 to disable."
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
group.addoption(
|
|
82
|
+
"--neon-env-var",
|
|
83
|
+
dest="neon_env_var",
|
|
84
|
+
default="DATABASE_URL",
|
|
85
|
+
help="Environment variable to set with connection string (default: DATABASE_URL)", # noqa: E501
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _get_config_value(
|
|
90
|
+
config: pytest.Config, option: str, env_var: str, default: str | None = None
|
|
91
|
+
) -> str | None:
|
|
92
|
+
"""Get config value from CLI option, env var, or default."""
|
|
93
|
+
value = config.getoption(option, default=None)
|
|
94
|
+
if value is not None:
|
|
95
|
+
return value
|
|
96
|
+
return os.environ.get(env_var, default)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _create_neon_branch(
|
|
100
|
+
request: pytest.FixtureRequest,
|
|
101
|
+
) -> Generator[NeonBranch, None, None]:
|
|
102
|
+
"""
|
|
103
|
+
Internal helper that creates and manages a Neon branch lifecycle.
|
|
104
|
+
|
|
105
|
+
This is the core implementation used by branch fixtures.
|
|
106
|
+
"""
|
|
107
|
+
config = request.config
|
|
108
|
+
|
|
109
|
+
api_key = _get_config_value(config, "neon_api_key", "NEON_API_KEY")
|
|
110
|
+
project_id = _get_config_value(config, "neon_project_id", "NEON_PROJECT_ID")
|
|
111
|
+
parent_branch_id = _get_config_value(
|
|
112
|
+
config, "neon_parent_branch", "NEON_PARENT_BRANCH_ID"
|
|
113
|
+
)
|
|
114
|
+
database_name = _get_config_value(
|
|
115
|
+
config, "neon_database", "NEON_DATABASE", "neondb"
|
|
116
|
+
)
|
|
117
|
+
role_name = _get_config_value(config, "neon_role", "NEON_ROLE", "neondb_owner")
|
|
118
|
+
keep_branches = config.getoption("neon_keep_branches", default=False)
|
|
119
|
+
branch_expiry = config.getoption(
|
|
120
|
+
"neon_branch_expiry", default=DEFAULT_BRANCH_EXPIRY_SECONDS
|
|
121
|
+
)
|
|
122
|
+
env_var_name = config.getoption("neon_env_var", default="DATABASE_URL")
|
|
123
|
+
|
|
124
|
+
if not api_key:
|
|
125
|
+
pytest.skip(
|
|
126
|
+
"Neon API key not configured (set NEON_API_KEY or use --neon-api-key)"
|
|
127
|
+
)
|
|
128
|
+
if not project_id:
|
|
129
|
+
pytest.skip(
|
|
130
|
+
"Neon project ID not configured "
|
|
131
|
+
"(set NEON_PROJECT_ID or use --neon-project-id)"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
neon = NeonAPI(api_key=api_key)
|
|
135
|
+
|
|
136
|
+
# Generate unique branch name
|
|
137
|
+
branch_name = f"pytest-{os.urandom(4).hex()}"
|
|
138
|
+
|
|
139
|
+
# Build branch creation payload
|
|
140
|
+
branch_config: dict[str, Any] = {"name": branch_name}
|
|
141
|
+
if parent_branch_id:
|
|
142
|
+
branch_config["parent_id"] = parent_branch_id
|
|
143
|
+
|
|
144
|
+
# Set branch expiration (auto-delete) as a safety net for interrupted test runs
|
|
145
|
+
# This uses the branch expires_at field, not endpoint suspend_timeout
|
|
146
|
+
if branch_expiry and branch_expiry > 0:
|
|
147
|
+
expires_at = datetime.now(timezone.utc) + timedelta(seconds=branch_expiry)
|
|
148
|
+
branch_config["expires_at"] = expires_at.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
149
|
+
|
|
150
|
+
# Create branch with compute endpoint
|
|
151
|
+
result = neon.branch_create(
|
|
152
|
+
project_id=project_id,
|
|
153
|
+
branch=branch_config,
|
|
154
|
+
endpoints=[{"type": "read_write"}],
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
branch = result.branch
|
|
158
|
+
|
|
159
|
+
# Get endpoint_id from operations
|
|
160
|
+
# (branch_create returns operations, not endpoints directly)
|
|
161
|
+
endpoint_id = None
|
|
162
|
+
for op in result.operations:
|
|
163
|
+
if op.endpoint_id:
|
|
164
|
+
endpoint_id = op.endpoint_id
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
if not endpoint_id:
|
|
168
|
+
raise RuntimeError(f"No endpoint created for branch {branch.id}")
|
|
169
|
+
|
|
170
|
+
# Wait for endpoint to be ready (it starts in "init" state)
|
|
171
|
+
# Endpoints typically become active in 1-2 seconds
|
|
172
|
+
max_wait_seconds = 60
|
|
173
|
+
poll_interval = 0.5
|
|
174
|
+
waited = 0.0
|
|
175
|
+
|
|
176
|
+
while True:
|
|
177
|
+
endpoint_response = neon.endpoint(
|
|
178
|
+
project_id=project_id, endpoint_id=endpoint_id
|
|
179
|
+
)
|
|
180
|
+
endpoint = endpoint_response.endpoint
|
|
181
|
+
state = endpoint.current_state
|
|
182
|
+
|
|
183
|
+
if state == "active" or str(state) == "EndpointState.active":
|
|
184
|
+
break
|
|
185
|
+
|
|
186
|
+
if waited >= max_wait_seconds:
|
|
187
|
+
raise RuntimeError(
|
|
188
|
+
f"Timeout waiting for endpoint {endpoint_id} to become active "
|
|
189
|
+
f"(current state: {state})"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
time.sleep(poll_interval)
|
|
193
|
+
waited += poll_interval
|
|
194
|
+
|
|
195
|
+
host = endpoint.host
|
|
196
|
+
|
|
197
|
+
# Reset password to get the password value
|
|
198
|
+
# (newly created branches don't expose password)
|
|
199
|
+
password_response = neon.role_password_reset(
|
|
200
|
+
project_id=project_id,
|
|
201
|
+
branch_id=branch.id,
|
|
202
|
+
role_name=role_name,
|
|
203
|
+
)
|
|
204
|
+
password = password_response.role.password
|
|
205
|
+
|
|
206
|
+
# Build connection string
|
|
207
|
+
connection_string = (
|
|
208
|
+
f"postgresql://{role_name}:{password}@{host}/{database_name}?sslmode=require"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
neon_branch_info = NeonBranch(
|
|
212
|
+
branch_id=branch.id,
|
|
213
|
+
project_id=project_id,
|
|
214
|
+
connection_string=connection_string,
|
|
215
|
+
host=host,
|
|
216
|
+
parent_id=branch.parent_id,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Set DATABASE_URL (or configured env var) for the duration of the fixture scope
|
|
220
|
+
original_env_value = os.environ.get(env_var_name)
|
|
221
|
+
os.environ[env_var_name] = connection_string
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
yield neon_branch_info
|
|
225
|
+
finally:
|
|
226
|
+
# Restore original env var
|
|
227
|
+
if original_env_value is None:
|
|
228
|
+
os.environ.pop(env_var_name, None)
|
|
229
|
+
else:
|
|
230
|
+
os.environ[env_var_name] = original_env_value
|
|
231
|
+
|
|
232
|
+
# Cleanup: delete branch unless --neon-keep-branches was specified
|
|
233
|
+
if not keep_branches:
|
|
234
|
+
try:
|
|
235
|
+
neon.branch_delete(project_id=project_id, branch_id=branch.id)
|
|
236
|
+
except Exception as e:
|
|
237
|
+
# Log but don't fail tests due to cleanup issues
|
|
238
|
+
import warnings
|
|
239
|
+
|
|
240
|
+
warnings.warn(
|
|
241
|
+
f"Failed to delete Neon branch {branch.id}: {e}",
|
|
242
|
+
stacklevel=2,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _reset_branch_to_parent(branch: NeonBranch, api_key: str) -> None:
|
|
247
|
+
"""Reset a branch to its parent's state using the Neon API."""
|
|
248
|
+
if not branch.parent_id:
|
|
249
|
+
raise RuntimeError(f"Branch {branch.branch_id} has no parent - cannot reset")
|
|
250
|
+
|
|
251
|
+
url = f"https://console.neon.tech/api/v2/projects/{branch.project_id}/branches/{branch.branch_id}/restore"
|
|
252
|
+
headers = {
|
|
253
|
+
"Authorization": f"Bearer {api_key}",
|
|
254
|
+
"Content-Type": "application/json",
|
|
255
|
+
}
|
|
256
|
+
response = requests.post(
|
|
257
|
+
url, headers=headers, json={"source_branch_id": branch.parent_id}
|
|
258
|
+
)
|
|
259
|
+
response.raise_for_status()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@pytest.fixture(scope="module")
|
|
263
|
+
def _neon_branch_for_reset(
|
|
264
|
+
request: pytest.FixtureRequest,
|
|
265
|
+
) -> Generator[NeonBranch, None, None]:
|
|
266
|
+
"""Internal fixture that creates a branch for reset-based isolation."""
|
|
267
|
+
yield from _create_neon_branch(request)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@pytest.fixture(scope="function")
|
|
271
|
+
def neon_branch(
|
|
272
|
+
request: pytest.FixtureRequest,
|
|
273
|
+
_neon_branch_for_reset: NeonBranch,
|
|
274
|
+
) -> Generator[NeonBranch, None, None]:
|
|
275
|
+
"""
|
|
276
|
+
Provide an isolated Neon database branch for each test.
|
|
277
|
+
|
|
278
|
+
This is the primary fixture for database testing. It creates one branch per
|
|
279
|
+
test module, then resets it to the parent branch's state after each test.
|
|
280
|
+
This provides test isolation with ~0.5s overhead per test.
|
|
281
|
+
|
|
282
|
+
The branch is automatically deleted after all tests in the module complete,
|
|
283
|
+
unless --neon-keep-branches is specified. Branches also auto-expire after
|
|
284
|
+
10 minutes by default (configurable via --neon-branch-expiry) as a safety net
|
|
285
|
+
for interrupted test runs.
|
|
286
|
+
|
|
287
|
+
The connection string is automatically set in the DATABASE_URL environment
|
|
288
|
+
variable (configurable via --neon-env-var).
|
|
289
|
+
|
|
290
|
+
Requires either:
|
|
291
|
+
- NEON_API_KEY and NEON_PROJECT_ID environment variables, or
|
|
292
|
+
- --neon-api-key and --neon-project-id command line options
|
|
293
|
+
|
|
294
|
+
Yields:
|
|
295
|
+
NeonBranch: Object with branch_id, project_id, connection_string, and host.
|
|
296
|
+
|
|
297
|
+
Example:
|
|
298
|
+
def test_database_operation(neon_branch):
|
|
299
|
+
# DATABASE_URL is automatically set
|
|
300
|
+
conn_string = os.environ["DATABASE_URL"]
|
|
301
|
+
# or use directly
|
|
302
|
+
conn_string = neon_branch.connection_string
|
|
303
|
+
"""
|
|
304
|
+
config = request.config
|
|
305
|
+
api_key = _get_config_value(config, "neon_api_key", "NEON_API_KEY")
|
|
306
|
+
|
|
307
|
+
yield _neon_branch_for_reset
|
|
308
|
+
|
|
309
|
+
# Reset branch to parent state after each test
|
|
310
|
+
if api_key:
|
|
311
|
+
try:
|
|
312
|
+
_reset_branch_to_parent(branch=_neon_branch_for_reset, api_key=api_key)
|
|
313
|
+
except Exception as e:
|
|
314
|
+
import warnings
|
|
315
|
+
|
|
316
|
+
warnings.warn(
|
|
317
|
+
f"Failed to reset branch {_neon_branch_for_reset.branch_id}: {e}",
|
|
318
|
+
stacklevel=2,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@pytest.fixture(scope="module")
|
|
323
|
+
def neon_branch_shared(
|
|
324
|
+
request: pytest.FixtureRequest,
|
|
325
|
+
) -> Generator[NeonBranch, None, None]:
|
|
326
|
+
"""
|
|
327
|
+
Provide a shared Neon database branch for all tests in a module.
|
|
328
|
+
|
|
329
|
+
This fixture creates one branch per test module and shares it across all
|
|
330
|
+
tests without resetting. This is the fastest option but tests can see
|
|
331
|
+
each other's data modifications.
|
|
332
|
+
|
|
333
|
+
Use this when:
|
|
334
|
+
- Tests are read-only or don't interfere with each other
|
|
335
|
+
- You manually clean up test data within each test
|
|
336
|
+
- Maximum speed is more important than isolation
|
|
337
|
+
|
|
338
|
+
Warning: Tests in the same module will share database state. Data created
|
|
339
|
+
by one test will be visible to subsequent tests. Use `neon_branch` instead
|
|
340
|
+
if you need isolation between tests.
|
|
341
|
+
|
|
342
|
+
Yields:
|
|
343
|
+
NeonBranch: Object with branch_id, project_id, connection_string, and host.
|
|
344
|
+
|
|
345
|
+
Example:
|
|
346
|
+
def test_read_only_query(neon_branch_shared):
|
|
347
|
+
# Fast: no reset between tests, but be careful about data leakage
|
|
348
|
+
conn_string = neon_branch_shared.connection_string
|
|
349
|
+
"""
|
|
350
|
+
yield from _create_neon_branch(request)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@pytest.fixture
|
|
354
|
+
def neon_connection(neon_branch: NeonBranch):
|
|
355
|
+
"""
|
|
356
|
+
Provide a psycopg2 connection to the test branch.
|
|
357
|
+
|
|
358
|
+
Requires the psycopg2 optional dependency:
|
|
359
|
+
pip install pytest-neon[psycopg2]
|
|
360
|
+
|
|
361
|
+
The connection is rolled back and closed after each test.
|
|
362
|
+
|
|
363
|
+
Yields:
|
|
364
|
+
psycopg2 connection object
|
|
365
|
+
|
|
366
|
+
Example:
|
|
367
|
+
def test_insert(neon_connection):
|
|
368
|
+
cur = neon_connection.cursor()
|
|
369
|
+
cur.execute("INSERT INTO users (name) VALUES ('test')")
|
|
370
|
+
neon_connection.commit()
|
|
371
|
+
"""
|
|
372
|
+
try:
|
|
373
|
+
import psycopg2
|
|
374
|
+
except ImportError:
|
|
375
|
+
pytest.fail(
|
|
376
|
+
"\n\n"
|
|
377
|
+
"═══════════════════════════════════════════════════════════════════\n"
|
|
378
|
+
" MISSING DEPENDENCY: psycopg2\n"
|
|
379
|
+
"═══════════════════════════════════════════════════════════════════\n\n"
|
|
380
|
+
" The 'neon_connection' fixture requires psycopg2.\n\n"
|
|
381
|
+
" To fix this, install the psycopg2 extra:\n\n"
|
|
382
|
+
" pip install pytest-neon[psycopg2]\n\n"
|
|
383
|
+
" Or use the 'neon_branch' fixture with your own database driver:\n\n"
|
|
384
|
+
" def test_example(neon_branch):\n"
|
|
385
|
+
" import your_driver\n"
|
|
386
|
+
" conn = your_driver.connect(neon_branch.connection_string)\n\n"
|
|
387
|
+
"═══════════════════════════════════════════════════════════════════\n"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
conn = psycopg2.connect(neon_branch.connection_string)
|
|
391
|
+
yield conn
|
|
392
|
+
conn.rollback()
|
|
393
|
+
conn.close()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@pytest.fixture
|
|
397
|
+
def neon_connection_psycopg(neon_branch: NeonBranch):
|
|
398
|
+
"""
|
|
399
|
+
Provide a psycopg (v3) connection to the test branch.
|
|
400
|
+
|
|
401
|
+
Requires the psycopg optional dependency:
|
|
402
|
+
pip install pytest-neon[psycopg]
|
|
403
|
+
|
|
404
|
+
The connection is rolled back and closed after each test.
|
|
405
|
+
|
|
406
|
+
Yields:
|
|
407
|
+
psycopg connection object
|
|
408
|
+
|
|
409
|
+
Example:
|
|
410
|
+
def test_insert(neon_connection_psycopg):
|
|
411
|
+
with neon_connection_psycopg.cursor() as cur:
|
|
412
|
+
cur.execute("INSERT INTO users (name) VALUES ('test')")
|
|
413
|
+
neon_connection_psycopg.commit()
|
|
414
|
+
"""
|
|
415
|
+
try:
|
|
416
|
+
import psycopg
|
|
417
|
+
except ImportError:
|
|
418
|
+
pytest.fail(
|
|
419
|
+
"\n\n"
|
|
420
|
+
"═══════════════════════════════════════════════════════════════════\n"
|
|
421
|
+
" MISSING DEPENDENCY: psycopg (v3)\n"
|
|
422
|
+
"═══════════════════════════════════════════════════════════════════\n\n"
|
|
423
|
+
" The 'neon_connection_psycopg' fixture requires psycopg v3.\n\n"
|
|
424
|
+
" To fix this, install the psycopg extra:\n\n"
|
|
425
|
+
" pip install pytest-neon[psycopg]\n\n"
|
|
426
|
+
" Or use the 'neon_branch' fixture with your own database driver:\n\n"
|
|
427
|
+
" def test_example(neon_branch):\n"
|
|
428
|
+
" import your_driver\n"
|
|
429
|
+
" conn = your_driver.connect(neon_branch.connection_string)\n\n"
|
|
430
|
+
"═══════════════════════════════════════════════════════════════════\n"
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
conn = psycopg.connect(neon_branch.connection_string)
|
|
434
|
+
yield conn
|
|
435
|
+
conn.rollback()
|
|
436
|
+
conn.close()
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@pytest.fixture
|
|
440
|
+
def neon_engine(neon_branch: NeonBranch):
|
|
441
|
+
"""
|
|
442
|
+
Provide a SQLAlchemy engine connected to the test branch.
|
|
443
|
+
|
|
444
|
+
Requires the sqlalchemy optional dependency:
|
|
445
|
+
pip install pytest-neon[sqlalchemy]
|
|
446
|
+
|
|
447
|
+
The engine is disposed after each test.
|
|
448
|
+
|
|
449
|
+
Yields:
|
|
450
|
+
SQLAlchemy Engine object
|
|
451
|
+
|
|
452
|
+
Example:
|
|
453
|
+
def test_query(neon_engine):
|
|
454
|
+
with neon_engine.connect() as conn:
|
|
455
|
+
result = conn.execute(text("SELECT 1"))
|
|
456
|
+
"""
|
|
457
|
+
try:
|
|
458
|
+
from sqlalchemy import create_engine
|
|
459
|
+
except ImportError:
|
|
460
|
+
pytest.fail(
|
|
461
|
+
"\n\n"
|
|
462
|
+
"═══════════════════════════════════════════════════════════════════\n"
|
|
463
|
+
" MISSING DEPENDENCY: SQLAlchemy\n"
|
|
464
|
+
"═══════════════════════════════════════════════════════════════════\n\n"
|
|
465
|
+
" The 'neon_engine' fixture requires SQLAlchemy.\n\n"
|
|
466
|
+
" To fix this, install the sqlalchemy extra:\n\n"
|
|
467
|
+
" pip install pytest-neon[sqlalchemy]\n\n"
|
|
468
|
+
" Or use the 'neon_branch' fixture with your own database driver:\n\n"
|
|
469
|
+
" def test_example(neon_branch):\n"
|
|
470
|
+
" from sqlalchemy import create_engine\n"
|
|
471
|
+
" engine = create_engine(neon_branch.connection_string)\n\n"
|
|
472
|
+
"═══════════════════════════════════════════════════════════════════\n"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
engine = create_engine(neon_branch.connection_string)
|
|
476
|
+
yield engine
|
|
477
|
+
engine.dispose()
|
pytest_neon/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-neon
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Pytest plugin for Neon database branch isolation in tests
|
|
5
|
+
Project-URL: Homepage, https://github.com/zain/pytest-neon
|
|
6
|
+
Project-URL: Repository, https://github.com/zain/pytest-neon
|
|
7
|
+
Project-URL: Issues, https://github.com/zain/pytest-neon/issues
|
|
8
|
+
Author: Zain Rizvi
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: branching,database,neon,postgres,pytest,testing
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: Pytest
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Database
|
|
25
|
+
Classifier: Topic :: Software Development :: Testing
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Requires-Dist: neon-api>=0.1.0
|
|
28
|
+
Requires-Dist: pytest>=7.0
|
|
29
|
+
Requires-Dist: requests>=2.20
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest-mock>=3.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
35
|
+
Provides-Extra: psycopg
|
|
36
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'psycopg'
|
|
37
|
+
Provides-Extra: psycopg2
|
|
38
|
+
Requires-Dist: psycopg2-binary>=2.9; extra == 'psycopg2'
|
|
39
|
+
Provides-Extra: sqlalchemy
|
|
40
|
+
Requires-Dist: sqlalchemy>=2.0; extra == 'sqlalchemy'
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# pytest-neon
|
|
44
|
+
|
|
45
|
+
Pytest plugin for [Neon](https://neon.tech) database branch isolation in tests.
|
|
46
|
+
|
|
47
|
+
Each test gets its own isolated database state via Neon's instant branching and reset features. Branches are automatically cleaned up after tests complete.
|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
|
|
51
|
+
- **Isolated test environments**: Each test runs against a clean database state
|
|
52
|
+
- **Fast resets**: ~0.5s per test to reset the branch (not create a new one)
|
|
53
|
+
- **Automatic cleanup**: Branches are deleted after tests, with auto-expiry fallback
|
|
54
|
+
- **Zero infrastructure**: No Docker, no local Postgres, no manual setup
|
|
55
|
+
- **Real database testing**: Test against actual Postgres with your production schema
|
|
56
|
+
- **Automatic `DATABASE_URL`**: Connection string is set in environment automatically
|
|
57
|
+
- **Driver agnostic**: Bring your own driver, or use the optional convenience fixtures
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
Core package (bring your own database driver):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install pytest-neon
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
With optional convenience fixtures:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# For psycopg v3 (recommended)
|
|
71
|
+
pip install pytest-neon[psycopg]
|
|
72
|
+
|
|
73
|
+
# For psycopg2 (legacy)
|
|
74
|
+
pip install pytest-neon[psycopg2]
|
|
75
|
+
|
|
76
|
+
# For SQLAlchemy
|
|
77
|
+
pip install pytest-neon[sqlalchemy]
|
|
78
|
+
|
|
79
|
+
# Multiple extras
|
|
80
|
+
pip install pytest-neon[psycopg,sqlalchemy]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Quick Start
|
|
84
|
+
|
|
85
|
+
1. Set environment variables:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
export NEON_API_KEY="your-api-key"
|
|
89
|
+
export NEON_PROJECT_ID="your-project-id"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
2. Write tests:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
def test_user_creation(neon_branch):
|
|
96
|
+
# DATABASE_URL is automatically set to the test branch
|
|
97
|
+
import psycopg # Your own install
|
|
98
|
+
|
|
99
|
+
with psycopg.connect() as conn: # Uses DATABASE_URL by default
|
|
100
|
+
with conn.cursor() as cur:
|
|
101
|
+
cur.execute("INSERT INTO users (email) VALUES ('test@example.com')")
|
|
102
|
+
conn.commit()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
3. Run tests:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pytest
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Fixtures
|
|
112
|
+
|
|
113
|
+
### `neon_branch` (default, recommended)
|
|
114
|
+
|
|
115
|
+
The primary fixture for database testing. Creates one branch per test module, then resets it to the parent branch's state after each test. This provides test isolation with ~0.5s overhead per test.
|
|
116
|
+
|
|
117
|
+
Returns a `NeonBranch` dataclass with:
|
|
118
|
+
|
|
119
|
+
- `branch_id`: The Neon branch ID
|
|
120
|
+
- `project_id`: The Neon project ID
|
|
121
|
+
- `connection_string`: Full PostgreSQL connection URI
|
|
122
|
+
- `host`: The database host
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
import os
|
|
126
|
+
|
|
127
|
+
def test_branch_info(neon_branch):
|
|
128
|
+
# DATABASE_URL is set automatically
|
|
129
|
+
assert os.environ["DATABASE_URL"] == neon_branch.connection_string
|
|
130
|
+
|
|
131
|
+
# Use with any driver
|
|
132
|
+
import psycopg
|
|
133
|
+
conn = psycopg.connect(neon_branch.connection_string)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Performance**: ~1.5s initial setup per module + ~0.5s reset per test. For a module with 10 tests, expect ~6.5s total overhead.
|
|
137
|
+
|
|
138
|
+
### `neon_branch_shared` (fastest, no isolation)
|
|
139
|
+
|
|
140
|
+
Creates one branch per test module and shares it across all tests without resetting. This is the fastest option but tests can see each other's data modifications.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
def test_read_only_query(neon_branch_shared):
|
|
144
|
+
# Fast: no reset between tests
|
|
145
|
+
# Warning: data from other tests in this module may be visible
|
|
146
|
+
conn = psycopg.connect(neon_branch_shared.connection_string)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Use this when**:
|
|
150
|
+
- Tests are read-only
|
|
151
|
+
- Tests don't interfere with each other
|
|
152
|
+
- You manually clean up test data
|
|
153
|
+
- Maximum speed is more important than isolation
|
|
154
|
+
|
|
155
|
+
**Performance**: ~1.5s initial setup per module, no per-test overhead.
|
|
156
|
+
|
|
157
|
+
### `neon_connection_psycopg` (psycopg v3)
|
|
158
|
+
|
|
159
|
+
Convenience fixture providing a [psycopg v3](https://www.psycopg.org/psycopg3/) connection with automatic rollback and cleanup.
|
|
160
|
+
|
|
161
|
+
**Requires:** `pip install pytest-neon[psycopg]`
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
def test_insert(neon_connection_psycopg):
|
|
165
|
+
with neon_connection_psycopg.cursor() as cur:
|
|
166
|
+
cur.execute("INSERT INTO users (name) VALUES (%s)", ("test",))
|
|
167
|
+
neon_connection_psycopg.commit()
|
|
168
|
+
|
|
169
|
+
with neon_connection_psycopg.cursor() as cur:
|
|
170
|
+
cur.execute("SELECT name FROM users")
|
|
171
|
+
assert cur.fetchone()[0] == "test"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### `neon_connection` (psycopg2)
|
|
175
|
+
|
|
176
|
+
Convenience fixture providing a [psycopg2](https://www.psycopg.org/docs/) connection with automatic rollback and cleanup.
|
|
177
|
+
|
|
178
|
+
**Requires:** `pip install pytest-neon[psycopg2]`
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
def test_insert(neon_connection):
|
|
182
|
+
cur = neon_connection.cursor()
|
|
183
|
+
cur.execute("INSERT INTO users (name) VALUES (%s)", ("test",))
|
|
184
|
+
neon_connection.commit()
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### `neon_engine` (SQLAlchemy)
|
|
188
|
+
|
|
189
|
+
Convenience fixture providing a [SQLAlchemy](https://www.sqlalchemy.org/) engine with automatic disposal.
|
|
190
|
+
|
|
191
|
+
**Requires:** `pip install pytest-neon[sqlalchemy]`
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from sqlalchemy import text
|
|
195
|
+
|
|
196
|
+
def test_query(neon_engine):
|
|
197
|
+
with neon_engine.connect() as conn:
|
|
198
|
+
result = conn.execute(text("SELECT 1"))
|
|
199
|
+
assert result.scalar() == 1
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Configuration
|
|
203
|
+
|
|
204
|
+
### Environment Variables
|
|
205
|
+
|
|
206
|
+
| Variable | Description | Required |
|
|
207
|
+
|----------|-------------|----------|
|
|
208
|
+
| `NEON_API_KEY` | Your Neon API key | Yes |
|
|
209
|
+
| `NEON_PROJECT_ID` | Your Neon project ID | Yes |
|
|
210
|
+
| `NEON_PARENT_BRANCH_ID` | Parent branch to create test branches from | No |
|
|
211
|
+
| `NEON_DATABASE` | Database name (default: `neondb`) | No |
|
|
212
|
+
| `NEON_ROLE` | Database role (default: `neondb_owner`) | No |
|
|
213
|
+
|
|
214
|
+
### Command Line Options
|
|
215
|
+
|
|
216
|
+
| Option | Description | Default |
|
|
217
|
+
|--------|-------------|---------|
|
|
218
|
+
| `--neon-api-key` | Neon API key | `NEON_API_KEY` env |
|
|
219
|
+
| `--neon-project-id` | Neon project ID | `NEON_PROJECT_ID` env |
|
|
220
|
+
| `--neon-parent-branch` | Parent branch ID | Project default |
|
|
221
|
+
| `--neon-database` | Database name | `neondb` |
|
|
222
|
+
| `--neon-role` | Database role | `neondb_owner` |
|
|
223
|
+
| `--neon-keep-branches` | Don't delete branches after tests | `false` |
|
|
224
|
+
| `--neon-branch-expiry` | Branch auto-expiry in seconds | `600` (10 min) |
|
|
225
|
+
| `--neon-env-var` | Environment variable for connection string | `DATABASE_URL` |
|
|
226
|
+
|
|
227
|
+
Examples:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
# Keep branches for debugging
|
|
231
|
+
pytest --neon-keep-branches
|
|
232
|
+
|
|
233
|
+
# Disable auto-expiry
|
|
234
|
+
pytest --neon-branch-expiry=0
|
|
235
|
+
|
|
236
|
+
# Use a different env var
|
|
237
|
+
pytest --neon-env-var=TEST_DATABASE_URL
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## CI/CD Integration
|
|
241
|
+
|
|
242
|
+
### GitHub Actions
|
|
243
|
+
|
|
244
|
+
```yaml
|
|
245
|
+
name: Tests
|
|
246
|
+
|
|
247
|
+
on: [push, pull_request]
|
|
248
|
+
|
|
249
|
+
jobs:
|
|
250
|
+
test:
|
|
251
|
+
runs-on: ubuntu-latest
|
|
252
|
+
steps:
|
|
253
|
+
- uses: actions/checkout@v4
|
|
254
|
+
- uses: actions/setup-python@v5
|
|
255
|
+
with:
|
|
256
|
+
python-version: '3.12'
|
|
257
|
+
|
|
258
|
+
- name: Install dependencies
|
|
259
|
+
run: pip install -e .[psycopg,dev]
|
|
260
|
+
|
|
261
|
+
- name: Run tests
|
|
262
|
+
env:
|
|
263
|
+
NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
|
|
264
|
+
NEON_PROJECT_ID: ${{ secrets.NEON_PROJECT_ID }}
|
|
265
|
+
run: pytest
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## How It Works
|
|
269
|
+
|
|
270
|
+
1. Before each test module, the plugin creates a new Neon branch from your parent branch
|
|
271
|
+
2. `DATABASE_URL` is set to point to the new branch
|
|
272
|
+
3. Tests run against this isolated branch with full access to your schema and data
|
|
273
|
+
4. After each test, the branch is reset to its parent state (~0.5s)
|
|
274
|
+
5. After all tests in the module complete, the branch is deleted
|
|
275
|
+
6. As a safety net, branches auto-expire after 10 minutes even if cleanup fails
|
|
276
|
+
|
|
277
|
+
Branches use copy-on-write storage, so you only pay for data that differs from the parent branch.
|
|
278
|
+
|
|
279
|
+
## Troubleshooting
|
|
280
|
+
|
|
281
|
+
### "psycopg not installed" or "psycopg2 not installed"
|
|
282
|
+
|
|
283
|
+
The convenience fixtures require their respective drivers. Install the appropriate extra:
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
# For neon_connection_psycopg fixture
|
|
287
|
+
pip install pytest-neon[psycopg]
|
|
288
|
+
|
|
289
|
+
# For neon_connection fixture
|
|
290
|
+
pip install pytest-neon[psycopg2]
|
|
291
|
+
|
|
292
|
+
# For neon_engine fixture
|
|
293
|
+
pip install pytest-neon[sqlalchemy]
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Or use the core `neon_branch` fixture with your own driver:
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
def test_example(neon_branch):
|
|
300
|
+
import my_preferred_driver
|
|
301
|
+
conn = my_preferred_driver.connect(neon_branch.connection_string)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### "Neon API key not configured"
|
|
305
|
+
|
|
306
|
+
Set the `NEON_API_KEY` environment variable or use the `--neon-api-key` CLI option.
|
|
307
|
+
|
|
308
|
+
### "Neon project ID not configured"
|
|
309
|
+
|
|
310
|
+
Set the `NEON_PROJECT_ID` environment variable or use the `--neon-project-id` CLI option.
|
|
311
|
+
|
|
312
|
+
## License
|
|
313
|
+
|
|
314
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pytest_neon/__init__.py,sha256=3dk41NZmdfWWpfqyf8I1T7UsMbt3hjlRMPrfC1k_W5Y,398
|
|
2
|
+
pytest_neon/plugin.py,sha256=-7NHdQ-nqfR-ZQXFmLBu_vV445LTLJ44UXnkQPtCgOQ,17217
|
|
3
|
+
pytest_neon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
pytest_neon-0.2.0.dist-info/METADATA,sha256=xremaYd4xWifbUUMB88IdltsK5d_wsQmZq_zDpOrxkw,9496
|
|
5
|
+
pytest_neon-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
6
|
+
pytest_neon-0.2.0.dist-info/entry_points.txt,sha256=5U88Idj_G8-PSDb9VF3OwYFbGLHnGOo_GxgYvi0dtXw,37
|
|
7
|
+
pytest_neon-0.2.0.dist-info/licenses/LICENSE,sha256=aKKp_Ex4WBHTByY4BhXJ181dzB_qYhi2pCUmZ7Spn_0,1067
|
|
8
|
+
pytest_neon-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Zain Rizvi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|