pytest-neon 0.2.1__tar.gz → 0.3.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 (22) hide show
  1. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/CLAUDE.md +16 -7
  2. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/PKG-INFO +23 -1
  3. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/README.md +22 -0
  4. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/pyproject.toml +1 -1
  5. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/src/pytest_neon/__init__.py +1 -1
  6. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/src/pytest_neon/plugin.py +21 -13
  7. pytest_neon-0.3.0/tests/test_reset_behavior.py +179 -0
  8. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/.env.example +0 -0
  9. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/.github/workflows/release.yml +0 -0
  10. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/.github/workflows/tests.yml +0 -0
  11. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/.gitignore +0 -0
  12. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/.neon +0 -0
  13. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/LICENSE +0 -0
  14. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/src/pytest_neon/py.typed +0 -0
  15. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/tests/conftest.py +0 -0
  16. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/tests/test_branch_lifecycle.py +0 -0
  17. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/tests/test_cli_options.py +0 -0
  18. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/tests/test_env_var.py +0 -0
  19. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/tests/test_fixture_errors.py +0 -0
  20. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/tests/test_integration.py +0 -0
  21. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/tests/test_skip_behavior.py +0 -0
  22. {pytest_neon-0.2.1 → pytest_neon-0.3.0}/uv.lock +0 -0
@@ -2,27 +2,30 @@
2
2
 
3
3
  ## Project Overview
4
4
 
5
- This is a pytest plugin that provides isolated Neon database branches for integration testing. Each test module gets its own branch, with automatic cleanup.
5
+ This is a pytest plugin that provides isolated Neon database branches for integration testing. Each test gets isolated database state via branch reset after each test.
6
6
 
7
7
  ## Key Architecture
8
8
 
9
9
  - **Entry point**: `src/pytest_neon/plugin.py` - Contains all fixtures and pytest hooks
10
- - **Core fixture**: `neon_branch` - Creates branch, sets `DATABASE_URL`, yields `NeonBranch` dataclass
10
+ - **Core fixture**: `neon_branch` - Creates branch (module-scoped), resets after each test (function-scoped wrapper), sets `DATABASE_URL`, yields `NeonBranch` dataclass
11
+ - **Shared fixture**: `neon_branch_shared` - Module-scoped, no reset between tests
11
12
  - **Convenience fixtures**: `neon_connection`, `neon_connection_psycopg`, `neon_engine` - Optional, require extras
12
13
 
13
14
  ## Dependencies
14
15
 
15
- - Core: `pytest`, `neon-api` only
16
+ - Core: `pytest`, `neon-api`, `requests`
16
17
  - Optional extras: `psycopg2`, `psycopg`, `sqlalchemy` - for convenience fixtures
17
18
 
18
19
  ## Important Patterns
19
20
 
20
21
  ### Fixture Scopes
21
- - `neon_branch`: `scope="module"` - one branch per test file
22
+ - `_neon_branch_for_reset`: `scope="module"` - internal, creates one branch per test file
23
+ - `neon_branch`: `scope="function"` - wraps the above, resets branch after each test
24
+ - `neon_branch_shared`: `scope="module"` - one branch per test file, no reset
22
25
  - Connection fixtures: `scope="function"` (default) - fresh connection per test
23
26
 
24
27
  ### Environment Variable Handling
25
- The `_temporary_env` context manager sets `DATABASE_URL` during test execution and restores the original value after. This is critical for not polluting other tests.
28
+ 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.
26
29
 
27
30
  ### Error Messages
28
31
  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.
@@ -37,9 +40,15 @@ Tests in `tests/` use `pytester` for testing pytest plugins. The plugin itself c
37
40
 
38
41
  ## Publishing
39
42
 
43
+ Use the GitHub Actions release workflow:
44
+ 1. Go to Actions → Release → Run workflow
45
+ 2. Choose patch/minor/major
46
+ 3. Workflow bumps version, commits, tags, and publishes to PyPI
47
+
48
+ Or manually:
40
49
  ```bash
41
- python -m build
42
- python -m twine upload dist/*
50
+ uv build
51
+ uv publish --token $PYPI_TOKEN
43
52
  ```
44
53
 
