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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ neon = pytest_neon.plugin
@@ -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.