pytest-neon 0.5.0__tar.gz → 0.6.0__tar.gz

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.
Files changed (24) hide show
  1. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/.github/workflows/release.yml +23 -0
  2. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/.github/workflows/tests.yml +12 -2
  3. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/CLAUDE.md +30 -2
  4. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/PKG-INFO +70 -10
  5. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/README.md +63 -6
  6. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/pyproject.toml +7 -4
  7. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/src/pytest_neon/__init__.py +1 -1
  8. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/src/pytest_neon/plugin.py +173 -21
  9. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/tests/test_integration.py +65 -0
  10. pytest_neon-0.6.0/tests/test_migrations.py +156 -0
  11. pytest_neon-0.5.0/tests/test_migrations.py +0 -77
  12. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/.env.example +0 -0
  13. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/.gitignore +0 -0
  14. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/.neon +0 -0
  15. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/LICENSE +0 -0
  16. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/src/pytest_neon/py.typed +0 -0
  17. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/tests/conftest.py +0 -0
  18. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/tests/test_branch_lifecycle.py +0 -0
  19. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/tests/test_cli_options.py +0 -0
  20. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/tests/test_env_var.py +0 -0
  21. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/tests/test_fixture_errors.py +0 -0
  22. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/tests/test_reset_behavior.py +0 -0
  23. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/tests/test_skip_behavior.py +0 -0
  24. {pytest_neon-0.5.0 → pytest_neon-0.6.0}/uv.lock +0 -0
@@ -23,6 +23,29 @@ jobs:
23
23
  with:
24
24
  token: ${{ secrets.GITHUB_TOKEN }}
25
25
 
26
+ - name: Check CI status
27
+ env:
28
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
29
+ REPO: ${{ github.repository }}
30
+ run: |
31
+ SHA=$(git rev-parse HEAD)
32
+ echo "Checking CI status for commit: $SHA"
33
+
34
+ # Get check runs excluding this release workflow
35
+ FAILED=$(gh api "repos/$REPO/commits/$SHA/check-runs" \
36
+ --jq '[.check_runs[] | select(.name != "release") | select(.conclusion != "success" and .conclusion != "skipped")] | length')
37
+
38
+ if [ "$FAILED" != "0" ]; then
39
+ echo "❌ CI checks have not passed on this commit"
40
+ echo ""
41
+ echo "Check runs:"
42
+ gh api "repos/$REPO/commits/$SHA/check-runs" \
43
+ --jq '.check_runs[] | select(.name != "release") | " - \(.name): \(.conclusion // .status)"'
44
+ exit 1
45
+ fi
46
+
47
+ echo "✅ All CI checks passed"
48
+
26
49
  - name: Set up Python
27
50
  uses: actions/setup-python@v5
28
51
  with:
@@ -3,8 +3,18 @@ name: Tests
3
3
  on:
4
4
  push:
5
5
  branches: [main]
6
+ paths:
7
+ - "src/**"
8
+ - "tests/**"
9
+ - "pyproject.toml"
10
+ - ".github/workflows/tests.yml"
6
11
  pull_request:
7
12
  branches: [main]
13
+ paths:
14
+ - "src/**"
15
+ - "tests/**"
16
+ - "pyproject.toml"
17
+ - ".github/workflows/tests.yml"
8
18
 
9
19
  jobs:
10
20
  test:
@@ -25,7 +35,7 @@ jobs:
25
35
  - name: Install dependencies
26
36
  run: |
27
37
  python -m pip install --upgrade pip
28
- pip install -e ".[dev,psycopg]"
38
+ pip install -e ".[dev]"
29
39
 
30
40
  - name: Run unit tests
31
41
  run: pytest tests/ --ignore=tests/test_integration.py -v
@@ -46,7 +56,7 @@ jobs:
46
56
  - name: Install dependencies
47
57
  run: |
48
58
  python -m pip install --upgrade pip
49
- pip install -e ".[dev,psycopg]"
59
+ pip install -e ".[dev]"
50
60
 
51
61
  - name: Run integration tests
52
62
  env:
@@ -13,7 +13,7 @@ This is a pytest plugin that provides isolated Neon database branches for integr
13
13
  - **Entry point**: `src/pytest_neon/plugin.py` - Contains all fixtures and pytest hooks
14
14
  - **Migration fixture**: `_neon_migration_branch` - Session-scoped, parent for all test branches
15
15
  - **User migration hook**: `neon_apply_migrations` - Session-scoped no-op, users override to run migrations
16
- - **Core fixture**: `neon_branch` - Creates branch (module-scoped), resets after each test (function-scoped wrapper), sets `DATABASE_URL`, yields `NeonBranch` dataclass
16
+ - **Core fixture**: `neon_branch` - Creates branch (session-scoped), resets after each test (function-scoped wrapper), sets `DATABASE_URL`, yields `NeonBranch` dataclass
17
17
  - **Shared fixture**: `neon_branch_shared` - Module-scoped, no reset between tests
18
18
  - **Convenience fixtures**: `neon_connection`, `neon_connection_psycopg`, `neon_engine` - Optional, require extras
19
19
 
@@ -27,7 +27,7 @@ This is a pytest plugin that provides isolated Neon database branches for integr
27
27
  ### Fixture Scopes
28
28
  - `_neon_migration_branch`: `scope="session"` - internal, parent for all test branches, migrations run here
29
29
  - `neon_apply_migrations`: `scope="session"` - user overrides to run migrations
30
- - `_neon_branch_for_reset`: `scope="module"` - internal, creates one branch per test file from migration branch
30
+ - `_neon_branch_for_reset`: `scope="session"` - internal, creates one branch per session from migration branch
31
31
  - `neon_branch`: `scope="function"` - wraps the above, resets branch after each test
32
32
  - `neon_branch_shared`: `scope="module"` - one branch per test file, no reset
33
33
  - Connection fixtures: `scope="function"` (default) - fresh connection per test
@@ -35,15 +35,43 @@ This is a pytest plugin that provides isolated Neon database branches for integr
35
35
  ### Environment Variable Handling