45
54
  Package name on PyPI: `pytest-neon`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-neon
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Pytest plugin for Neon database branch isolation in tests
5
5
  Project-URL: Homepage, https://github.com/zain/pytest-neon
6
6
  Project-URL: Repository, https://github.com/zain/pytest-neon
@@ -276,6 +276,28 @@ jobs:
276
276
 
277
277
  Branches use copy-on-write storage, so you only pay for data that differs from the parent branch.
278
278
 
279
+ ### What Reset Does
280
+
281
+ The `neon_branch` fixture uses Neon's branch restore API to reset database state after each test:
282
+
283
+ - **Data changes are reverted**: All INSERT, UPDATE, DELETE operations are undone
284
+ - **Schema changes are reverted**: CREATE TABLE, ALTER TABLE, DROP TABLE, etc. are undone
285
+ - **Sequences are reset**: Auto-increment counters return to parent state
286
+ - **Complete rollback**: The branch is restored to the exact state of the parent at the time the child branch was created
287
+
288
+ This is similar to database transactions but at the branch level.
289
+
290
+ ## Limitations
291
+
292
+ ### Parallel Test Execution
293
+
294
+ 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.
295
+
296
+ If you need parallel execution, you can:
297
+ - Use `neon_branch.connection_string` directly instead of relying on `DATABASE_URL`
298
+ - Run with `pytest-xdist --dist=loadfile` to keep modules in separate processes
299
+ - Run tests serially (default pytest behavior)
300
+
279
301
  ## Troubleshooting
280
302
 
281
303
  ### "psycopg not installed" or "psycopg2 not installed"
@@ -234,6 +234,28 @@ jobs:
234
234
 
235
235
  Branches use copy-on-write storage, so you only pay for data that differs from the parent branch.
236
236
 
237
+ ### What Reset Does
238
+
239
+ The `neon_branch` fixture uses Neon's branch restore API to reset database state after each test:
240
+
241
+ - **Data changes are reverted**: All INSERT, UPDATE, DELETE operations are undone
242
+ - **Schema changes are reverted**: CREATE TABLE, ALTER TABLE, DROP TABLE, etc. are undone
243
+ - **Sequences are reset**: Auto-increment counters return to parent state
244
+ - **Complete rollback**: The branch is restored to the exact state of the parent at the time the child branch was created
245
+
246
+ This is similar to database transactions but at the branch level.
247
+
248
+ ## Limitations
249
+
250
+ ### Parallel Test Execution
251
+
252
+ 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.
253
+
254
+ If you need parallel execution, you can:
255
+ - Use `neon_branch.connection_string` directly instead of relying on `DATABASE_URL`
256
+ - Run with `pytest-xdist --dist=loadfile` to keep modules in separate processes
257
+ - Run tests serially (default pytest behavior)
258
+
237
259
  ## Troubleshooting
238
260
 
239
261
  ### "psycopg not installed" or "psycopg2 not installed"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-neon"
