pytest-neon 2.1.0__tar.gz → 2.1.2__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 (25) hide show
  1. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/PKG-INFO +1 -1
  2. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/pyproject.toml +1 -1
  3. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/src/pytest_neon/__init__.py +1 -1
  4. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/src/pytest_neon/plugin.py +88 -3
  5. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/tests/test_reset_behavior.py +49 -0
  6. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/uv.lock +1 -1
  7. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/.env.example +0 -0
  8. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/.github/workflows/release.yml +0 -0
  9. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/.github/workflows/tests.yml +0 -0
  10. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/.gitignore +0 -0
  11. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/.neon +0 -0
  12. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/CLAUDE.md +0 -0
  13. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/LICENSE +0 -0
  14. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/README.md +0 -0
  15. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/src/pytest_neon/py.typed +0 -0
  16. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/tests/conftest.py +0 -0
  17. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/tests/test_branch_lifecycle.py +0 -0
  18. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/tests/test_cli_options.py +0 -0
  19. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/tests/test_env_var.py +0 -0
  20. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/tests/test_fixture_errors.py +0 -0
  21. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/tests/test_integration.py +0 -0
  22. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/tests/test_migrations.py +0 -0
  23. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/tests/test_readwrite_readonly_fixtures.py +0 -0
  24. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/tests/test_skip_behavior.py +0 -0
  25. {pytest_neon-2.1.0 → pytest_neon-2.1.2}/tests/test_xdist_worker_support.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-neon
3
- Version: 2.1.0
3
+ Version: 2.1.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-neon"
7
- version = "2.1.0"
7
+ version = "2.1.2"
8
8
  description = "Pytest plugin for Neon database branch isolation in tests"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -9,7 +9,7 @@ from pytest_neon.plugin import (
9
9
  neon_engine,
10
10
  )
11
11
 