36
36
  The `_create_neon_branch` function sets `DATABASE_URL` (or configured env var) during the fixture lifecycle and restores the original value in the finally block. This is critical for not polluting other tests.
37
37
 
38
+ ### Smart Migration Detection (Cost Optimization)
39
+ The plugin avoids creating unnecessary branches through a two-layer detection strategy:
40
+
41
+ 1. **Sentinel detection**: If `neon_apply_migrations` is not overridden, it returns `_MIGRATIONS_NOT_DEFINED` sentinel. No child branch is created.
42
+
43
+ 2. **Schema fingerprint comparison**: If migrations are defined, the plugin captures `information_schema.columns` before migrations run and compares after. Only creates a child branch if the schema actually changed.
44
+
45
+ **Design philosophy**: Users who define a migration fixture but rarely have actual pending migrations shouldn't pay for an extra branch every test run. The schema fingerprint approach detects actual changes, not just "migration code ran."
46
+
47
+ **Implementation notes**:
48
+ - Pre-migration fingerprint is captured in `_neon_migration_branch` and stored on `request.config`
49
+ - Post-migration comparison happens in `_neon_branch_for_reset`
50
+ - Falls back to assuming changes if no psycopg/psycopg2 is available for fingerprinting
51
+ - Only checks schema (tables, columns), not data - this is intentional since seeding is not the use case
52
+
38
53
  ### Error Messages
39
54
  Convenience fixtures use `pytest.fail()` with detailed, formatted error messages when dependencies are missing. Keep this pattern - users need clear guidance on how to fix import errors.
40
55
 
56
+ ## Documentation
57
+
58
+ Important help text should be documented in BOTH:
59
+ 1. **README.md** - Full user-facing documentation
60
+ 2. **Module/fixture docstrings** - So `help(pytest_neon)` shows useful info
61
+
62
+ The module docstring in `plugin.py` should include key usage notes (like the SQLAlchemy `pool_pre_ping=True` requirement). Keep docstrings and README in sync.
63
+
41
64
  ## Commit Messages
42
65
  - Do NOT add Claude attribution or Co-Authored-By lines
43
66
  - Keep commits clean and descriptive
44
67
 
45
68
  ## Testing
46
69
 
70
+ Run tests with:
71
+ ```bash
72
+ uv run pytest tests/ -v
73
+ ```
74
+
47
75
  Tests in `tests/` use `pytester` for testing pytest plugins. The plugin itself can be tested without a real Neon connection by mocking `NeonAPI`.
48
76
 
49
77
  ## Publishing
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-neon
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
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
5
+ Project-URL: Homepage, https://github.com/ZainRizvi/pytest-neon
6
+ Project-URL: Repository, https://github.com/ZainRizvi/pytest-neon
7
+ Project-URL: Issues, https://github.com/ZainRizvi/pytest-neon/issues
8
8
  Author: Zain Rizvi
9
9
  License-Expression: MIT
10
10
  License-File: LICENSE
@@ -29,9 +29,12 @@ Requires-Dist: pytest>=7.0
29
29
  Requires-Dist: requests>=2.20
30
30
  Provides-Extra: dev
31
31
  Requires-Dist: mypy>=1.0; extra == 'dev'
32
+ Requires-Dist: psycopg2-binary>=2.9; extra == 'dev'
33
+ Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
32
34
  Requires-Dist: pytest-cov>=4.0; extra == 'dev'
33
35
  Requires-Dist: pytest-mock>=3.0; extra == 'dev'
34
36
  Requires-Dist: ruff>=0.8; extra == 'dev'
37
+ Requires-Dist: sqlalchemy>=2.0; extra == 'dev'
35
38
  Provides-Extra: psycopg
36
39
  Requires-Dist: psycopg[binary]>=3.1; extra == 'psycopg'
37
40
  Provides-Extra: psycopg2
@@ -42,6 +45,8 @@ Description-Content-Type: text/markdown
42
45
 
43
46
  # pytest-neon
44
47
 
48
+ [![Tests](https://github.com/ZainRizvi/pytest-neon/actions/workflows/tests.yml/badge.svg)](https://github.com/ZainRizvi/pytest-neon/actions/workflows/tests.yml)
49
+
45
50
  Pytest plugin for [Neon](https://neon.tech) database branch isolation in tests.
46
51
 
47
52
  Each test gets its own isolated database state via Neon's instant branching and reset features. Branches are automatically cleaned up after tests complete.
@@ -112,7 +117,7 @@ pytest
112
117
 
113
118
  ### `neon_branch` (default, recommended)
114
119
 
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.
120
+ The primary fixture for database testing. Creates one branch per test session, then resets it to the parent branch's state after each test. This provides test isolation with ~0.5s overhead per test.
116
121
 
117
122
  Returns a `NeonBranch` dataclass with:
118
123
 
@@ -134,7 +139,7 @@ def test_branch_info(neon_branch):
134
139
  conn = psycopg.connect(neon_branch.connection_string)
135
140
  ```
136
141
 
137
- **Performance**: ~1.5s initial setup per module + ~0.5s reset per test. For a module with 10 tests, expect ~6.5s total overhead.
142
+ **Performance**: ~1.5s initial setup per session + ~0.5s reset per test. For 10 tests, expect ~6.5s total overhead.
138
143
 
139
144
  ### `neon_branch_shared` (fastest, no isolation)
140
145
 
@@ -200,22 +205,65 @@ def test_query(neon_engine):
200
205
  assert result.scalar() == 1
201
206
  ```
202
207
 
208
+ ### Using Your Own SQLAlchemy Engine
209
+
210
+ If you have a module-level SQLAlchemy engine (common pattern), you **must** use `pool_pre_ping=True`:
211
+
212
+ ```python
213
+ # database.py
214
+ from sqlalchemy import create_engine
215
+ from config import DATABASE_URL
216
+
217
+ # pool_pre_ping=True is REQUIRED for pytest-neon
218
+ # It verifies connections are alive before using them
219
+ engine = create_engine(DATABASE_URL, pool_pre_ping=True)
220
+ ```
221
+
222
+ **Why?** After each test, pytest-neon resets the branch which terminates server-side connections. Without `pool_pre_ping`, SQLAlchemy may try to reuse a dead pooled connection, causing `SSL connection has been closed unexpectedly` errors.
223
+
224
+ This is also a best practice for any cloud database (Neon, RDS, etc.) where connections can be terminated externally.
225
+
203
226
  ## Migrations
204
227
 
205
228
  pytest-neon supports running migrations once before tests, with all test resets preserving the migrated state.
206
229
 
230
+ ### Smart Migration Detection
231
+
232
+ The plugin automatically detects whether migrations actually modified the database schema. This optimization:
233
+
234
+ - **Saves Neon costs**: No extra branch created when migrations don't change anything
235
+ - **Saves branch slots**: Neon projects have branch limits; this avoids wasting them
236
+ - **Zero configuration**: Works automatically with any migration tool
237
+
238
+ **When a second branch is created:**
239
+ - Only when `neon_apply_migrations` is overridden AND the schema actually changes
240
+
241
+ **When only one branch is used:**
242
+ - If you don't override `neon_apply_migrations` (no migrations defined)
243
+ - If your migrations are already applied (schema unchanged)
244
+
245
+ The detection works by comparing a fingerprint of `information_schema.columns` before and after migrations run.
246
+
207
247
  ### How It Works
208
248
 
209
- When you override the `neon_apply_migrations` fixture, the plugin uses a two-branch architecture:
249
+ When migrations actually modify the schema, the plugin uses a two-branch architecture:
210
250
 
211
251
  ```
212
252
  Parent Branch (your configured parent)
213
253
  └── Migration Branch (session-scoped)
214
254
  │ ↑ migrations run here ONCE
215
- └── Test Branch (module-scoped)
255
+ └── Test Branch (session-scoped)
216
256
  ↑ resets to migration branch after each test
217
257
  ```
218
258
 
259
+ When no schema changes occur, the plugin uses a single-branch architecture:
260
+
261
+ ```
262
+ Parent Branch (your configured parent)
263
+ └── Migration/Test Branch (session-scoped)
264
+ ↑ resets to parent after each test
265
+ ```
266
+
219
267
  This means:
220
268
  - Migrations run **once per test session** (not per test or per module)
221
269
  - Each test reset restores to the **post-migration state**
@@ -376,11 +424,11 @@ jobs:
376
424
 
377
425
  ## How It Works
378
426
 
379
- 1. Before each test module, the plugin creates a new Neon branch from your parent branch
427
+ 1. At the start of the test session, the plugin creates a new Neon branch from your parent branch
380
428
  2. `DATABASE_URL` is set to point to the new branch
381
429
  3. Tests run against this isolated branch with full access to your schema and data
382
430
  4. After each test, the branch is reset to its parent state (~0.5s)
383
- 5. After all tests in the module complete, the branch is deleted
431
+ 5. After all tests complete, the branch is deleted
384
432
  6. As a safety net, branches auto-expire after 10 minutes even if cleanup fails
385
433
 
386
434
  Branches use copy-on-write storage, so you only pay for data that differs from the parent branch.
@@ -440,6 +488,18 @@ Set the `NEON_API_KEY` environment variable or use the `--neon-api-key` CLI opti
440
488
 
441
489
  Set the `NEON_PROJECT_ID` environment variable or use the `--neon-project-id` CLI option.
442
490
 
491
+ ### "SSL connection has been closed unexpectedly" (SQLAlchemy)
492
+
493
+ This happens when SQLAlchemy tries to reuse a pooled connection after a branch reset. The reset terminates server-side connections, but SQLAlchemy's pool doesn't know.
494
+
495
+ **Fix:** Add `pool_pre_ping=True` to your engine:
496
+
497
+ ```python
498
+ engine = create_engine(DATABASE_URL, pool_pre_ping=True)
499
+ ```
500
+
501
+ This makes SQLAlchemy verify connections before using them, automatically discarding stale ones.
502
+
443
503
  ## License
444
504
 
445
505
  MIT
@@ -1,5 +1,7 @@
1
1
  # pytest-neon
2
2
 
3
+ [![Tests](https://github.com/ZainRizvi/pytest-neon/actions/workflows/tests.yml/badge.svg)](https://github.com/ZainRizvi/pytest-neon/actions/workflows/tests.yml)
4
+
3
5
  Pytest plugin for [Neon](https://neon.tech) database branch isolation in tests.
4
6
 
5
7
  Each test gets its own isolated database state via Neon's instant branching and reset features. Branches are automatically cleaned up after tests complete.
@@ -70,7 +72,7 @@ pytest
70
72
 
71
73
  ### `neon_branch` (default, recommended)
72
74
 
73
- 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.
75
+ The primary fixture for database testing. Creates one branch per test session, then resets it to the parent branch's state after each test. This provides test isolation with ~0.5s overhead per test.
74
76
 
75
77
  Returns a `NeonBranch` dataclass with:
76
78
 
@@ -92,7 +94,7 @@ def test_branch_info(neon_branch):
92
94
  conn = psycopg.connect(neon_branch.connection_string)
93
95
  ```
94
96
 
95
- **Performance**: ~1.5s initial setup per module + ~0.5s reset per test. For a module with 10 tests, expect ~6.5s total overhead.
97
+ **Performance**: ~1.5s initial setup per session + ~0.5s reset per test. For 10 tests, expect ~6.5s total overhead.
96
98
 
97
99
  ### `neon_branch_shared` (fastest, no isolation)
98
100
 
@@ -158,22 +160,65 @@ def test_query(neon_engine):
158
160
  assert result.scalar() == 1
159
161
  ```
160
162
 
163
+ ### Using Your Own SQLAlchemy Engine
164
+
165
+ If you have a module-level SQLAlchemy engine (common pattern), you **must** use `pool_pre_ping=True`:
166
+
167
+ ```python
168
+ # database.py
169
+ from sqlalchemy import create_engine
170
+ from config import DATABASE_URL
171
+
172
+ # pool_pre_ping=True is REQUIRED for pytest-neon
173
+ # It verifies connections are alive before using them
174
+ engine = create_engine(DATABASE_URL, pool_pre_ping=True)
175
+ ```
176
+
177
+ **Why?** After each test, pytest-neon resets the branch which terminates server-side connections. Without `pool_pre_ping`, SQLAlchemy may try to reuse a dead pooled connection, causing `SSL connection has been closed unexpectedly` errors.
178
+
179
+ This is also a best practice for any cloud database (Neon, RDS, etc.) where connections can be terminated externally.
180
+
161
181
  ## Migrations
162
182
 
163
183
  pytest-neon supports running migrations once before tests, with all test resets preserving the migrated state.
164
184
 
185
+ ### Smart Migration Detection
186
+
187
+ The plugin automatically detects whether migrations actually modified the database schema. This optimization:
188
+
189
+ - **Saves Neon costs**: No extra branch created when migrations don't change anything
190
+ - **Saves branch slots**: Neon projects have branch limits; this avoids wasting them
191
+ - **Zero configuration**: Works automatically with any migration tool
192
+
193
+ **When a second branch is created:**
194
+ - Only when `neon_apply_migrations` is overridden AND the schema actually changes
195
+
196
+ **When only one branch is used:**
197
+ - If you don't override `neon_apply_migrations` (no migrations defined)
198
+ - If your migrations are already applied (schema unchanged)
199
+
200
+ The detection works by comparing a fingerprint of `information_schema.columns` before and after migrations run.
201
+
165
202
  ### How It Works
166
203
 
167
- When you override the `neon_apply_migrations` fixture, the plugin uses a two-branch architecture:
204
+ When migrations actually modify the schema, the plugin uses a two-branch architecture:
168
205
 
169
206
  ```
170
207
  Parent Branch (your configured parent)
171
208
  └── Migration Branch (session-scoped)
172
209
  │ ↑ migrations run here ONCE
173
- └── Test Branch (module-scoped)
210
+ └── Test Branch (session-scoped)
174
211
  ↑ resets to migration branch after each test
175
212
  ```
176
213
 
214
+ When no schema changes occur, the plugin uses a single-branch architecture:
215
+
216
+ ```
217
+ Parent Branch (your configured parent)
218
+ └── Migration/Test Branch (session-scoped)
219
+ ↑ resets to parent after each test
220
+ ```
221
+
177
222
  This means:
178
223
  - Migrations run **once per test session** (not per test or per module)
179
224
  - Each test reset restores to the **post-migration state**
@@ -334,11 +379,11 @@ jobs:
334
379
 
335
380
  ## How It Works
336
381
 
337
- 1. Before each test module, the plugin creates a new Neon branch from your parent branch
382
+ 1. At the start of the test session, the plugin creates a new Neon branch from your parent branch
338
383
  2. `DATABASE_URL` is set to point to the new branch
339
384
  3. Tests run against this isolated branch with full access to your schema and data
340
385
  4. After each test, the branch is reset to its parent state (~0.5s)
341
- 5. After all tests in the module complete, the branch is deleted
386
+ 5. After all tests complete, the branch is deleted
342
387
  6. As a safety net, branches auto-expire after 10 minutes even if cleanup fails
343
388
 
344
389
  Branches use copy-on-write storage, so you only pay for data that differs from the parent branch.
@@ -398,6 +443,18 @@ Set the `NEON_API_KEY` environment variable or use the `--neon-api-key` CLI opti
398
443
 
399
444
  Set the `NEON_PROJECT_ID` environment variable or use the `--neon-project-id` CLI option.
400
445
 
446
+ ### "SSL connection has been closed unexpectedly" (SQLAlchemy)
447
+
448
+ This happens when SQLAlchemy tries to reuse a pooled connection after a branch reset. The reset terminates server-side connections, but SQLAlchemy's pool doesn't know.
449
+
450
+ **Fix:** Add `pool_pre_ping=True` to your engine:
451
+
452
+ ```python
453
+ engine = create_engine(DATABASE_URL, pool_pre_ping=True)
454
+ ```
455
+
456
+ This makes SQLAlchemy verify connections before using them, automatically discarding stale ones.
457
+
401
458
  ## License
402
459
 
403
460
  MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-neon"
7
- version = "0.5.0"
7
+ version = "0.6.0"
8
8
  description = "Pytest plugin for Neon database branch isolation in tests"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -42,12 +42,15 @@ dev = [
42
42
  "mypy>=1.0",
43
43
  "pytest-cov>=4.0",
44
44
  "pytest-mock>=3.0",
45
+ "psycopg2-binary>=2.9",
46
+ "psycopg[binary]>=3.1",
47
+ "sqlalchemy>=2.0",
45
48
  ]
46
49
 
47
50
  [project.urls]
48
- Homepage = "https://github.com/zain/pytest-neon"
49
- Repository = "https://github.com/zain/pytest-neon"
50
- Issues = "https://github.com/zain/pytest-neon/issues"
51
+ Homepage = "https://github.com/ZainRizvi/pytest-neon"
52
+ Repository = "https://github.com/ZainRizvi/pytest-neon"
53
+ Issues = "https://github.com/ZainRizvi/pytest-neon/issues"
51
54
 
52
55
  [project.entry-points.pytest11]
53
56
  neon = "pytest_neon.plugin"
@@ -9,7 +9,7 @@ from pytest_neon.plugin import (
9
9
  neon_engine,
10
10
  )
11
11
 
12
- __version__ = "0.5.0"
12
+ __version__ = "0.6.0"
13
13
  __all__ = [
14
14
  "NeonBranch",
15
15
  "neon_branch",
@@ -1,7 +1,36 @@
1
- """Pytest plugin providing Neon database branch fixtures."""
1
+ """Pytest plugin providing Neon database branch fixtures.
2
+
3
+ This plugin provides fixtures for isolated database testing using Neon's
4
+ instant branching feature. Each test gets a clean database state via
5
+ branch reset after each test.
6
+
7
+ Main fixtures:
8
+ neon_branch: Primary fixture - one branch per session, reset after each test
9
+ neon_branch_shared: Shared branch without reset (fastest, no isolation)
10
+ neon_connection: psycopg2 connection (requires psycopg2 extra)
11
+ neon_connection_psycopg: psycopg v3 connection (requires psycopg extra)
12
+ neon_engine: SQLAlchemy engine (requires sqlalchemy extra)
13
+
14
+ SQLAlchemy Users:
15
+ If you create your own SQLAlchemy engine (not using neon_engine fixture),
16
+ you MUST use pool_pre_ping=True:
17
+
18
+ engine = create_engine(DATABASE_URL, pool_pre_ping=True)
19
+
20
+ This is required because branch resets terminate server-side connections.
21
+ Without pool_pre_ping, SQLAlchemy may try to reuse dead pooled connections,
22
+ causing "SSL connection has been closed unexpectedly" errors.
23
+
24
+ Configuration:
25
+ Set NEON_API_KEY and NEON_PROJECT_ID environment variables, or use
26
+ --neon-api-key and --neon-project-id CLI options.
27
+
28
+ For full documentation, see: https://github.com/ZainRizvi/pytest-neon
29
+ """
2
30
 
3
31
  from __future__ import annotations
4
32
 
33
+ import contextlib
5
34
  import os
6
35
  import time
7
36
  from collections.abc import Generator
@@ -17,6 +46,41 @@ from neon_api.schema import EndpointState
17
46
  # Default branch expiry in seconds (10 minutes)
18
47
  DEFAULT_BRANCH_EXPIRY_SECONDS = 600
19
48
 
49
+ # Sentinel value to detect when neon_apply_migrations was not overridden
50
+ _MIGRATIONS_NOT_DEFINED = object()
51
+
52
+
53
+ def _get_schema_fingerprint(connection_string: str) -> tuple[tuple[Any, ...], ...]:
54
+ """
55
+ Get a fingerprint of the database schema for change detection.
56
+
57
+ Queries information_schema for all tables, columns, and their properties
58
+ in the public schema. Returns a hashable tuple that can be compared
59
+ before/after migrations to detect if the schema actually changed.
60
+
61
+ This is used to avoid creating unnecessary migration branches when
62
+ no actual schema changes occurred.
63
+ """
64
+ try:
65
+ import psycopg
66
+ except ImportError:
67
+ try:
68
+ import psycopg2 as psycopg # type: ignore[import-not-found]
69
+ except ImportError:
70
+ # No driver available - can't fingerprint, assume migrations changed things
71
+ return ()
72
+
73
+ with psycopg.connect(connection_string) as conn, conn.cursor() as cur:
74
+ cur.execute("""
75
+ SELECT table_name, column_name, data_type, is_nullable,
76
+ column_default, ordinal_position
77
+ FROM information_schema.columns
78
+ WHERE table_schema = 'public'
79
+ ORDER BY table_name, ordinal_position
80
+ """)
81
+ rows = cur.fetchall()
82
+ return tuple(tuple(row) for row in rows)
83
+
20
84
 
21
85
  @dataclass
22
86
  class NeonBranch:
@@ -339,23 +403,47 @@ def _neon_migration_branch(
339
403
  Note: The migration branch cannot have an expiry because Neon doesn't
340
404
  allow creating child branches from branches with expiration dates.
341
405
  Cleanup relies on the fixture teardown at session end.
406
+
407
+ Smart Migration Detection:
408
+ Before yielding, this fixture captures a schema fingerprint and stores
409
+ it on request.config. After migrations run, _neon_branch_for_reset
410
+ compares the fingerprint to detect if the schema actually changed.
342
411
  """
343
412
  # No expiry - Neon doesn't allow children from branches with expiry
344
- yield from _create_neon_branch(
413
+ branch_gen = _create_neon_branch(
345
414
  request,
346
415
  branch_expiry_override=0,
347
416
  branch_name_suffix="-migrated",
348
417
  )
418
+ branch = next(branch_gen)
419
+
420
+ # Capture schema fingerprint BEFORE migrations run
421
+ # This is stored on config so _neon_branch_for_reset can compare after
422
+ pre_migration_fingerprint = _get_schema_fingerprint(branch.connection_string)
423
+ request.config._neon_pre_migration_fingerprint = pre_migration_fingerprint # type: ignore[attr-defined]
424
+
425
+ try:
426
+ yield branch
427
+ finally:
428
+ # Clean up by exhausting the generator (triggers branch deletion)
429
+ with contextlib.suppress(StopIteration):
430
+ next(branch_gen)
349
431
 
350
432
 
351
433
  @pytest.fixture(scope="session")
352
- def neon_apply_migrations(_neon_migration_branch: NeonBranch) -> None:
434
+ def neon_apply_migrations(_neon_migration_branch: NeonBranch) -> Any:
353
435
  """
354
436
  Override this fixture to run migrations on the test database.
355
437
 
356
438
  The migration branch is already created and DATABASE_URL is set.
357
439
  Migrations run once per test session, before any tests execute.
358
440
 
441
+ Smart Migration Detection:
442
+ The plugin automatically detects whether migrations actually modified
443
+ the database schema. If no schema changes occurred (or this fixture
444
+ isn't overridden), the plugin skips creating a separate migration
445
+ branch, saving Neon costs and branch slots.
446
+
359
447
  Example in conftest.py:
360
448
 
361
449
  @pytest.fixture(scope="session")
@@ -384,27 +472,69 @@ def neon_apply_migrations(_neon_migration_branch: NeonBranch) -> None:
384
472
  _neon_migration_branch: The migration branch with connection details.
385
473
  Use _neon_migration_branch.connection_string to connect directly,
386
474
  or rely on DATABASE_URL which is already set.
475
+
476
+ Returns:
477
+ Any value (ignored). The default returns a sentinel to indicate
478
+ the fixture was not overridden.
387
479
  """
388
- pass # No-op by default - users override this fixture to run migrations
480
+ return _MIGRATIONS_NOT_DEFINED
389
481
 
390
482
 
391
- @pytest.fixture(scope="module")
483
+ @pytest.fixture(scope="session")
392
484
  def _neon_branch_for_reset(
393
485
  request: pytest.FixtureRequest,
394
486
  _neon_migration_branch: NeonBranch,
395
- neon_apply_migrations: None, # Ensures migrations run first
487
+ neon_apply_migrations: Any, # Ensures migrations run first; value for detection
396
488
  ) -> Generator[NeonBranch, None, None]:
397
489
  """
398
490
  Internal fixture that creates a test branch from the migration branch.
399
491
 
400
- The test branch is created as a child of the migration branch, so resets
401
- restore to post-migration state rather than the original parent state.
492
+ This is session-scoped so DATABASE_URL remains stable throughout the test
493
+ session, avoiding issues with Python's module caching (e.g., SQLAlchemy
494
+ engines created at import time would otherwise point to stale branches).
495
+
496
+ Smart Migration Detection:
497
+ This fixture implements a cost-optimization strategy:
498
+
499
+ 1. If neon_apply_migrations was not overridden (returns sentinel),
500
+ skip creating a separate test branch - use the migration branch directly.
501
+
502
+ 2. If neon_apply_migrations was overridden, compare schema fingerprints
503
+ before/after migrations. Only create a child branch if the schema
504
+ actually changed.
505
+
506
+ This avoids unnecessary Neon costs and branch slots when:
507
+ - No migration fixture is defined
508
+ - Migrations exist but are already applied (no schema changes)
402
509
  """
403
- yield from _create_neon_branch(
404
- request,
405
- parent_branch_id_override=_neon_migration_branch.branch_id,
406
- branch_name_suffix="-test",
407
- )
510
+ # Check if migrations fixture was overridden
511
+ migrations_defined = neon_apply_migrations is not _MIGRATIONS_NOT_DEFINED
512
+
513
+ # Check if schema actually changed (if we have a pre-migration fingerprint)
514
+ pre_fingerprint = getattr(request.config, "_neon_pre_migration_fingerprint", ())
515
+ schema_changed = False
516
+
517
+ if migrations_defined and pre_fingerprint:
518
+ # Compare with current schema
519
+ conn_str = _neon_migration_branch.connection_string
520
+ post_fingerprint = _get_schema_fingerprint(conn_str)
521
+ schema_changed = pre_fingerprint != post_fingerprint
522
+ elif migrations_defined and not pre_fingerprint:
523
+ # No fingerprint available (no psycopg/psycopg2 installed)
524
+ # Assume migrations changed something to be safe
525
+ schema_changed = True
526
+
527
+ # Only create a child branch if migrations actually modified the schema
528
+ if schema_changed:
529
+ yield from _create_neon_branch(
530
+ request,
531
+ parent_branch_id_override=_neon_migration_branch.branch_id,
532
+ branch_name_suffix="-test",
533
+ )
534
+ else:
535
+ # No schema changes - reuse the migration branch directly
536
+ # This saves creating an unnecessary branch
537
+ yield _neon_migration_branch
408
538
 
409
539
 
410
540
  @pytest.fixture(scope="function")
@@ -416,25 +546,35 @@ def neon_branch(
416
546
  Provide an isolated Neon database branch for each test.
417
547
 
418
548
  This is the primary fixture for database testing. It creates one branch per
419
- test module, then resets it to the parent branch's state after each test.
549
+ test session, then resets it to the parent branch's state after each test.
420
550
  This provides test isolation with ~0.5s overhead per test.
421
551
 
422
- The branch is automatically deleted after all tests in the module complete,
423
- unless --neon-keep-branches is specified. Branches also auto-expire after
552
+ The branch is automatically deleted after all tests complete, unless
553
+ --neon-keep-branches is specified. Branches also auto-expire after
424
554
  10 minutes by default (configurable via --neon-branch-expiry) as a safety net
425
555
  for interrupted test runs.
426
556
 
427
557
  The connection string is automatically set in the DATABASE_URL environment
428
558
  variable (configurable via --neon-env-var).
429
559
 
560
+ SQLAlchemy Users:
561
+ If you create your own engine (not using the neon_engine fixture),
562
+ you MUST use pool_pre_ping=True::
563
+
564
+ engine = create_engine(DATABASE_URL, pool_pre_ping=True)
565
+
566
+ Branch resets terminate server-side connections. Without pool_pre_ping,
567
+ SQLAlchemy may reuse dead pooled connections, causing SSL errors.
568
+
430
569
  Requires either:
431
- - NEON_API_KEY and NEON_PROJECT_ID environment variables, or
432
- - --neon-api-key and --neon-project-id command line options
570
+ - NEON_API_KEY and NEON_PROJECT_ID environment variables, or
571
+ - --neon-api-key and --neon-project-id command line options
433
572
 
434
573
  Yields:
435
574
  NeonBranch: Object with branch_id, project_id, connection_string, and host.
436
575
 
437
- Example:
576
+ Example::
577
+
438
578
  def test_database_operation(neon_branch):
439
579
  # DATABASE_URL is automatically set
440
580
  conn_string = os.environ["DATABASE_URL"]
@@ -602,12 +742,24 @@ def neon_engine(neon_branch: NeonBranch):
602
742
  Requires the sqlalchemy optional dependency:
603
743
  pip install pytest-neon[sqlalchemy]
604
744
 
605
- The engine is disposed after each test.
745
+ The engine is disposed after each test, which handles stale connections
746
+ after branch resets automatically.
747
+
748
+ Note:
749
+ If you create your own module-level engine instead of using this
750
+ fixture, you MUST use pool_pre_ping=True::
751
+
752
+ engine = create_engine(DATABASE_URL, pool_pre_ping=True)
753
+
754
+ This is required because branch resets terminate server-side
755
+ connections, and without pool_pre_ping SQLAlchemy may reuse dead
756
+ pooled connections.
606
757
 
607
758
  Yields:
608
759
  SQLAlchemy Engine object
609
760
 
610
- Example:
761
+ Example::
762
+
611
763
  def test_query(neon_engine):
612
764
  with neon_engine.connect() as conn:
613
765
  result = conn.execute(text("SELECT 1"))
@@ -206,3 +206,68 @@ class TestRealDatabaseConnectivity:
206
206
  )
207
207
  result = cur.fetchone()
208
208
  assert result[0] == "test_value"
209
+
210
+
211
+ class TestSQLAlchemyPooledConnections:
212
+ """Test SQLAlchemy connection pooling behavior with branch resets.
213
+
214
+ Branch resets terminate server-side connections. SQLAlchemy's connection
215
+ pool doesn't know this, so pooled connections become stale.
216
+
217
+ Solution: Use pool_pre_ping=True (recommended for any cloud database).
218
+ This pings connections before use and discards stale ones automatically.
219
+ """
220
+
221
+ def test_pool_pre_ping_handles_stale_connections(self, pytester):
222
+ """
223
+ Verify that pool_pre_ping=True handles stale connections after reset.
224
+
225
+ Pattern:
226
+ 1. database.py creates engine with pool_pre_ping=True at import time
227
+ 2. test_one uses it, connection goes to pool
228
+ 3. Branch resets after test_one (server terminates connection)
229
+ 4. test_two gets pooled connection, pool_pre_ping detects it's dead,
230
+ automatically gets fresh connection
231
+ """
232
+ pytester.makepyfile(
233
+ database="""
234
+ import os
235
+ from sqlalchemy import create_engine
236
+
237
+ DATABASE_URL = os.environ.get("DATABASE_URL")
238
+ # pool_pre_ping=True is REQUIRED for pytest-neon (and recommended for any cloud DB)
239
+ # It verifies connections are alive before using them
240
+ engine = create_engine(DATABASE_URL, pool_pre_ping=True) if DATABASE_URL else None
241
+ """
242
+ )
243
+
244
+ pytester.makepyfile(
245
+ test_sqlalchemy_reset="""
246
+ from sqlalchemy import text
247
+
248
+ def test_first_query(neon_branch):
249
+ '''First test - creates pooled connection.'''
250
+ from database import engine
251
+ with engine.connect() as conn:
252
+ result = conn.execute(text("SELECT 1"))
253
+ assert result.scalar() == 1
254
+ # Connection returned to pool
255
+
256
+ def test_second_query_after_reset(neon_branch):
257
+ '''Second test - branch was reset, but pool_pre_ping handles it.'''
258
+ from database import engine
259
+ with engine.connect() as conn:
260
+ result = conn.execute(text("SELECT 2"))
261
+ assert result.scalar() == 2
262
+
263
+ def test_third_query_after_another_reset(neon_branch):
264
+ '''Third test - still works thanks to pool_pre_ping.'''
265
+ from database import engine
266
+ with engine.connect() as conn:
267
+ result = conn.execute(text("SELECT 3"))
268
+ assert result.scalar() == 3
269
+ """
270
+ )
271
+
272
+ result = pytester.runpytest("-v")
273
+ result.assert_outcomes(passed=3)
@@ -0,0 +1,156 @@
1
+ """Tests for migration support."""
2
+
3
+ import inspect
4
+
5
+ from pytest_neon.plugin import _MIGRATIONS_NOT_DEFINED, neon_apply_migrations
6
+
7
+
8
+ class TestSmartMigrationDetection:
9
+ """Test the sentinel-based detection for skipping unnecessary branches."""
10
+
11
+ def test_sentinel_returned_when_migrations_not_overridden(self):
12
+ """Default neon_apply_migrations returns sentinel to signal no override."""
13
+ # Get the default implementation's return behavior from source
14
+ source = inspect.getsource(neon_apply_migrations)
15
+ assert "_MIGRATIONS_NOT_DEFINED" in source
16
+
17
+ def test_sentinel_is_unique_object(self):
18
+ """Sentinel should be a unique object that won't match normal returns."""
19
+ assert _MIGRATIONS_NOT_DEFINED is not None
20
+ assert _MIGRATIONS_NOT_DEFINED is not False
21
+ assert _MIGRATIONS_NOT_DEFINED != ()
22
+
23
+ def test_user_override_does_not_return_sentinel(self, pytester):
24
+ """When user overrides neon_apply_migrations, it returns None (not sentinel)."""
25
+ pytester.makeconftest(
26
+ """
27
+ import os
28
+ import pytest
29
+ from dataclasses import dataclass
30
+ from pytest_neon.plugin import _MIGRATIONS_NOT_DEFINED
31
+
32
+ @dataclass
33
+ class FakeNeonBranch:
34
+ branch_id: str
35
+ project_id: str
36
+ connection_string: str
37
+ host: str
38
+ parent_id: str
39
+
40
+ @pytest.fixture(scope="session")
41
+ def _neon_migration_branch(request):
42
+ branch = FakeNeonBranch(
43
+ branch_id="br-migration",
44
+ project_id="proj-test",
45
+ connection_string="postgresql://fake",
46
+ host="test.neon.tech",
47
+ parent_id="br-parent",
48
+ )
49
+ os.environ["DATABASE_URL"] = branch.connection_string
50
+ request.config._neon_pre_migration_fingerprint = ()
51
+ yield branch
52
+
53
+ @pytest.fixture(scope="session")
54
+ def neon_apply_migrations(_neon_migration_branch):
55
+ # User override - returns None implicitly, not the sentinel
56
+ pass
57
+
58
+ @pytest.fixture(scope="session")
59
+ def _neon_branch_for_reset(_neon_migration_branch, neon_apply_migrations):
60
+ # Verify the detection logic
61
+ sentinel = _MIGRATIONS_NOT_DEFINED
62
+ migrations_defined = neon_apply_migrations is not sentinel
63
+ assert migrations_defined, "User override should NOT return sentinel"
64
+ yield _neon_migration_branch
65
+
66
+ @pytest.fixture(scope="function")
67
+ def neon_branch(_neon_branch_for_reset):
68
+ yield _neon_branch_for_reset
69
+ """
70
+ )
71
+
72
+ pytester.makepyfile(
73
+ """
74
+ def test_migration_override_detected(neon_branch):
75
+ assert neon_branch.branch_id == "br-migration"
76
+ """
77
+ )
78
+
79
+ result = pytester.runpytest("-v")
80
+ result.assert_outcomes(passed=1)
81
+
82
+
83
+ class TestMigrationFixtureOrder:
84
+ """Test that migrations run before test branches are created."""
85
+
86
+ def test_migrations_run_before_test_branch_created(self, pytester):
87
+ """Verify neon_apply_migrations is called before test branch exists."""
88
+ pytester.makeconftest(
89
+ """
90
+ import os
91
+ import pytest
92
+ from dataclasses import dataclass
93
+
94
+ execution_order = []
95
+
96
+ @dataclass
97
+ class FakeNeonBranch:
98
+ branch_id: str
99
+ project_id: str
100
+ connection_string: str
101
+ host: str
102
+ parent_id: str
103
+
104
+ @pytest.fixture(scope="session")
105
+ def _neon_migration_branch():
106
+ execution_order.append("migration_branch_created")
107
+ branch = FakeNeonBranch(
108
+ branch_id="br-migration",
109
+ project_id="proj-test",
110
+ connection_string="postgresql://migration",
111
+ host="test.neon.tech",
112
+ parent_id="br-parent",
113
+ )
114
+ os.environ["DATABASE_URL"] = branch.connection_string
115
+ yield branch
116
+
117
+ @pytest.fixture(scope="session")
118
+ def neon_apply_migrations(_neon_migration_branch):
119
+ execution_order.append("migrations_applied")
120
+ # User would run migrations here
121
+
122
+ @pytest.fixture(scope="module")
123
+ def _neon_branch_for_reset(_neon_migration_branch, neon_apply_migrations):
124
+ execution_order.append("test_branch_created")
125
+ branch = FakeNeonBranch(
126
+ branch_id="br-test",
127
+ project_id="proj-test",
128
+ connection_string="postgresql://test",
129
+ host="test.neon.tech",
130
+ parent_id=_neon_migration_branch.branch_id,
131
+ )
132
+ yield branch
133
+
134
+ @pytest.fixture(scope="function")
135
+ def neon_branch(_neon_branch_for_reset):
136
+ yield _neon_branch_for_reset
137
+
138
+ def pytest_sessionfinish(session, exitstatus):
139
+ # Verify order: migration branch -> migrations -> test branch
140
+ assert execution_order == [
141
+ "migration_branch_created",
142
+ "migrations_applied",
143
+ "test_branch_created",
144
+ ], f"Wrong order: {execution_order}"
145
+ """
146
+ )
147
+
148
+ pytester.makepyfile(
149
+ """
150
+ def test_uses_branch(neon_branch):
151
+ assert neon_branch.parent_id == "br-migration"
152
+ """
153
+ )
154
+
155
+ result = pytester.runpytest("-v")
156
+ result.assert_outcomes(passed=1)
@@ -1,77 +0,0 @@
1
- """Tests for migration support."""
2
-
3
-
4
- class TestMigrationFixtureOrder:
5
- """Test that migrations run before test branches are created."""
6
-
7
- def test_migrations_run_before_test_branch_created(self, pytester):
8
- """Verify neon_apply_migrations is called before test branch exists."""
9
- pytester.makeconftest(
10
- """
11
- import os
12
- import pytest
13
- from dataclasses import dataclass
14
-
15
- execution_order = []
16
-
17
- @dataclass
18
- class FakeNeonBranch:
19
- branch_id: str
20
- project_id: str
21
- connection_string: str
22
- host: str
23
- parent_id: str
24
-
25
- @pytest.fixture(scope="session")
26
- def _neon_migration_branch():
27
- execution_order.append("migration_branch_created")
28
- branch = FakeNeonBranch(
29
- branch_id="br-migration",
30
- project_id="proj-test",
31
- connection_string="postgresql://migration",
32
- host="test.neon.tech",
33
- parent_id="br-parent",
34
- )
35
- os.environ["DATABASE_URL"] = branch.connection_string
36
- yield branch
37
-
38
- @pytest.fixture(scope="session")
39
- def neon_apply_migrations(_neon_migration_branch):
40
- execution_order.append("migrations_applied")
41
- # User would run migrations here
42
-
43
- @pytest.fixture(scope="module")
44
- def _neon_branch_for_reset(_neon_migration_branch, neon_apply_migrations):
45
- execution_order.append("test_branch_created")
46
- branch = FakeNeonBranch(
47
- branch_id="br-test",
48
- project_id="proj-test",
49
- connection_string="postgresql://test",
50
- host="test.neon.tech",
51
- parent_id=_neon_migration_branch.branch_id,
52
- )
53
- yield branch
54
-
55
- @pytest.fixture(scope="function")
56
- def neon_branch(_neon_branch_for_reset):
57
- yield _neon_branch_for_reset
58
-
59
- def pytest_sessionfinish(session, exitstatus):
60
- # Verify order: migration branch -> migrations -> test branch
61
- assert execution_order == [
62
- "migration_branch_created",
63
- "migrations_applied",
64
- "test_branch_created",
65
- ], f"Wrong order: {execution_order}"
66
- """
67
- )
68
-
69
- pytester.makepyfile(
70
- """
71
- def test_uses_branch(neon_branch):
72
- assert neon_branch.parent_id == "br-migration"
73
- """
74
- )
75
-
76
- result = pytester.runpytest("-v")
77
- result.assert_outcomes(passed=1)
File without changes
File without changes
File without changes
File without changes
File without changes