7
- version = "0.2.1"
7
+ version = "0.3.0"
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__ = "0.2.1"
12
+ __version__ = "0.3.0"
13
13
  __all__ = [
14
14
  "NeonBranch",
15
15
  "neon_branch",
@@ -7,14 +7,12 @@ import time
7
7
  from collections.abc import Generator
8
8
  from dataclasses import dataclass
9
9
  from datetime import datetime, timedelta, timezone
10
- from typing import TYPE_CHECKING, Any
10
+ from typing import Any
11
11
 
12
12
  import pytest
13
13
  import requests
14
14
  from neon_api import NeonAPI
15
-
16
- if TYPE_CHECKING:
17
- pass
15
+ from neon_api.schema import EndpointState
18
16
 
19
17
  # Default branch expiry in seconds (10 minutes)
20
18
  DEFAULT_BRANCH_EXPIRY_SECONDS = 600
@@ -168,9 +166,10 @@ def _create_neon_branch(
168
166
  raise RuntimeError(f"No endpoint created for branch {branch.id}")
169
167
 
170
168
  # Wait for endpoint to be ready (it starts in "init" state)
171
- # Endpoints typically become active in 1-2 seconds
169
+ # Endpoints typically become active in 1-2 seconds, but we allow up to 60s
170
+ # to handle occasional Neon API slowness or high load scenarios
172
171
  max_wait_seconds = 60
173
- poll_interval = 0.5
172
+ poll_interval = 0.5 # Poll every 500ms for responsive feedback
174
173
  waited = 0.0
175
174
 
176
175
  while True:
@@ -180,7 +179,7 @@ def _create_neon_branch(
180
179
  endpoint = endpoint_response.endpoint
181
180
  state = endpoint.current_state
182
181
 
183
- if state == "active" or str(state) == "EndpointState.active":
182
+ if state == EndpointState.active:
184
183
  break
185
184
 
186
185
  if waited >= max_wait_seconds:
@@ -254,7 +253,7 @@ def _reset_branch_to_parent(branch: NeonBranch, api_key: str) -> None:
254
253
  "Content-Type": "application/json",
255
254
  }
256
255
  response = requests.post(
257
- url, headers=headers, json={"source_branch_id": branch.parent_id}
256
+ url, headers=headers, json={"source_branch_id": branch.parent_id}, timeout=30
258
257
  )
259
258
  response.raise_for_status()
260
259
 
@@ -304,6 +303,15 @@ def neon_branch(
304
303
  config = request.config
305
304
  api_key = _get_config_value(config, "neon_api_key", "NEON_API_KEY")
306
305
 
306
+ # Validate that branch has a parent for reset functionality
307
+ if not _neon_branch_for_reset.parent_id:
308
+ pytest.fail(
309
+ f"\n\nBranch {_neon_branch_for_reset.branch_id} has no parent. "
310
+ f"The neon_branch fixture requires a parent branch for reset.\n\n"
311
+ f"Use neon_branch_shared if you don't need reset, or specify "
312
+ f"a parent branch with --neon-parent-branch or NEON_PARENT_BRANCH_ID."
313
+ )
314
+
307
315
  yield _neon_branch_for_reset
308
316
 
309
317
  # Reset branch to parent state after each test
@@ -311,11 +319,11 @@ def neon_branch(
311
319
  try:
312
320
  _reset_branch_to_parent(branch=_neon_branch_for_reset, api_key=api_key)
313
321
  except Exception as e:
314
- import warnings
315
-
316
- warnings.warn(
317
- f"Failed to reset branch {_neon_branch_for_reset.branch_id}: {e}",
318
- stacklevel=2,
322
+ pytest.fail(
323
+ f"\n\nFailed to reset branch {_neon_branch_for_reset.branch_id} "
324
+ f"after test. Subsequent tests in this module may see dirty "
325
+ f"database state.\n\nError: {e}\n\n"
326
+ f"To keep the branch for debugging, use --neon-keep-branches"
319
327
  )
320
328
 
321
329
 
@@ -0,0 +1,179 @@
1
+ """Tests for branch reset behavior."""
2
+
3
+
4
+ class TestResetBehavior:
5
+ """Test that branch reset happens between tests."""
6
+
7
+ def test_reset_called_after_each_test(self, pytester):
8
+ """Verify reset is called after each test function."""
9
+ pytester.makeconftest(
10
+ """
11
+ import os
12
+ import pytest
13
+ from dataclasses import dataclass
14
+
15
+ reset_count = [0]
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="module")
26
+ def _neon_branch_for_reset():
27
+ branch = FakeNeonBranch(
28
+ branch_id="br-test",
29
+ project_id="proj-test",
30
+ connection_string="postgresql://test",
31
+ host="test.neon.tech",
32
+ parent_id="br-parent",
33
+ )
34
+ os.environ["DATABASE_URL"] = branch.connection_string
35
+ try:
36
+ yield branch
37
+ finally:
38
+ os.environ.pop("DATABASE_URL", None)
39
+
40
+ @pytest.fixture(scope="function")
41
+ def neon_branch(_neon_branch_for_reset):
42
+ yield _neon_branch_for_reset
43
+ # Simulate reset
44
+ reset_count[0] += 1
45
+
46
+ def pytest_sessionfinish(session, exitstatus):
47
+ # Verify resets happened
48
+ assert reset_count[0] == 2, f"Expected 2 resets, got {reset_count[0]}"
49
+ """
50
+ )
51
+
52
+ pytester.makepyfile(
53
+ """
54
+ def test_first(neon_branch):
55
+ assert neon_branch.branch_id == "br-test"
56
+
57
+ def test_second(neon_branch):
58
+ assert neon_branch.branch_id == "br-test"
59
+ """
60
+ )
61
+
62
+ result = pytester.runpytest("-v")
63
+ result.assert_outcomes(passed=2)
64
+
65
+ def test_same_branch_used_across_tests_in_module(self, pytester):
66
+ """Verify all tests in a module use the same branch instance."""
67
+ pytester.makeconftest(
68
+ """
69
+ import os
70
+ import pytest
71
+ from dataclasses import dataclass
72
+
73
+ branch_ids_seen = []
74
+
75
+ @dataclass
76
+ class FakeNeonBranch:
77
+ branch_id: str
78
+ project_id: str
79
+ connection_string: str
80
+ host: str
81
+ parent_id: str
82
+
83
+ @pytest.fixture(scope="module")
84
+ def _neon_branch_for_reset():
85
+ import random
86
+ branch = FakeNeonBranch(
87
+ branch_id=f"br-{random.randint(1000, 9999)}",
88
+ project_id="proj-test",
89
+ connection_string="postgresql://test",
90
+ host="test.neon.tech",
91
+ parent_id="br-parent",
92
+ )
93
+ os.environ["DATABASE_URL"] = branch.connection_string
94
+ try:
95
+ yield branch
96
+ finally:
97
+ os.environ.pop("DATABASE_URL", None)
98
+
99
+ @pytest.fixture(scope="function")
100
+ def neon_branch(_neon_branch_for_reset):
101
+ branch_ids_seen.append(_neon_branch_for_reset.branch_id)
102
+ yield _neon_branch_for_reset
103
+
104
+ def pytest_sessionfinish(session, exitstatus):
105
+ # All tests should see the same branch
106
+ unique = len(set(branch_ids_seen))
107
+ assert unique == 1, f"Expected 1 unique branch, got {unique}"
108
+ """
109
+ )
110
+
111
+ pytester.makepyfile(
112
+ """
113
+ def test_first(neon_branch):
114
+ pass
115
+
116
+ def test_second(neon_branch):
117
+ pass
118
+
119
+ def test_third(neon_branch):
120
+ pass
121
+ """
122
+ )
123
+
124
+ result = pytester.runpytest("-v")
125
+ result.assert_outcomes(passed=3)
126
+
127
+
128
+ class TestParentIdValidation:
129
+ """Test that missing parent_id is caught early."""
130
+
131
+ def test_fails_if_no_parent_id(self, pytester):
132
+ """Verify that neon_branch fails if branch has no parent."""
133
+ pytester.makeconftest(
134
+ """
135
+ import os
136
+ import pytest
137
+ from dataclasses import dataclass
138
+
139
+ @dataclass
140
+ class FakeNeonBranch:
141
+ branch_id: str
142
+ project_id: str
143
+ connection_string: str
144
+ host: str
145
+ parent_id: str = None # No parent!
146
+
147
+ @pytest.fixture(scope="module")
148
+ def _neon_branch_for_reset():
149
+ branch = FakeNeonBranch(
150
+ branch_id="br-test",
151
+ project_id="proj-test",
152
+ connection_string="postgresql://test",
153
+ host="test.neon.tech",
154
+ parent_id=None, # No parent
155
+ )
156
+ os.environ["DATABASE_URL"] = branch.connection_string
157
+ try:
158
+ yield branch
159
+ finally:
160
+ os.environ.pop("DATABASE_URL", None)
161
+
162
+ @pytest.fixture(scope="function")
163
+ def neon_branch(_neon_branch_for_reset):
164
+ if not _neon_branch_for_reset.parent_id:
165
+ pytest.fail("Branch has no parent - cannot reset")
166
+ yield _neon_branch_for_reset
167
+ """
168
+ )
169
+
170
+ pytester.makepyfile(
171
+ """
172
+ def test_should_fail(neon_branch):
173
+ pass
174
+ """
175
+ )
176
+
177
+ result = pytester.runpytest("-v")
178
+ result.assert_outcomes(errors=1)
179
+ assert "has no parent" in result.stdout.str()
File without changes
File without changes
File without changes
File without changes
File without changes