pytest-neon 0.5.1__py3-none-any.whl → 0.6.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 CHANGED
@@ -9,7 +9,7 @@ from pytest_neon.plugin import (
9
9
  neon_engine,
10
10
  )
11
11
 
12
- __version__ = "0.5.1"
12
+ __version__ = "0.6.0"
13
13
  __all__ = [
14
14
  "NeonBranch",
15
15
  "neon_branch",
pytest_neon/plugin.py CHANGED
@@ -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"))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-neon
3
- Version: 0.5.1
3
+ Version: 0.6.0
4
4
  Summary: Pytest plugin for Neon database branch isolation in tests
5
5
  Project-URL: Homepage, https://github.com/ZainRizvi/pytest-neon
6
6
  Project-URL: Repository, https://github.com/ZainRizvi/pytest-neon
@@ -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
@@ -0,0 +1,8 @@
1
+ pytest_neon/__init__.py,sha256=54UgijfJx44ra6-LyAS2fKVPTdqOKofrD3wQeCt8ydQ,398
2
+ pytest_neon/plugin.py,sha256=6wcZe9E90y2kya4wM0ur9EtUhqWFUll_ebN4xgsogPk,29601
3
+ pytest_neon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ pytest_neon-0.6.0.dist-info/METADATA,sha256=Aw8Y8MHYAo9Wt8TbthiN-pd8pAQenaHckwobPipvtsk,16057
5
+ pytest_neon-0.6.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
+ pytest_neon-0.6.0.dist-info/entry_points.txt,sha256=5U88Idj_G8-PSDb9VF3OwYFbGLHnGOo_GxgYvi0dtXw,37
7
+ pytest_neon-0.6.0.dist-info/licenses/LICENSE,sha256=aKKp_Ex4WBHTByY4BhXJ181dzB_qYhi2pCUmZ7Spn_0,1067
8
+ pytest_neon-0.6.0.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- pytest_neon/__init__.py,sha256=YQt1LLTbNLiq4kChmiPEmOD5aTsqhCgpRpxK80VAY7Y,398
2
- pytest_neon/plugin.py,sha256=1TukuxsBQ88NlgQS6qosUarIrCc94apMrwvVrYt9IOg,23241
3
- pytest_neon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- pytest_neon-0.5.1.dist-info/METADATA,sha256=i1h6TFZWjG0QY2h_UZsYzz5e9AaQewfCXKIXi_JC00E,13547
5
- pytest_neon-0.5.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
- pytest_neon-0.5.1.dist-info/entry_points.txt,sha256=5U88Idj_G8-PSDb9VF3OwYFbGLHnGOo_GxgYvi0dtXw,37
7
- pytest_neon-0.5.1.dist-info/licenses/LICENSE,sha256=aKKp_Ex4WBHTByY4BhXJ181dzB_qYhi2pCUmZ7Spn_0,1067
8
- pytest_neon-0.5.1.dist-info/RECORD,,