pytest-neon 2.1.0__tar.gz → 2.1.1__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.
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/PKG-INFO +1 -1
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/pyproject.toml +1 -1
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/src/pytest_neon/__init__.py +1 -1
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/src/pytest_neon/plugin.py +80 -3
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/tests/test_reset_behavior.py +49 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/.env.example +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/.github/workflows/release.yml +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/.github/workflows/tests.yml +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/.gitignore +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/.neon +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/CLAUDE.md +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/LICENSE +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/README.md +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/src/pytest_neon/py.typed +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/tests/conftest.py +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/tests/test_branch_lifecycle.py +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/tests/test_cli_options.py +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/tests/test_env_var.py +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/tests/test_fixture_errors.py +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/tests/test_integration.py +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/tests/test_migrations.py +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/tests/test_readwrite_readonly_fixtures.py +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/tests/test_skip_behavior.py +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/tests/test_xdist_worker_support.py +0 -0
- {pytest_neon-2.1.0 → pytest_neon-2.1.1}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-neon
|
|
3
|
-
Version: 2.1.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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,64 @@ 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
|
+
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
|
+
|
|
437
514
|
@pytest.fixture(scope="session")
|
|
438
515
|
def _neon_migration_branch(
|
|
439
516
|
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."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|