12
- __version__ = "2.1.0"
12
+ __version__ = "2.1.2"
13
13
  __all__ = [
14
14
  "NeonBranch",
15
15
  "neon_branch",
@@ -396,7 +396,8 @@ def _reset_branch_to_parent(
396
396
  """Reset a branch to its parent's state using the Neon API.
397
397
 
398
398
  Uses exponential backoff retry logic to handle transient API errors
399
- 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.
400
401
 
401
402
  Args:
402
403
  branch: The branch to reset
@@ -406,7 +407,10 @@ def _reset_branch_to_parent(
406
407
  if not branch.parent_id:
407
408
  raise RuntimeError(f"Branch {branch.branch_id} has no parent - cannot reset")
408
409
 
409
- 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"
410
414
  headers = {
411
415
  "Authorization": f"Bearer {api_key}",
412
416
  "Content-Type": "application/json",
@@ -416,12 +420,27 @@ def _reset_branch_to_parent(
416
420
  for attempt in range(max_retries + 1):
417
421
  try:
418
422
  response = requests.post(
419
- url,
423
+ restore_url,
420
424
  headers=headers,
421
425
  json={"source_branch_id": branch.parent_id},
422
426
  timeout=30,
423
427
  )
424
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
+
425
444
  return # Success
426
445
  except requests.RequestException as e:
427
446
  last_error = e
@@ -434,6 +453,72 @@ def _reset_branch_to_parent(
434
453
  raise last_error # type: ignore[misc]
435
454
 
436
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
+ first_poll = True
484
+ while pending_op_ids and waited < max_wait_seconds:
485
+ # Poll immediately first time (operation usually completes instantly),
486
+ # then wait between subsequent polls
487
+ if first_poll:
488
+ time.sleep(0.1) # Tiny delay to let operation start
489
+ waited += 0.1
490
+ first_poll = False
491
+ else:
492
+ time.sleep(poll_interval)
493
+ waited += poll_interval
494
+
495
+ # Check status of each pending operation
496
+ still_pending = []
497
+ for op_id in pending_op_ids:
498
+ op_url = f"{base_url}/projects/{project_id}/operations/{op_id}"
499
+ try:
500
+ response = requests.get(op_url, headers=headers, timeout=10)
501
+ response.raise_for_status()
502
+ op_data = response.json().get("operation", {})
503
+ status = op_data.get("status")
504
+
505
+ if status == "error":
506
+ err = op_data.get("error", "unknown error")
507
+ raise RuntimeError(f"Operation {op_id} failed: {err}")
508
+ if status not in ("finished", "skipped"):
509
+ still_pending.append(op_id)
510
+ except requests.RequestException:
511
+ # On network error, assume still pending and retry
512
+ still_pending.append(op_id)
513
+
514
+ pending_op_ids = still_pending
515
+
516
+ if pending_op_ids:
517
+ raise RuntimeError(
518
+ f"Timeout waiting for operations to complete: {pending_op_ids}"
519
+ )
520
+
521
+
437
522
  @pytest.fixture(scope="session")
438
523
  def _neon_migration_branch(
439
524
  request: pytest.FixtureRequest,
@@ -21,6 +21,8 @@ class TestResetRetryBehavior:
21
21
  # Mock requests.post to fail twice, then succeed
22
22
  mock_response = mocker.Mock()
23
23
  mock_response.raise_for_status = mocker.Mock()
24
+ # Return empty operations list (already complete)
25
+ mock_response.json.return_value = {"operations": []}
24
26
 
25
27
  import requests
26
28
 
@@ -103,6 +105,8 @@ class TestResetRetryBehavior:
103
105
 
104
106
  mock_response = mocker.Mock()
105
107
  mock_response.raise_for_status = mocker.Mock()
108
+ # Return empty operations list (already complete)
109
+ mock_response.json.return_value = {"operations": []}
106
110
  mock_post = mocker.patch(
107
111
  "pytest_neon.plugin.requests.post", return_value=mock_response
108
112
  )
@@ -113,6 +117,51 @@ class TestResetRetryBehavior:
113
117
  assert mock_post.call_count == 1
114
118
  assert mock_sleep.call_count == 0
115
119
 
120
+ def test_reset_waits_for_operations_to_complete(self, mocker):
121
+ """Verify reset polls operation status until complete."""
122
+ branch = NeonBranch(
123
+ branch_id="br-test",
124
+ project_id="proj-test",
125
+ connection_string="postgresql://test",
126
+ host="test.neon.tech",
127
+ parent_id="br-parent",
128
+ )
129
+
130
+ # Mock POST response with pending operation
131
+ mock_post_response = mocker.Mock()
132
+ mock_post_response.raise_for_status = mocker.Mock()
133
+ mock_post_response.json.return_value = {
134
+ "operations": [{"id": "op-123", "status": "running"}]
135
+ }
136
+
137
+ # Mock GET responses: first running, then finished
138
+ get_call_count = [0]
139
+
140
+ def mock_get(*args, **kwargs):
141
+ get_call_count[0] += 1
142
+ mock_get_response = mocker.Mock()
143
+ mock_get_response.raise_for_status = mocker.Mock()
144
+ if get_call_count[0] < 3:
145
+ mock_get_response.json.return_value = {
146
+ "operation": {"id": "op-123", "status": "running"}
147
+ }
148
+ else:
149
+ mock_get_response.json.return_value = {
150
+ "operation": {"id": "op-123", "status": "finished"}
151
+ }
152
+ return mock_get_response
153
+
154
+ mocker.patch(
155
+ "pytest_neon.plugin.requests.post", return_value=mock_post_response
156
+ )
157
+ mocker.patch("pytest_neon.plugin.requests.get", side_effect=mock_get)
158
+ mocker.patch("pytest_neon.plugin.time.sleep")
159
+
160
+ _reset_branch_to_parent(branch, "fake-api-key")
161
+
162
+ # Should have polled until finished
163
+ assert get_call_count[0] == 3
164
+
116
165
 
117
166
  class TestResetBehavior:
118
167
  """Test that branch reset happens between tests."""
@@ -1200,7 +1200,7 @@ wheels = [
1200
1200
 
1201
1201
  [[package]]
1202
1202
  name = "pytest-neon"
1203
- version = "2.0.0"
1203
+ version = "2.1.0"
1204
1204
  source = { editable = "." }
1205
1205
  dependencies = [
1206
1206
  { name = "neon-api" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes