pytest-neon 2.0.0__py3-none-any.whl → 2.1.1__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__ = "2.0.0"
12
+ __version__ = "2.1.1"
13
13
  __all__ = [
14
14
  "NeonBranch",
15
15
  "neon_branch",
pytest_neon/plugin.py CHANGED
@@ -56,6 +56,17 @@ DEFAULT_BRANCH_EXPIRY_SECONDS = 600
56
56
  _MIGRATIONS_NOT_DEFINED = object()
57
57
 
58
58
 
59
+ def _get_xdist_worker_id() -> str:
60
+ """
61
+ Get the pytest-xdist worker ID, or "main" if not running under xdist.
62
+
63
+ When running tests in parallel with pytest-xdist, each worker process
64
+ gets a unique ID (gw0, gw1, gw2, etc.). This is used to create separate
65
+ branches per worker to avoid database state pollution between parallel tests.
66
+ """
67
+ return os.environ.get("PYTEST_XDIST_WORKER", "main")
68
+
69
+
59
70
  def _get_schema_fingerprint(connection_string: str) -> tuple[tuple[Any, ...], ...]:
60
71
  """
61
72
  Get a fingerprint of the database schema for change detection.
@@ -385,7 +396,8 @@ def _reset_branch_to_parent(
385
396
  """Reset a branch to its parent's state using the Neon API.
386
397
 
387
398
  Uses exponential backoff retry logic to handle transient API errors
388
- that can occur during parallel test execution.
399
+ that can occur during parallel test execution. After initiating the
400
+ restore, polls the operation status until it completes.
389
401
 
390
402
  Args:
391
403
  branch: The branch to reset
@@ -395,7 +407,10 @@ def _reset_branch_to_parent(
395
407
  if not branch.parent_id:
396
408
  raise RuntimeError(f"Branch {branch.branch_id} has no parent - cannot reset")
397
409
 
398
- url = f"https://console.neon.tech/api/v2/projects/{branch.project_id}/branches/{branch.branch_id}/restore"
410
+ base_url = "https://console.neon.tech/api/v2"
411
+ project_id = branch.project_id
412
+ branch_id = branch.branch_id
413
+ restore_url = f"{base_url}/projects/{project_id}/branches/{branch_id}/restore"
399
414
  headers = {
400
415
  "Authorization": f"Bearer {api_key}",
401
416
  "Content-Type": "application/json",
@@ -405,12 +420,27 @@ def _reset_branch_to_parent(
405
420
  for attempt in range(max_retries + 1):
406
421
  try:
407
422
  response = requests.post(
408
- url,
423
+ restore_url,
409
424
  headers=headers,
410
425
  json={"source_branch_id": branch.parent_id},
411
426
  timeout=30,
412
427
  )
413
428
  response.raise_for_status()
429
+
430
+ # The restore API returns operations that run asynchronously.
431
+ # We must wait for operations to complete before the next test
432
+ # starts, otherwise connections may fail during the restore.
433
+ data = response.json()
434
+ operations = data.get("operations", [])
435
+
436
+ if operations:
437
+ _wait_for_operations(
438
+ project_id=branch.project_id,
439
+ operations=operations,
440
+ headers=headers,
441
+ base_url=base_url,
442
+ )
443
+
414
444
  return # Success
415
445
  except requests.RequestException as e:
416
446
  last_error = e
@@ -423,6 +453,64 @@ def _reset_branch_to_parent(
423
453
  raise last_error # type: ignore[misc]
424
454
 
425
455
 
456
+ def _wait_for_operations(
457
+ project_id: str,
458
+ operations: list[dict[str, Any]],
459
+ headers: dict[str, str],
460
+ base_url: str,
461
+ max_wait_seconds: float = 60,
462
+ poll_interval: float = 0.5,
463
+ ) -> None:
464
+ """Wait for Neon operations to complete.
465
+
466
+ Args:
467
+ project_id: The Neon project ID
468
+ operations: List of operation dicts from the API response
469
+ headers: HTTP headers including auth
470
+ base_url: Base URL for Neon API
471
+ max_wait_seconds: Maximum time to wait (default: 60s)
472
+ poll_interval: Time between polls (default: 0.5s)
473
+ """
474
+ # Get operation IDs that aren't already finished
475
+ pending_op_ids = [
476
+ op["id"] for op in operations if op.get("status") not in ("finished", "skipped")
477
+ ]
478
+
479
+ if not pending_op_ids:
480
+ return # All operations already complete
481
+
482
+ waited = 0.0
483
+ while pending_op_ids and waited < max_wait_seconds:
484
+ time.sleep(poll_interval)
485
+ waited += poll_interval
486
+
487
+ # Check status of each pending operation
488
+ still_pending = []
489
+ for op_id in pending_op_ids:
490
+ op_url = f"{base_url}/projects/{project_id}/operations/{op_id}"
491
+ try:
492
+ response = requests.get(op_url, headers=headers, timeout=10)
493
+ response.raise_for_status()
494
+ op_data = response.json().get("operation", {})
495
+ status = op_data.get("status")
496
+
497
+ if status == "error":
498
+ err = op_data.get("error", "unknown error")
499
+ raise RuntimeError(f"Operation {op_id} failed: {err}")
500
+ if status not in ("finished", "skipped"):
501
+ still_pending.append(op_id)
502
+ except requests.RequestException:
503
+ # On network error, assume still pending and retry
504
+ still_pending.append(op_id)
505
+
506
+ pending_op_ids = still_pending
507
+
508
+ if pending_op_ids:
509
+ raise RuntimeError(
510
+ f"Timeout waiting for operations to complete: {pending_op_ids}"
511
+ )
512
+
513
+
426
514
  @pytest.fixture(scope="session")
427
515
  def _neon_migration_branch(
428
516
  request: pytest.FixtureRequest,
@@ -527,6 +615,12 @@ def _neon_branch_for_reset(
527
615
  session, avoiding issues with Python's module caching (e.g., SQLAlchemy
528
616
  engines created at import time would otherwise point to stale branches).
529
617
 
618
+ Parallel Test Support (pytest-xdist):
619
+ When running tests in parallel with pytest-xdist, each worker gets its
620
+ own branch. This prevents database state pollution between tests running
621
+ concurrently on different workers. The worker ID is included in the
622
+ branch name suffix (e.g., "-test-gw0", "-test-gw1").
623
+
530
624
  Smart Migration Detection:
531
625
  This fixture implements a cost-optimization strategy:
532
626
 
@@ -558,15 +652,21 @@ def _neon_branch_for_reset(
558
652
  # Assume migrations changed something to be safe
559
653
  schema_changed = True
560
654
 
655
+ # Get worker ID for parallel test support
656
+ # Each xdist worker gets its own branch to avoid state pollution
657
+ worker_id = _get_xdist_worker_id()
658
+ branch_suffix = f"-test-{worker_id}"
659
+
561
660
  # Only create a child branch if migrations actually modified the schema
562
- if schema_changed:
661
+ # OR if we're running under xdist (each worker needs its own branch)
662
+ if schema_changed or worker_id != "main":
563
663
  yield from _create_neon_branch(
564
664
  request,
565
665
  parent_branch_id_override=_neon_migration_branch.branch_id,
566
- branch_name_suffix="-test",
666
+ branch_name_suffix=branch_suffix,
567
667
  )
568
668
  else:
569
- # No schema changes - reuse the migration branch directly
669
+ # No schema changes and not parallel - reuse the migration branch directly
570
670
  # This saves creating an unnecessary branch
571
671
  yield _neon_migration_branch
572
672
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-neon
3
- Version: 2.0.0
3
+ Version: 2.1.1
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
@@ -489,16 +489,26 @@ The `neon_branch_readwrite` fixture uses Neon's branch restore API to reset data
489
489
 
490
490
  This is similar to database transactions but at the branch level.
491
491
 
492
- ## Limitations
492
+ ## Parallel Test Execution (pytest-xdist)
493
493
 
494
- ### Parallel Test Execution
494
+ This plugin supports parallel test execution with [pytest-xdist](https://pytest-xdist.readthedocs.io/). Each xdist worker automatically gets its own isolated branch.
495
495
 
496
- This plugin sets the `DATABASE_URL` environment variable, which is process-global. This means it is **not compatible with pytest-xdist** or other parallel test runners that run tests in the same process.
496
+ ```bash
497
+ # Run tests in parallel with 4 workers
498
+ pip install pytest-xdist
499
+ pytest -n 4
500
+ ```
501
+
502
+ **How it works:**
503
+ - Each xdist worker (gw0, gw1, gw2, etc.) creates its own branch
504
+ - Branches are named with the worker ID suffix (e.g., `-test-gw0`, `-test-gw1`)
505
+ - Workers run tests in parallel without database state interference
506
+ - All branches are cleaned up after the test session
497
507
 
498
- If you need parallel execution, you can:
499
- - Use `neon_branch.connection_string` directly instead of relying on `DATABASE_URL`
500
- - Run with `pytest-xdist --dist=loadfile` to keep modules in separate processes
501
- - Run tests serially (default pytest behavior)
508
+ **Cost implications:**
509
+ - Running with `-n 4` creates 4 branches (one per worker) plus the migration branch
510
+ - Choose your parallelism level based on your Neon plan's branch limits
511
+ - Each worker's branch is reset after each test using the fast reset operation (~0.5s)
502
512
 
503
513
  ## Troubleshooting
504
514
 
@@ -0,0 +1,8 @@
1
+ pytest_neon/__init__.py,sha256=fHGWjiPNW359dinHRv4ldELBxmxb9mOPG1DqzL2QFuA,398
2
+ pytest_neon/plugin.py,sha256=BewDydvL_16zT38TsmLJJhvkyLAHtmFIsDfOzae-vQ4,37725
3
+ pytest_neon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ pytest_neon-2.1.1.dist-info/METADATA,sha256=PO8HOrmTLpyBHAlMK-0akpzpm9pN2msWePZ8Nkf5bUI,18734
5
+ pytest_neon-2.1.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
+ pytest_neon-2.1.1.dist-info/entry_points.txt,sha256=5U88Idj_G8-PSDb9VF3OwYFbGLHnGOo_GxgYvi0dtXw,37
7
+ pytest_neon-2.1.1.dist-info/licenses/LICENSE,sha256=aKKp_Ex4WBHTByY4BhXJ181dzB_qYhi2pCUmZ7Spn_0,1067
8
+ pytest_neon-2.1.1.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- pytest_neon/__init__.py,sha256=Ti_bjX7CgEZjtaY_uoNnkSvRUXnEw4MkfsbFie_K6Mo,398
2
- pytest_neon/plugin.py,sha256=9VH7h2UYp0AoJj3FagpP6Xqtd1_qqkcuS0sLF-cqYpo,33877
3
- pytest_neon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- pytest_neon-2.0.0.dist-info/METADATA,sha256=bQ7y0oOcymrho3AY9NhHRFT1FNM4jPK27rRFh217FN4,18386
5
- pytest_neon-2.0.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
- pytest_neon-2.0.0.dist-info/entry_points.txt,sha256=5U88Idj_G8-PSDb9VF3OwYFbGLHnGOo_GxgYvi0dtXw,37
7
- pytest_neon-2.0.0.dist-info/licenses/LICENSE,sha256=aKKp_Ex4WBHTByY4BhXJ181dzB_qYhi2pCUmZ7Spn_0,1067
8
- pytest_neon-2.0.0.dist-info/RECORD,,