pytest-neon 2.2.1__py3-none-any.whl → 2.3.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 +1 -1
- pytest_neon/plugin.py +914 -300
- {pytest_neon-2.2.1.dist-info → pytest_neon-2.3.0.dist-info}/METADATA +150 -76
- pytest_neon-2.3.0.dist-info/RECORD +8 -0
- pytest_neon-2.2.1.dist-info/RECORD +0 -8
- {pytest_neon-2.2.1.dist-info → pytest_neon-2.3.0.dist-info}/WHEEL +0 -0
- {pytest_neon-2.2.1.dist-info → pytest_neon-2.3.0.dist-info}/entry_points.txt +0 -0
- {pytest_neon-2.2.1.dist-info → pytest_neon-2.3.0.dist-info}/licenses/LICENSE +0 -0
pytest_neon/plugin.py
CHANGED
|
@@ -1,21 +1,38 @@
|
|
|
1
1
|
"""Pytest plugin providing Neon database branch fixtures.
|
|
2
2
|
|
|
3
|
-
This plugin provides fixtures for
|
|
4
|
-
|
|
5
|
-
branch reset after each test.
|
|
3
|
+
This plugin provides fixtures for database testing using Neon's instant
|
|
4
|
+
branching feature. Multiple isolation levels are available:
|
|
6
5
|
|
|
7
6
|
Main fixtures:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
neon_branch_readonly: True read-only access via read_only endpoint (enforced)
|
|
8
|
+
neon_branch_dirty: Session-scoped read-write, shared state across all tests
|
|
9
|
+
neon_branch_isolated: Per-worker branch with reset after each test (recommended)
|
|
10
|
+
neon_branch_readwrite: Deprecated, use neon_branch_isolated instead
|
|
11
|
+
neon_branch: Deprecated alias for neon_branch_isolated
|
|
11
12
|
neon_branch_shared: Shared branch without reset (module-scoped)
|
|
13
|
+
|
|
14
|
+
Connection fixtures (require extras):
|
|
12
15
|
neon_connection: psycopg2 connection (requires psycopg2 extra)
|
|
13
16
|
neon_connection_psycopg: psycopg v3 connection (requires psycopg extra)
|
|
14
17
|
neon_engine: SQLAlchemy engine (requires sqlalchemy extra)
|
|
15
18
|
|
|
19
|
+
Architecture:
|
|
20
|
+
Parent Branch (configured or project default)
|
|
21
|
+
└── Migration Branch (session-scoped, read_write endpoint)
|
|
22
|
+
│ ↑ migrations run here ONCE
|
|
23
|
+
│
|
|
24
|
+
├── Read-only Endpoint (read_only endpoint ON migration branch)
|
|
25
|
+
│ ↑ neon_branch_readonly uses this
|
|
26
|
+
│
|
|
27
|
+
├── Dirty Branch (session-scoped child, shared across ALL workers)
|
|
28
|
+
│ ↑ neon_branch_dirty uses this
|
|
29
|
+
│
|
|
30
|
+
└── Isolated Branch (one per xdist worker, lazily created)
|
|
31
|
+
↑ neon_branch_isolated uses this, reset after each test
|
|
32
|
+
|
|
16
33
|
SQLAlchemy Users:
|
|
17
34
|
If you create your own SQLAlchemy engine (not using neon_engine fixture),
|
|
18
|
-
you MUST use pool_pre_ping=True when using
|
|
35
|
+
you MUST use pool_pre_ping=True when using neon_branch_isolated:
|
|
19
36
|
|
|
20
37
|
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
|
21
38
|
|
|
@@ -23,9 +40,6 @@ SQLAlchemy Users:
|
|
|
23
40
|
Without pool_pre_ping, SQLAlchemy may try to reuse dead pooled connections,
|
|
24
41
|
causing "SSL connection has been closed unexpectedly" errors.
|
|
25
42
|
|
|
26
|
-
Note: pool_pre_ping is not required for neon_branch_readonly since no
|
|
27
|
-
resets occur.
|
|
28
|
-
|
|
29
43
|
Configuration:
|
|
30
44
|
Set NEON_API_KEY and NEON_PROJECT_ID environment variables, or use
|
|
31
45
|
--neon-api-key and --neon-project-id CLI options.
|
|
@@ -322,6 +336,417 @@ class NeonBranch:
|
|
|
322
336
|
connection_string: str
|
|
323
337
|
host: str
|
|
324
338
|
parent_id: str | None = None
|
|
339
|
+
endpoint_id: str | None = None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@dataclass
|
|
343
|
+
class NeonConfig:
|
|
344
|
+
"""Configuration for Neon operations. Extracted from pytest config."""
|
|
345
|
+
|
|
346
|
+
api_key: str
|
|
347
|
+
project_id: str
|
|
348
|
+
parent_branch_id: str | None
|
|
349
|
+
database_name: str
|
|
350
|
+
role_name: str
|
|
351
|
+
keep_branches: bool
|
|
352
|
+
branch_expiry: int
|
|
353
|
+
env_var_name: str
|
|
354
|
+
|
|
355
|
+
@classmethod
|
|
356
|
+
def from_pytest_config(cls, config: pytest.Config) -> NeonConfig | None:
|
|
357
|
+
"""
|
|
358
|
+
Extract NeonConfig from pytest configuration.
|
|
359
|
+
|
|
360
|
+
Returns None if required values (api_key, project_id) are missing,
|
|
361
|
+
allowing callers to skip tests gracefully.
|
|
362
|
+
"""
|
|
363
|
+
api_key = _get_config_value(
|
|
364
|
+
config, "neon_api_key", "NEON_API_KEY", "neon_api_key"
|
|
365
|
+
)
|
|
366
|
+
project_id = _get_config_value(
|
|
367
|
+
config, "neon_project_id", "NEON_PROJECT_ID", "neon_project_id"
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
if not api_key or not project_id:
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
parent_branch_id = _get_config_value(
|
|
374
|
+
config, "neon_parent_branch", "NEON_PARENT_BRANCH_ID", "neon_parent_branch"
|
|
375
|
+
)
|
|
376
|
+
database_name = _get_config_value(
|
|
377
|
+
config, "neon_database", "NEON_DATABASE", "neon_database", "neondb"
|
|
378
|
+
)
|
|
379
|
+
role_name = _get_config_value(
|
|
380
|
+
config, "neon_role", "NEON_ROLE", "neon_role", "neondb_owner"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
keep_branches = config.getoption("neon_keep_branches", default=None)
|
|
384
|
+
if keep_branches is None:
|
|
385
|
+
keep_branches = config.getini("neon_keep_branches")
|
|
386
|
+
|
|
387
|
+
branch_expiry = config.getoption("neon_branch_expiry", default=None)
|
|
388
|
+
if branch_expiry is None:
|
|
389
|
+
branch_expiry = int(config.getini("neon_branch_expiry"))
|
|
390
|
+
|
|
391
|
+
env_var_name = _get_config_value(
|
|
392
|
+
config, "neon_env_var", "", "neon_env_var", "DATABASE_URL"
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
return cls(
|
|
396
|
+
api_key=api_key,
|
|
397
|
+
project_id=project_id,
|
|
398
|
+
parent_branch_id=parent_branch_id,
|
|
399
|
+
database_name=database_name or "neondb",
|
|
400
|
+
role_name=role_name or "neondb_owner",
|
|
401
|
+
keep_branches=bool(keep_branches),
|
|
402
|
+
branch_expiry=branch_expiry or DEFAULT_BRANCH_EXPIRY_SECONDS,
|
|
403
|
+
env_var_name=env_var_name or "DATABASE_URL",
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class NeonBranchManager:
|
|
408
|
+
"""
|
|
409
|
+
Manages Neon branch lifecycle operations.
|
|
410
|
+
|
|
411
|
+
This class encapsulates all Neon API interactions for branch management,
|
|
412
|
+
making it easier to test and reason about branch operations.
|
|
413
|
+
"""
|
|
414
|
+
|
|
415
|
+
def __init__(self, config: NeonConfig):
|
|
416
|
+
self.config = config
|
|
417
|
+
self._neon = NeonAPI(api_key=config.api_key)
|
|
418
|
+
self._default_branch_id: str | None = None
|
|
419
|
+
self._default_branch_id_fetched = False
|
|
420
|
+
|
|
421
|
+
def get_default_branch_id(self) -> str | None:
|
|
422
|
+
"""Get the default/primary branch ID (cached)."""
|
|
423
|
+
if not self._default_branch_id_fetched:
|
|
424
|
+
self._default_branch_id = _get_default_branch_id(
|
|
425
|
+
self._neon, self.config.project_id
|
|
426
|
+
)
|
|
427
|
+
self._default_branch_id_fetched = True
|
|
428
|
+
return self._default_branch_id
|
|
429
|
+
|
|
430
|
+
def create_branch(
|
|
431
|
+
self,
|
|
432
|
+
name_suffix: str = "",
|
|
433
|
+
parent_branch_id: str | None = None,
|
|
434
|
+
expiry_seconds: int | None = None,
|
|
435
|
+
) -> NeonBranch:
|
|
436
|
+
"""
|
|
437
|
+
Create a new Neon branch with a read_write endpoint.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
name_suffix: Suffix to add to branch name (e.g., "-migration", "-dirty")
|
|
441
|
+
parent_branch_id: Parent branch ID (defaults to config's parent)
|
|
442
|
+
expiry_seconds: Branch expiry in seconds (0 or None for no expiry)
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
NeonBranch with connection details
|
|
446
|
+
"""
|
|
447
|
+
parent_id = parent_branch_id or self.config.parent_branch_id
|
|
448
|
+
|
|
449
|
+
# Generate unique branch name
|
|
450
|
+
random_suffix = os.urandom(2).hex()
|
|
451
|
+
git_branch = _get_git_branch_name()
|
|
452
|
+
if git_branch:
|
|
453
|
+
git_prefix = git_branch[:15]
|
|
454
|
+
branch_name = f"pytest-{git_prefix}-{random_suffix}{name_suffix}"
|
|
455
|
+
else:
|
|
456
|
+
branch_name = f"pytest-{random_suffix}{name_suffix}"
|
|
457
|
+
|
|
458
|
+
# Build branch config
|
|
459
|
+
branch_config: dict[str, Any] = {"name": branch_name}
|
|
460
|
+
if parent_id:
|
|
461
|
+
branch_config["parent_id"] = parent_id
|
|
462
|
+
|
|
463
|
+
# Set expiry if specified
|
|
464
|
+
if expiry_seconds and expiry_seconds > 0:
|
|
465
|
+
expires_at = datetime.now(timezone.utc) + timedelta(seconds=expiry_seconds)
|
|
466
|
+
branch_config["expires_at"] = expires_at.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
467
|
+
|
|
468
|
+
# Create branch with read_write endpoint
|
|
469
|
+
result = _retry_on_rate_limit(
|
|
470
|
+
lambda: self._neon.branch_create(
|
|
471
|
+
project_id=self.config.project_id,
|
|
472
|
+
branch=branch_config,
|
|
473
|
+
endpoints=[{"type": "read_write"}],
|
|
474
|
+
),
|
|
475
|
+
operation_name="branch_create",
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
branch = result.branch
|
|
479
|
+
endpoint_id = None
|
|
480
|
+
for op in result.operations:
|
|
481
|
+
if op.endpoint_id:
|
|
482
|
+
endpoint_id = op.endpoint_id
|
|
483
|
+
break
|
|
484
|
+
|
|
485
|
+
if not endpoint_id:
|
|
486
|
+
raise RuntimeError(f"No endpoint created for branch {branch.id}")
|
|
487
|
+
|
|
488
|
+
# Wait for endpoint to be active
|
|
489
|
+
host = self._wait_for_endpoint(endpoint_id)
|
|
490
|
+
|
|
491
|
+
# Safety check: never operate on default branch
|
|
492
|
+
default_branch_id = self.get_default_branch_id()
|
|
493
|
+
if default_branch_id and branch.id == default_branch_id:
|
|
494
|
+
raise RuntimeError(
|
|
495
|
+
f"SAFETY CHECK FAILED: Attempted to operate on default branch "
|
|
496
|
+
f"{branch.id}. Please report this bug."
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
# Get password
|
|
500
|
+
connection_string = self._reset_password_and_build_connection_string(
|
|
501
|
+
branch.id, host
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
return NeonBranch(
|
|
505
|
+
branch_id=branch.id,
|
|
506
|
+
project_id=self.config.project_id,
|
|
507
|
+
connection_string=connection_string,
|
|
508
|
+
host=host,
|
|
509
|
+
parent_id=branch.parent_id,
|
|
510
|
+
endpoint_id=endpoint_id,
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def create_readonly_endpoint(self, branch: NeonBranch) -> NeonBranch:
|
|
514
|
+
"""
|
|
515
|
+
Create a read_only endpoint on an existing branch.
|
|
516
|
+
|
|
517
|
+
This creates a true read-only endpoint that enforces no writes at the
|
|
518
|
+
database level.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
branch: The branch to create the endpoint on
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
NeonBranch with the read_only endpoint's connection details
|
|
525
|
+
"""
|
|
526
|
+
result = _retry_on_rate_limit(
|
|
527
|
+
lambda: self._neon.endpoint_create(
|
|
528
|
+
project_id=self.config.project_id,
|
|
529
|
+
endpoint={
|
|
530
|
+
"branch_id": branch.branch_id,
|
|
531
|
+
"type": "read_only",
|
|
532
|
+
},
|
|
533
|
+
),
|
|
534
|
+
operation_name="endpoint_create_readonly",
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
endpoint_id = result.endpoint.id
|
|
538
|
+
host = self._wait_for_endpoint(endpoint_id)
|
|
539
|
+
|
|
540
|
+
# Get password for the read_only endpoint
|
|
541
|
+
connection_string = self._reset_password_and_build_connection_string(
|
|
542
|
+
branch.branch_id, host
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
return NeonBranch(
|
|
546
|
+
branch_id=branch.branch_id,
|
|
547
|
+
project_id=self.config.project_id,
|
|
548
|
+
connection_string=connection_string,
|
|
549
|
+
host=host,
|
|
550
|
+
parent_id=branch.parent_id,
|
|
551
|
+
endpoint_id=endpoint_id,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
def delete_branch(self, branch_id: str) -> None:
|
|
555
|
+
"""Delete a branch (silently ignores errors)."""
|
|
556
|
+
if self.config.keep_branches:
|
|
557
|
+
return
|
|
558
|
+
try:
|
|
559
|
+
_retry_on_rate_limit(
|
|
560
|
+
lambda: self._neon.branch_delete(
|
|
561
|
+
project_id=self.config.project_id, branch_id=branch_id
|
|
562
|
+
),
|
|
563
|
+
operation_name="branch_delete",
|
|
564
|
+
)
|
|
565
|
+
except Exception as e:
|
|
566
|
+
msg = f"Failed to delete Neon branch {branch_id}: {e}"
|
|
567
|
+
warnings.warn(msg, stacklevel=2)
|
|
568
|
+
|
|
569
|
+
def delete_endpoint(self, endpoint_id: str) -> None:
|
|
570
|
+
"""Delete an endpoint (silently ignores errors)."""
|
|
571
|
+
try:
|
|
572
|
+
_retry_on_rate_limit(
|
|
573
|
+
lambda: self._neon.endpoint_delete(
|
|
574
|
+
project_id=self.config.project_id, endpoint_id=endpoint_id
|
|
575
|
+
),
|
|
576
|
+
operation_name="endpoint_delete",
|
|
577
|
+
)
|
|
578
|
+
except Exception as e:
|
|
579
|
+
warnings.warn(
|
|
580
|
+
f"Failed to delete Neon endpoint {endpoint_id}: {e}", stacklevel=2
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
def reset_branch(self, branch: NeonBranch) -> None:
|
|
584
|
+
"""Reset a branch to its parent's state."""
|
|
585
|
+
if not branch.parent_id:
|
|
586
|
+
msg = f"Branch {branch.branch_id} has no parent - cannot reset"
|
|
587
|
+
raise RuntimeError(msg)
|
|
588
|
+
|
|
589
|
+
_reset_branch_to_parent(branch, self.config.api_key)
|
|
590
|
+
|
|
591
|
+
def _wait_for_endpoint(self, endpoint_id: str, max_wait_seconds: float = 60) -> str:
|
|
592
|
+
"""Wait for endpoint to become active and return its host."""
|
|
593
|
+
poll_interval = 0.5
|
|
594
|
+
waited = 0.0
|
|
595
|
+
|
|
596
|
+
while True:
|
|
597
|
+
endpoint_response = _retry_on_rate_limit(
|
|
598
|
+
lambda: self._neon.endpoint(
|
|
599
|
+
project_id=self.config.project_id, endpoint_id=endpoint_id
|
|
600
|
+
),
|
|
601
|
+
operation_name="endpoint_status",
|
|
602
|
+
)
|
|
603
|
+
endpoint = endpoint_response.endpoint
|
|
604
|
+
state = endpoint.current_state
|
|
605
|
+
|
|
606
|
+
if state == EndpointState.active:
|
|
607
|
+
return endpoint.host
|
|
608
|
+
|
|
609
|
+
if waited >= max_wait_seconds:
|
|
610
|
+
raise RuntimeError(
|
|
611
|
+
f"Timeout waiting for endpoint {endpoint_id} to become active "
|
|
612
|
+
f"(current state: {state})"
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
time.sleep(poll_interval)
|
|
616
|
+
waited += poll_interval
|
|
617
|
+
|
|
618
|
+
def _reset_password_and_build_connection_string(
|
|
619
|
+
self, branch_id: str, host: str
|
|
620
|
+
) -> str:
|
|
621
|
+
"""Reset role password and build connection string."""
|
|
622
|
+
password_response = _retry_on_rate_limit(
|
|
623
|
+
lambda: self._neon.role_password_reset(
|
|
624
|
+
project_id=self.config.project_id,
|
|
625
|
+
branch_id=branch_id,
|
|
626
|
+
role_name=self.config.role_name,
|
|
627
|
+
),
|
|
628
|
+
operation_name="role_password_reset",
|
|
629
|
+
)
|
|
630
|
+
password = password_response.role.password
|
|
631
|
+
|
|
632
|
+
return (
|
|
633
|
+
f"postgresql://{self.config.role_name}:{password}@{host}/"
|
|
634
|
+
f"{self.config.database_name}?sslmode=require"
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
class XdistCoordinator:
|
|
639
|
+
"""
|
|
640
|
+
Coordinates branch sharing across pytest-xdist workers.
|
|
641
|
+
|
|
642
|
+
Uses file locks and JSON cache files to ensure only one worker creates
|
|
643
|
+
shared resources (like the migration branch), while others reuse them.
|
|
644
|
+
"""
|
|
645
|
+
|
|
646
|
+
def __init__(self, tmp_path_factory: pytest.TempPathFactory):
|
|
647
|
+
self.worker_id = _get_xdist_worker_id()
|
|
648
|
+
self.is_xdist = self.worker_id != "main"
|
|
649
|
+
|
|
650
|
+
if self.is_xdist:
|
|
651
|
+
root_tmp_dir = tmp_path_factory.getbasetemp().parent
|
|
652
|
+
self._lock_dir = root_tmp_dir
|
|
653
|
+
else:
|
|
654
|
+
self._lock_dir = None
|
|
655
|
+
|
|
656
|
+
def coordinate_resource(
|
|
657
|
+
self,
|
|
658
|
+
resource_name: str,
|
|
659
|
+
create_fn: Callable[[], dict[str, Any]],
|
|
660
|
+
) -> tuple[dict[str, Any], bool]:
|
|
661
|
+
"""
|
|
662
|
+
Coordinate creation of a shared resource across workers.
|
|
663
|
+
|
|
664
|
+
Args:
|
|
665
|
+
resource_name: Name of the resource (used for cache/lock files)
|
|
666
|
+
create_fn: Function to create the resource, returns dict to cache
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
Tuple of (cached_data, is_creator)
|
|
670
|
+
"""
|
|
671
|
+
if not self.is_xdist:
|
|
672
|
+
return create_fn(), True
|
|
673
|
+
|
|
674
|
+
assert self._lock_dir is not None
|
|
675
|
+
cache_file = self._lock_dir / f"neon_{resource_name}.json"
|
|
676
|
+
lock_file = self._lock_dir / f"neon_{resource_name}.lock"
|
|
677
|
+
|
|
678
|
+
with FileLock(str(lock_file)):
|
|
679
|
+
if cache_file.exists():
|
|
680
|
+
data = json.loads(cache_file.read_text())
|
|
681
|
+
return data, False
|
|
682
|
+
else:
|
|
683
|
+
data = create_fn()
|
|
684
|
+
cache_file.write_text(json.dumps(data))
|
|
685
|
+
return data, True
|
|
686
|
+
|
|
687
|
+
def wait_for_signal(self, signal_name: str, timeout: float = 60) -> None:
|
|
688
|
+
"""Wait for a signal file to be created by another worker."""
|
|
689
|
+
if not self.is_xdist or self._lock_dir is None:
|
|
690
|
+
return
|
|
691
|
+
|
|
692
|
+
signal_file = self._lock_dir / f"neon_{signal_name}"
|
|
693
|
+
waited = 0.0
|
|
694
|
+
poll_interval = 0.5
|
|
695
|
+
|
|
696
|
+
while not signal_file.exists():
|
|
697
|
+
if waited >= timeout:
|
|
698
|
+
raise RuntimeError(
|
|
699
|
+
f"Worker {self.worker_id} timed out waiting for signal "
|
|
700
|
+
f"'{signal_name}' after {timeout}s. This usually means the "
|
|
701
|
+
f"creator worker failed or is still processing."
|
|
702
|
+
)
|
|
703
|
+
time.sleep(poll_interval)
|
|
704
|
+
waited += poll_interval
|
|
705
|
+
|
|
706
|
+
def send_signal(self, signal_name: str) -> None:
|
|
707
|
+
"""Create a signal file for other workers."""
|
|
708
|
+
if not self.is_xdist or self._lock_dir is None:
|
|
709
|
+
return
|
|
710
|
+
|
|
711
|
+
signal_file = self._lock_dir / f"neon_{signal_name}"
|
|
712
|
+
signal_file.write_text("done")
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
class EnvironmentManager:
|
|
716
|
+
"""Manages DATABASE_URL environment variable lifecycle."""
|
|
717
|
+
|
|
718
|
+
def __init__(self, env_var_name: str = "DATABASE_URL"):
|
|
719
|
+
self.env_var_name = env_var_name
|
|
720
|
+
self._original_value: str | None = None
|
|
721
|
+
self._is_set = False
|
|
722
|
+
|
|
723
|
+
def set(self, connection_string: str) -> None:
|
|
724
|
+
"""Set the environment variable, saving original value."""
|
|
725
|
+
if not self._is_set:
|
|
726
|
+
self._original_value = os.environ.get(self.env_var_name)
|
|
727
|
+
self._is_set = True
|
|
728
|
+
os.environ[self.env_var_name] = connection_string
|
|
729
|
+
|
|
730
|
+
def restore(self) -> None:
|
|
731
|
+
"""Restore the original environment variable value."""
|
|
732
|
+
if not self._is_set:
|
|
733
|
+
return
|
|
734
|
+
|
|
735
|
+
if self._original_value is None:
|
|
736
|
+
os.environ.pop(self.env_var_name, None)
|
|
737
|
+
else:
|
|
738
|
+
os.environ[self.env_var_name] = self._original_value
|
|
739
|
+
|
|
740
|
+
self._is_set = False
|
|
741
|
+
|
|
742
|
+
@contextlib.contextmanager
|
|
743
|
+
def temporary(self, connection_string: str) -> Generator[None, None, None]:
|
|
744
|
+
"""Context manager for temporary environment variable."""
|
|
745
|
+
self.set(connection_string)
|
|
746
|
+
try:
|
|
747
|
+
yield
|
|
748
|
+
finally:
|
|
749
|
+
self.restore()
|
|
325
750
|
|
|
326
751
|
|
|
327
752
|
def _get_default_branch_id(neon: NeonAPI, project_id: str) -> str | None:
|
|
@@ -642,6 +1067,7 @@ def _create_neon_branch(
|
|
|
642
1067
|
connection_string=connection_string,
|
|
643
1068
|
host=host,
|
|
644
1069
|
parent_id=branch.parent_id,
|
|
1070
|
+
endpoint_id=endpoint_id,
|
|
645
1071
|
)
|
|
646
1072
|
|
|
647
1073
|
# Set DATABASE_URL (or configured env var) for the duration of the fixture scope
|
|
@@ -676,6 +1102,117 @@ def _create_neon_branch(
|
|
|
676
1102
|
)
|
|
677
1103
|
|
|
678
1104
|
|
|
1105
|
+
def _create_readonly_endpoint(
|
|
1106
|
+
branch: NeonBranch,
|
|
1107
|
+
api_key: str,
|
|
1108
|
+
database_name: str,
|
|
1109
|
+
role_name: str,
|
|
1110
|
+
) -> NeonBranch:
|
|
1111
|
+
"""
|
|
1112
|
+
Create a read_only endpoint on an existing branch.
|
|
1113
|
+
|
|
1114
|
+
Returns a new NeonBranch object with the read_only endpoint's connection string.
|
|
1115
|
+
The read_only endpoint enforces that no writes can be made through this connection.
|
|
1116
|
+
|
|
1117
|
+
Args:
|
|
1118
|
+
branch: The branch to create a read_only endpoint on
|
|
1119
|
+
api_key: Neon API key
|
|
1120
|
+
database_name: Database name for connection string
|
|
1121
|
+
role_name: Role name for connection string
|
|
1122
|
+
|
|
1123
|
+
Returns:
|
|
1124
|
+
NeonBranch with read_only endpoint connection details
|
|
1125
|
+
"""
|
|
1126
|
+
neon = NeonAPI(api_key=api_key)
|
|
1127
|
+
|
|
1128
|
+
# Create read_only endpoint on the branch
|
|
1129
|
+
# See: https://api-docs.neon.tech/reference/createprojectendpoint
|
|
1130
|
+
result = _retry_on_rate_limit(
|
|
1131
|
+
lambda: neon.endpoint_create(
|
|
1132
|
+
project_id=branch.project_id,
|
|
1133
|
+
endpoint={
|
|
1134
|
+
"branch_id": branch.branch_id,
|
|
1135
|
+
"type": "read_only",
|
|
1136
|
+
},
|
|
1137
|
+
),
|
|
1138
|
+
operation_name="endpoint_create_readonly",
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
endpoint = result.endpoint
|
|
1142
|
+
endpoint_id = endpoint.id
|
|
1143
|
+
|
|
1144
|
+
# Wait for endpoint to be ready
|
|
1145
|
+
max_wait_seconds = 60
|
|
1146
|
+
poll_interval = 0.5
|
|
1147
|
+
waited = 0.0
|
|
1148
|
+
|
|
1149
|
+
while True:
|
|
1150
|
+
endpoint_response = _retry_on_rate_limit(
|
|
1151
|
+
lambda: neon.endpoint(
|
|
1152
|
+
project_id=branch.project_id, endpoint_id=endpoint_id
|
|
1153
|
+
),
|
|
1154
|
+
operation_name="endpoint_status_readonly",
|
|
1155
|
+
)
|
|
1156
|
+
endpoint = endpoint_response.endpoint
|
|
1157
|
+
state = endpoint.current_state
|
|
1158
|
+
|
|
1159
|
+
if state == EndpointState.active:
|
|
1160
|
+
break
|
|
1161
|
+
|
|
1162
|
+
if waited >= max_wait_seconds:
|
|
1163
|
+
raise RuntimeError(
|
|
1164
|
+
f"Timeout waiting for read_only endpoint {endpoint_id} "
|
|
1165
|
+
f"to become active (current state: {state})"
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
time.sleep(poll_interval)
|
|
1169
|
+
waited += poll_interval
|
|
1170
|
+
|
|
1171
|
+
host = endpoint.host
|
|
1172
|
+
|
|
1173
|
+
# Reset password to get the password value for this endpoint
|
|
1174
|
+
password_response = _retry_on_rate_limit(
|
|
1175
|
+
lambda: neon.role_password_reset(
|
|
1176
|
+
project_id=branch.project_id,
|
|
1177
|
+
branch_id=branch.branch_id,
|
|
1178
|
+
role_name=role_name,
|
|
1179
|
+
),
|
|
1180
|
+
operation_name="role_password_reset_readonly",
|
|
1181
|
+
)
|
|
1182
|
+
password = password_response.role.password
|
|
1183
|
+
|
|
1184
|
+
# Build connection string for the read_only endpoint
|
|
1185
|
+
connection_string = (
|
|
1186
|
+
f"postgresql://{role_name}:{password}@{host}/{database_name}?sslmode=require"
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
return NeonBranch(
|
|
1190
|
+
branch_id=branch.branch_id,
|
|
1191
|
+
project_id=branch.project_id,
|
|
1192
|
+
connection_string=connection_string,
|
|
1193
|
+
host=host,
|
|
1194
|
+
parent_id=branch.parent_id,
|
|
1195
|
+
endpoint_id=endpoint_id,
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def _delete_endpoint(project_id: str, endpoint_id: str, api_key: str) -> None:
|
|
1200
|
+
"""Delete a Neon endpoint."""
|
|
1201
|
+
neon = NeonAPI(api_key=api_key)
|
|
1202
|
+
try:
|
|
1203
|
+
_retry_on_rate_limit(
|
|
1204
|
+
lambda: neon.endpoint_delete(
|
|
1205
|
+
project_id=project_id, endpoint_id=endpoint_id
|
|
1206
|
+
),
|
|
1207
|
+
operation_name="endpoint_delete",
|
|
1208
|
+
)
|
|
1209
|
+
except Exception as e:
|
|
1210
|
+
warnings.warn(
|
|
1211
|
+
f"Failed to delete Neon endpoint {endpoint_id}: {e}",
|
|
1212
|
+
stacklevel=2,
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
|
|
679
1216
|
def _reset_branch_to_parent(branch: NeonBranch, api_key: str) -> None:
|
|
680
1217
|
"""Reset a branch to its parent's state using the Neon API.
|
|
681
1218
|
|
|
@@ -822,17 +1359,49 @@ def _dict_to_branch(data: dict[str, Any]) -> NeonBranch:
|
|
|
822
1359
|
_MIGRATION_WAIT_TIMEOUT = 300 # 5 minutes
|
|
823
1360
|
|
|
824
1361
|
|
|
1362
|
+
@pytest.fixture(scope="session")
|
|
1363
|
+
def _neon_config(request: pytest.FixtureRequest) -> NeonConfig:
|
|
1364
|
+
"""
|
|
1365
|
+
Session-scoped Neon configuration extracted from pytest config.
|
|
1366
|
+
|
|
1367
|
+
Skips tests if required configuration (api_key, project_id) is missing.
|
|
1368
|
+
"""
|
|
1369
|
+
config = NeonConfig.from_pytest_config(request.config)
|
|
1370
|
+
if config is None:
|
|
1371
|
+
pytest.skip(
|
|
1372
|
+
"Neon configuration missing. Set NEON_API_KEY and NEON_PROJECT_ID "
|
|
1373
|
+
"environment variables or use --neon-api-key and --neon-project-id."
|
|
1374
|
+
)
|
|
1375
|
+
return config
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
@pytest.fixture(scope="session")
|
|
1379
|
+
def _neon_branch_manager(_neon_config: NeonConfig) -> NeonBranchManager:
|
|
1380
|
+
"""Session-scoped branch manager for Neon operations."""
|
|
1381
|
+
return NeonBranchManager(_neon_config)
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
@pytest.fixture(scope="session")
|
|
1385
|
+
def _neon_xdist_coordinator(
|
|
1386
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
1387
|
+
) -> XdistCoordinator:
|
|
1388
|
+
"""Session-scoped coordinator for xdist worker synchronization."""
|
|
1389
|
+
return XdistCoordinator(tmp_path_factory)
|
|
1390
|
+
|
|
1391
|
+
|
|
825
1392
|
@pytest.fixture(scope="session")
|
|
826
1393
|
def _neon_migration_branch(
|
|
827
1394
|
request: pytest.FixtureRequest,
|
|
828
|
-
|
|
1395
|
+
_neon_config: NeonConfig,
|
|
1396
|
+
_neon_branch_manager: NeonBranchManager,
|
|
1397
|
+
_neon_xdist_coordinator: XdistCoordinator,
|
|
829
1398
|
) -> Generator[NeonBranch, None, None]:
|
|
830
1399
|
"""
|
|
831
1400
|
Session-scoped branch where migrations are applied.
|
|
832
1401
|
|
|
833
|
-
This branch is created from the configured parent and serves as
|
|
834
|
-
the parent for all test branches
|
|
835
|
-
on this branch.
|
|
1402
|
+
This branch is ALWAYS created from the configured parent and serves as
|
|
1403
|
+
the parent for all test branches (dirty, isolated, readonly endpoint).
|
|
1404
|
+
Migrations run once per session on this branch.
|
|
836
1405
|
|
|
837
1406
|
pytest-xdist Support:
|
|
838
1407
|
When running with pytest-xdist, the first worker to acquire the lock
|
|
@@ -844,108 +1413,43 @@ def _neon_migration_branch(
|
|
|
844
1413
|
Note: The migration branch cannot have an expiry because Neon doesn't
|
|
845
1414
|
allow creating child branches from branches with expiration dates.
|
|
846
1415
|
Cleanup relies on the fixture teardown at session end.
|
|
847
|
-
|
|
848
|
-
Smart Migration Detection:
|
|
849
|
-
Before yielding, this fixture captures a schema fingerprint and stores
|
|
850
|
-
it on request.config. After migrations run, _neon_branch_for_reset
|
|
851
|
-
compares the fingerprint to detect if the schema actually changed.
|
|
852
1416
|
"""
|
|
853
|
-
|
|
854
|
-
worker_id = _get_xdist_worker_id()
|
|
855
|
-
is_xdist = worker_id != "main"
|
|
856
|
-
|
|
857
|
-
# Get env var name for DATABASE_URL
|
|
858
|
-
env_var_name = _get_config_value(
|
|
859
|
-
config, "neon_env_var", "", "neon_env_var", "DATABASE_URL"
|
|
860
|
-
)
|
|
861
|
-
|
|
862
|
-
# For xdist, use shared temp directory and filelock
|
|
863
|
-
# tmp_path_factory.getbasetemp().parent is shared across all workers
|
|
864
|
-
if is_xdist:
|
|
865
|
-
root_tmp_dir = tmp_path_factory.getbasetemp().parent
|
|
866
|
-
cache_file = root_tmp_dir / "neon_migration_branch.json"
|
|
867
|
-
lock_file = root_tmp_dir / "neon_migration_branch.lock"
|
|
868
|
-
else:
|
|
869
|
-
cache_file = None
|
|
870
|
-
lock_file = None
|
|
871
|
-
|
|
872
|
-
is_creator = False
|
|
1417
|
+
env_manager = EnvironmentManager(_neon_config.env_var_name)
|
|
873
1418
|
branch: NeonBranch
|
|
874
|
-
|
|
875
|
-
original_env_value: str | None = None
|
|
1419
|
+
is_creator: bool
|
|
876
1420
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
branch = _dict_to_branch(data["branch"])
|
|
884
|
-
pre_migration_fingerprint = tuple(
|
|
885
|
-
tuple(row) for row in data["pre_migration_fingerprint"]
|
|
886
|
-
)
|
|
887
|
-
config._neon_pre_migration_fingerprint = pre_migration_fingerprint # type: ignore[attr-defined]
|
|
1421
|
+
def create_migration_branch() -> dict[str, Any]:
|
|
1422
|
+
b = _neon_branch_manager.create_branch(
|
|
1423
|
+
name_suffix="-migration",
|
|
1424
|
+
expiry_seconds=0, # No expiry - child branches need this
|
|
1425
|
+
)
|
|
1426
|
+
return {"branch": _branch_to_dict(b)}
|
|
888
1427
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
is_creator = True
|
|
895
|
-
branch_gen = _create_neon_branch(
|
|
896
|
-
request,
|
|
897
|
-
branch_expiry_override=0,
|
|
898
|
-
branch_name_suffix="-migrated",
|
|
899
|
-
)
|
|
900
|
-
branch = next(branch_gen)
|
|
1428
|
+
# Coordinate branch creation across xdist workers
|
|
1429
|
+
data, is_creator = _neon_xdist_coordinator.coordinate_resource(
|
|
1430
|
+
"migration_branch", create_migration_branch
|
|
1431
|
+
)
|
|
1432
|
+
branch = _dict_to_branch(data["branch"])
|
|
901
1433
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
branch.connection_string
|
|
905
|
-
)
|
|
906
|
-
config._neon_pre_migration_fingerprint = pre_migration_fingerprint # type: ignore[attr-defined]
|
|
907
|
-
|
|
908
|
-
# Cache for other workers (they'll read this after lock released)
|
|
909
|
-
# Note: We cache now with pre-migration fingerprint. The branch
|
|
910
|
-
# content will have migrations applied by neon_apply_migrations.
|
|
911
|
-
cache_file.write_text(
|
|
912
|
-
json.dumps(
|
|
913
|
-
{
|
|
914
|
-
"branch": _branch_to_dict(branch),
|
|
915
|
-
"pre_migration_fingerprint": pre_migration_fingerprint,
|
|
916
|
-
}
|
|
917
|
-
)
|
|
918
|
-
)
|
|
919
|
-
else:
|
|
920
|
-
# Not using xdist - create branch normally
|
|
921
|
-
is_creator = True
|
|
922
|
-
branch_gen = _create_neon_branch(
|
|
923
|
-
request,
|
|
924
|
-
branch_expiry_override=0,
|
|
925
|
-
branch_name_suffix="-migrated",
|
|
926
|
-
)
|
|
927
|
-
branch = next(branch_gen)
|
|
1434
|
+
# Store creator status for other fixtures
|
|
1435
|
+
request.config._neon_is_migration_creator = is_creator # type: ignore[attr-defined]
|
|
928
1436
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
config._neon_pre_migration_fingerprint = pre_migration_fingerprint # type: ignore[attr-defined]
|
|
1437
|
+
# Set DATABASE_URL
|
|
1438
|
+
env_manager.set(branch.connection_string)
|
|
932
1439
|
|
|
933
|
-
#
|
|
934
|
-
|
|
1440
|
+
# Non-creators wait for migrations to complete
|
|
1441
|
+
if not is_creator:
|
|
1442
|
+
_neon_xdist_coordinator.wait_for_signal(
|
|
1443
|
+
"migrations_done", timeout=_MIGRATION_WAIT_TIMEOUT
|
|
1444
|
+
)
|
|
935
1445
|
|
|
936
1446
|
try:
|
|
937
1447
|
yield branch
|
|
938
1448
|
finally:
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
os.environ.pop(env_var_name, None)
|
|
944
|
-
|
|
945
|
-
# Only the creator cleans up the branch
|
|
946
|
-
if is_creator and branch_gen is not None:
|
|
947
|
-
with contextlib.suppress(StopIteration):
|
|
948
|
-
next(branch_gen)
|
|
1449
|
+
env_manager.restore()
|
|
1450
|
+
# Only creator cleans up
|
|
1451
|
+
if is_creator:
|
|
1452
|
+
_neon_branch_manager.delete_branch(branch.branch_id)
|
|
949
1453
|
|
|
950
1454
|
|
|
951
1455
|
@pytest.fixture(scope="session")
|
|
@@ -1007,234 +1511,303 @@ def neon_apply_migrations(_neon_migration_branch: NeonBranch) -> Any:
|
|
|
1007
1511
|
@pytest.fixture(scope="session")
|
|
1008
1512
|
def _neon_migrations_synchronized(
|
|
1009
1513
|
request: pytest.FixtureRequest,
|
|
1010
|
-
tmp_path_factory: pytest.TempPathFactory,
|
|
1011
1514
|
_neon_migration_branch: NeonBranch,
|
|
1515
|
+
_neon_xdist_coordinator: XdistCoordinator,
|
|
1012
1516
|
neon_apply_migrations: Any,
|
|
1013
1517
|
) -> Any:
|
|
1014
1518
|
"""
|
|
1015
1519
|
Internal fixture that synchronizes migrations across xdist workers.
|
|
1016
1520
|
|
|
1017
1521
|
This fixture ensures that:
|
|
1018
|
-
1. Only the creator worker runs migrations
|
|
1019
|
-
|
|
1522
|
+
1. Only the creator worker runs migrations (non-creators wait in
|
|
1523
|
+
_neon_migration_branch BEFORE neon_apply_migrations runs)
|
|
1524
|
+
2. Creator signals completion after migrations finish
|
|
1020
1525
|
3. The return value from neon_apply_migrations is preserved for detection
|
|
1021
1526
|
|
|
1022
1527
|
Without xdist, this is a simple passthrough.
|
|
1023
1528
|
"""
|
|
1024
|
-
|
|
1025
|
-
worker_id = _get_xdist_worker_id()
|
|
1026
|
-
is_xdist = worker_id != "main"
|
|
1027
|
-
is_creator = getattr(config, "_neon_is_migration_creator", True)
|
|
1028
|
-
|
|
1029
|
-
if not is_xdist:
|
|
1030
|
-
# Not using xdist - migrations already ran, just return the value
|
|
1031
|
-
return neon_apply_migrations
|
|
1032
|
-
|
|
1033
|
-
# For xdist, use a signal file to coordinate
|
|
1034
|
-
root_tmp_dir = tmp_path_factory.getbasetemp().parent
|
|
1035
|
-
migrations_done_file = root_tmp_dir / "neon_migrations_done"
|
|
1036
|
-
migrations_lock_file = root_tmp_dir / "neon_migrations.lock"
|
|
1529
|
+
is_creator = getattr(request.config, "_neon_is_migration_creator", True)
|
|
1037
1530
|
|
|
1038
1531
|
if is_creator:
|
|
1039
1532
|
# Creator: migrations just ran via neon_apply_migrations dependency
|
|
1040
1533
|
# Signal completion to other workers
|
|
1041
|
-
|
|
1042
|
-
migrations_done_file.write_text("done")
|
|
1043
|
-
return neon_apply_migrations
|
|
1044
|
-
else:
|
|
1045
|
-
# Non-creator: wait for migrations to complete
|
|
1046
|
-
# The neon_apply_migrations fixture still runs but on already-migrated DB
|
|
1047
|
-
# (most migration tools handle this gracefully as a no-op)
|
|
1048
|
-
waited = 0.0
|
|
1049
|
-
poll_interval = 0.5
|
|
1050
|
-
while not migrations_done_file.exists():
|
|
1051
|
-
if waited >= _MIGRATION_WAIT_TIMEOUT:
|
|
1052
|
-
raise RuntimeError(
|
|
1053
|
-
f"Timeout waiting for migrations to complete after "
|
|
1054
|
-
f"{_MIGRATION_WAIT_TIMEOUT}s. The creator worker may have "
|
|
1055
|
-
f"failed or is still running migrations."
|
|
1056
|
-
)
|
|
1057
|
-
time.sleep(poll_interval)
|
|
1058
|
-
waited += poll_interval
|
|
1534
|
+
_neon_xdist_coordinator.send_signal("migrations_done")
|
|
1059
1535
|
|
|
1060
|
-
|
|
1536
|
+
return neon_apply_migrations
|
|
1061
1537
|
|
|
1062
1538
|
|
|
1063
1539
|
@pytest.fixture(scope="session")
|
|
1064
|
-
def
|
|
1065
|
-
|
|
1540
|
+
def _neon_dirty_branch(
|
|
1541
|
+
_neon_config: NeonConfig,
|
|
1542
|
+
_neon_branch_manager: NeonBranchManager,
|
|
1543
|
+
_neon_xdist_coordinator: XdistCoordinator,
|
|
1066
1544
|
_neon_migration_branch: NeonBranch,
|
|
1067
|
-
_neon_migrations_synchronized: Any, # Ensures migrations complete
|
|
1545
|
+
_neon_migrations_synchronized: Any, # Ensures migrations complete first
|
|
1068
1546
|
) -> Generator[NeonBranch, None, None]:
|
|
1069
1547
|
"""
|
|
1070
|
-
|
|
1548
|
+
Session-scoped dirty branch shared across ALL xdist workers.
|
|
1071
1549
|
|
|
1072
|
-
This is
|
|
1073
|
-
|
|
1074
|
-
|
|
1550
|
+
This branch is a child of the migration branch. All tests using
|
|
1551
|
+
neon_branch_dirty share this single branch - writes persist and
|
|
1552
|
+
are visible to all tests (even across workers).
|
|
1075
1553
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1554
|
+
This is the "dirty" branch because:
|
|
1555
|
+
- No reset between tests
|
|
1556
|
+
- Shared across all workers (concurrent writes possible)
|
|
1557
|
+
- Fast because no per-test overhead
|
|
1558
|
+
"""
|
|
1559
|
+
env_manager = EnvironmentManager(_neon_config.env_var_name)
|
|
1560
|
+
branch: NeonBranch
|
|
1561
|
+
is_creator: bool
|
|
1081
1562
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1563
|
+
def create_dirty_branch() -> dict[str, Any]:
|
|
1564
|
+
b = _neon_branch_manager.create_branch(
|
|
1565
|
+
name_suffix="-dirty",
|
|
1566
|
+
parent_branch_id=_neon_migration_branch.branch_id,
|
|
1567
|
+
expiry_seconds=_neon_config.branch_expiry,
|
|
1568
|
+
)
|
|
1569
|
+
return {"branch": _branch_to_dict(b)}
|
|
1084
1570
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1571
|
+
# Coordinate dirty branch creation - shared across ALL workers
|
|
1572
|
+
data, is_creator = _neon_xdist_coordinator.coordinate_resource(
|
|
1573
|
+
"dirty_branch", create_dirty_branch
|
|
1574
|
+
)
|
|
1575
|
+
branch = _dict_to_branch(data["branch"])
|
|
1576
|
+
|
|
1577
|
+
# Set DATABASE_URL
|
|
1578
|
+
env_manager.set(branch.connection_string)
|
|
1579
|
+
|
|
1580
|
+
try:
|
|
1581
|
+
yield branch
|
|
1582
|
+
finally:
|
|
1583
|
+
env_manager.restore()
|
|
1584
|
+
# Only creator cleans up
|
|
1585
|
+
if is_creator:
|
|
1586
|
+
_neon_branch_manager.delete_branch(branch.branch_id)
|
|
1087
1587
|
|
|
1088
|
-
2. If neon_apply_migrations was overridden, compare schema fingerprints
|
|
1089
|
-
before/after migrations. Only create a child branch if the schema
|
|
1090
|
-
actually changed.
|
|
1091
1588
|
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1589
|
+
@pytest.fixture(scope="session")
|
|
1590
|
+
def _neon_readonly_endpoint(
|
|
1591
|
+
_neon_config: NeonConfig,
|
|
1592
|
+
_neon_branch_manager: NeonBranchManager,
|
|
1593
|
+
_neon_xdist_coordinator: XdistCoordinator,
|
|
1594
|
+
_neon_migration_branch: NeonBranch,
|
|
1595
|
+
_neon_migrations_synchronized: Any, # Ensures migrations complete first
|
|
1596
|
+
) -> Generator[NeonBranch, None, None]:
|
|
1095
1597
|
"""
|
|
1096
|
-
|
|
1097
|
-
# _neon_migrations_synchronized passes through the neon_apply_migrations value
|
|
1098
|
-
migrations_defined = _neon_migrations_synchronized is not _MIGRATIONS_NOT_DEFINED
|
|
1099
|
-
|
|
1100
|
-
# Check if schema actually changed (if we have a pre-migration fingerprint)
|
|
1101
|
-
pre_fingerprint = getattr(request.config, "_neon_pre_migration_fingerprint", ())
|
|
1102
|
-
schema_changed = False
|
|
1103
|
-
|
|
1104
|
-
if migrations_defined and pre_fingerprint:
|
|
1105
|
-
# Compare with current schema
|
|
1106
|
-
conn_str = _neon_migration_branch.connection_string
|
|
1107
|
-
post_fingerprint = _get_schema_fingerprint(conn_str)
|
|
1108
|
-
schema_changed = pre_fingerprint != post_fingerprint
|
|
1109
|
-
elif migrations_defined and not pre_fingerprint:
|
|
1110
|
-
# No fingerprint available (no psycopg/psycopg2 installed)
|
|
1111
|
-
# Assume migrations changed something to be safe
|
|
1112
|
-
schema_changed = True
|
|
1113
|
-
|
|
1114
|
-
# Get worker ID for parallel test support
|
|
1115
|
-
# Each xdist worker gets its own branch to avoid state pollution
|
|
1116
|
-
worker_id = _get_xdist_worker_id()
|
|
1117
|
-
branch_suffix = f"-test-{worker_id}"
|
|
1118
|
-
|
|
1119
|
-
# Only create a child branch if migrations actually modified the schema
|
|
1120
|
-
# OR if we're running under xdist (each worker needs its own branch)
|
|
1121
|
-
if schema_changed or worker_id != "main":
|
|
1122
|
-
yield from _create_neon_branch(
|
|
1123
|
-
request,
|
|
1124
|
-
parent_branch_id_override=_neon_migration_branch.branch_id,
|
|
1125
|
-
branch_name_suffix=branch_suffix,
|
|
1126
|
-
)
|
|
1127
|
-
else:
|
|
1128
|
-
# No schema changes and not parallel - reuse the migration branch directly
|
|
1129
|
-
# This saves creating an unnecessary branch
|
|
1130
|
-
yield _neon_migration_branch
|
|
1598
|
+
Session-scoped read_only endpoint on the migration branch.
|
|
1131
1599
|
|
|
1600
|
+
This is a true read-only endpoint - writes are blocked at the database
|
|
1601
|
+
level. All workers share this endpoint since it's read-only anyway.
|
|
1602
|
+
"""
|
|
1603
|
+
env_manager = EnvironmentManager(_neon_config.env_var_name)
|
|
1604
|
+
branch: NeonBranch
|
|
1605
|
+
is_creator: bool
|
|
1132
1606
|
|
|
1133
|
-
|
|
1134
|
-
|
|
1607
|
+
def create_readonly_endpoint() -> dict[str, Any]:
|
|
1608
|
+
b = _neon_branch_manager.create_readonly_endpoint(_neon_migration_branch)
|
|
1609
|
+
return {"branch": _branch_to_dict(b)}
|
|
1610
|
+
|
|
1611
|
+
# Coordinate endpoint creation - shared across ALL workers
|
|
1612
|
+
data, is_creator = _neon_xdist_coordinator.coordinate_resource(
|
|
1613
|
+
"readonly_endpoint", create_readonly_endpoint
|
|
1614
|
+
)
|
|
1615
|
+
branch = _dict_to_branch(data["branch"])
|
|
1616
|
+
|
|
1617
|
+
# Set DATABASE_URL
|
|
1618
|
+
env_manager.set(branch.connection_string)
|
|
1619
|
+
|
|
1620
|
+
try:
|
|
1621
|
+
yield branch
|
|
1622
|
+
finally:
|
|
1623
|
+
env_manager.restore()
|
|
1624
|
+
# Only creator cleans up the endpoint
|
|
1625
|
+
if is_creator and branch.endpoint_id:
|
|
1626
|
+
_neon_branch_manager.delete_endpoint(branch.endpoint_id)
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
@pytest.fixture(scope="session")
|
|
1630
|
+
def _neon_isolated_branch(
|
|
1135
1631
|
request: pytest.FixtureRequest,
|
|
1136
|
-
|
|
1632
|
+
_neon_config: NeonConfig,
|
|
1633
|
+
_neon_branch_manager: NeonBranchManager,
|
|
1634
|
+
_neon_xdist_coordinator: XdistCoordinator,
|
|
1635
|
+
_neon_migration_branch: NeonBranch,
|
|
1636
|
+
_neon_migrations_synchronized: Any, # Ensures migrations complete first
|
|
1137
1637
|
) -> Generator[NeonBranch, None, None]:
|
|
1138
1638
|
"""
|
|
1139
|
-
|
|
1639
|
+
Session-scoped isolated branch, one per xdist worker.
|
|
1140
1640
|
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
branch's state after each test. This provides test isolation with
|
|
1144
|
-
~0.5s overhead per test.
|
|
1641
|
+
Each worker gets its own branch. Unlike the dirty branch, this is
|
|
1642
|
+
per-worker to allow reset operations without affecting other workers.
|
|
1145
1643
|
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1644
|
+
The branch is reset after each test that uses neon_branch_isolated.
|
|
1645
|
+
"""
|
|
1646
|
+
env_manager = EnvironmentManager(_neon_config.env_var_name)
|
|
1647
|
+
worker_id = _neon_xdist_coordinator.worker_id
|
|
1648
|
+
|
|
1649
|
+
# Each worker creates its own isolated branch - no coordination needed
|
|
1650
|
+
# because each worker has a unique ID
|
|
1651
|
+
branch = _neon_branch_manager.create_branch(
|
|
1652
|
+
name_suffix=f"-isolated-{worker_id}",
|
|
1653
|
+
parent_branch_id=_neon_migration_branch.branch_id,
|
|
1654
|
+
expiry_seconds=_neon_config.branch_expiry,
|
|
1655
|
+
)
|
|
1149
1656
|
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
10 minutes by default (configurable via --neon-branch-expiry) as a safety net
|
|
1153
|
-
for interrupted test runs.
|
|
1657
|
+
# Store branch manager on config for reset operations
|
|
1658
|
+
request.config._neon_isolated_branch_manager = _neon_branch_manager # type: ignore[attr-defined]
|
|
1154
1659
|
|
|
1155
|
-
|
|
1156
|
-
|
|
1660
|
+
# Set DATABASE_URL
|
|
1661
|
+
env_manager.set(branch.connection_string)
|
|
1157
1662
|
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1663
|
+
try:
|
|
1664
|
+
yield branch
|
|
1665
|
+
finally:
|
|
1666
|
+
env_manager.restore()
|
|
1667
|
+
_neon_branch_manager.delete_branch(branch.branch_id)
|
|
1161
1668
|
|
|
1162
|
-
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
|
1163
1669
|
|
|
1164
|
-
|
|
1165
|
-
|
|
1670
|
+
@pytest.fixture(scope="session")
|
|
1671
|
+
def neon_branch_readonly(
|
|
1672
|
+
_neon_config: NeonConfig,
|
|
1673
|
+
_neon_readonly_endpoint: NeonBranch,
|
|
1674
|
+
) -> NeonBranch:
|
|
1675
|
+
"""
|
|
1676
|
+
Provide a true read-only Neon database connection.
|
|
1677
|
+
|
|
1678
|
+
This fixture uses a read_only endpoint on the migration branch, which
|
|
1679
|
+
enforces read-only access at the database level. Any attempt to write
|
|
1680
|
+
will result in a database error.
|
|
1681
|
+
|
|
1682
|
+
This is the recommended fixture for tests that only read data (SELECT queries).
|
|
1683
|
+
It's session-scoped and shared across all tests and workers since it's read-only.
|
|
1684
|
+
|
|
1685
|
+
Use this fixture when your tests only perform SELECT queries.
|
|
1686
|
+
For tests that INSERT, UPDATE, or DELETE data, use ``neon_branch_dirty``
|
|
1687
|
+
(for shared state) or ``neon_branch_isolated`` (for test isolation).
|
|
1688
|
+
|
|
1689
|
+
The connection string is automatically set in the DATABASE_URL environment
|
|
1690
|
+
variable (configurable via --neon-env-var).
|
|
1166
1691
|
|
|
1167
1692
|
Requires either:
|
|
1168
1693
|
- NEON_API_KEY and NEON_PROJECT_ID environment variables, or
|
|
1169
1694
|
- --neon-api-key and --neon-project-id command line options
|
|
1170
1695
|
|
|
1171
|
-
|
|
1696
|
+
Returns:
|
|
1172
1697
|
NeonBranch: Object with branch_id, project_id, connection_string, and host.
|
|
1173
1698
|
|
|
1174
1699
|
Example::
|
|
1175
1700
|
|
|
1176
|
-
def
|
|
1701
|
+
def test_query_users(neon_branch_readonly):
|
|
1177
1702
|
# DATABASE_URL is automatically set
|
|
1178
1703
|
conn_string = os.environ["DATABASE_URL"]
|
|
1179
|
-
# or use directly
|
|
1180
|
-
conn_string = neon_branch_readwrite.connection_string
|
|
1181
1704
|
|
|
1182
|
-
#
|
|
1705
|
+
# Read-only query
|
|
1183
1706
|
with psycopg.connect(conn_string) as conn:
|
|
1184
|
-
conn.execute("
|
|
1185
|
-
|
|
1707
|
+
result = conn.execute("SELECT * FROM users").fetchall()
|
|
1708
|
+
assert len(result) > 0
|
|
1709
|
+
|
|
1710
|
+
# This would fail with a database error:
|
|
1711
|
+
# conn.execute("INSERT INTO users (name) VALUES ('test')")
|
|
1186
1712
|
"""
|
|
1187
|
-
|
|
1188
|
-
|
|
1713
|
+
# DATABASE_URL is already set by _neon_readonly_endpoint
|
|
1714
|
+
return _neon_readonly_endpoint
|
|
1189
1715
|
|
|
1190
|
-
# Validate that branch has a parent for reset functionality
|
|
1191
|
-
if not _neon_branch_for_reset.parent_id:
|
|
1192
|
-
pytest.fail(
|
|
1193
|
-
f"\n\nBranch {_neon_branch_for_reset.branch_id} has no parent. "
|
|
1194
|
-
f"The neon_branch_readwrite fixture requires a parent branch for "
|
|
1195
|
-
f"reset.\n\n"
|
|
1196
|
-
f"Use neon_branch_readonly if you don't need reset, or specify "
|
|
1197
|
-
f"a parent branch with --neon-parent-branch or NEON_PARENT_BRANCH_ID."
|
|
1198
|
-
)
|
|
1199
1716
|
|
|
1200
|
-
|
|
1717
|
+
@pytest.fixture(scope="session")
|
|
1718
|
+
def neon_branch_dirty(
|
|
1719
|
+
_neon_config: NeonConfig,
|
|
1720
|
+
_neon_dirty_branch: NeonBranch,
|
|
1721
|
+
) -> NeonBranch:
|
|
1722
|
+
"""
|
|
1723
|
+
Provide a session-scoped Neon database branch for read-write access.
|
|
1201
1724
|
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
_reset_branch_to_parent(branch=_neon_branch_for_reset, api_key=api_key)
|
|
1206
|
-
except Exception as e:
|
|
1207
|
-
pytest.fail(
|
|
1208
|
-
f"\n\nFailed to reset branch {_neon_branch_for_reset.branch_id} "
|
|
1209
|
-
f"after test. Subsequent tests in this module may see dirty "
|
|
1210
|
-
f"database state.\n\nError: {e}\n\n"
|
|
1211
|
-
f"To keep the branch for debugging, use --neon-keep-branches"
|
|
1212
|
-
)
|
|
1725
|
+
All tests share the same branch and writes persist across tests (no cleanup
|
|
1726
|
+
between tests). This is faster than neon_branch_isolated because there's no
|
|
1727
|
+
reset overhead.
|
|
1213
1728
|
|
|
1729
|
+
Use this fixture when:
|
|
1730
|
+
- Most tests can share database state without interference
|
|
1731
|
+
- You want maximum performance with minimal API calls
|
|
1732
|
+
- You manually manage test data cleanup if needed
|
|
1733
|
+
- You're using it alongside ``neon_branch_isolated`` for specific tests
|
|
1734
|
+
that need guaranteed clean state
|
|
1214
1735
|
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1736
|
+
The connection string is automatically set in the DATABASE_URL environment
|
|
1737
|
+
variable (configurable via --neon-env-var).
|
|
1738
|
+
|
|
1739
|
+
Warning:
|
|
1740
|
+
Data written by one test WILL be visible to subsequent tests AND to
|
|
1741
|
+
other xdist workers. This is truly shared - use ``neon_branch_isolated``
|
|
1742
|
+
for tests that require guaranteed clean state.
|
|
1743
|
+
|
|
1744
|
+
pytest-xdist:
|
|
1745
|
+
ALL workers share the same dirty branch. Concurrent writes from different
|
|
1746
|
+
workers may conflict. This is "dirty" by design - for isolation, use
|
|
1747
|
+
``neon_branch_isolated``.
|
|
1748
|
+
|
|
1749
|
+
Requires either:
|
|
1750
|
+
- NEON_API_KEY and NEON_PROJECT_ID environment variables, or
|
|
1751
|
+
- --neon-api-key and --neon-project-id command line options
|
|
1752
|
+
|
|
1753
|
+
Returns:
|
|
1754
|
+
NeonBranch: Object with branch_id, project_id, connection_string, and host.
|
|
1755
|
+
|
|
1756
|
+
Example::
|
|
1757
|
+
|
|
1758
|
+
def test_insert_user(neon_branch_dirty):
|
|
1759
|
+
# DATABASE_URL is automatically set
|
|
1760
|
+
import psycopg
|
|
1761
|
+
with psycopg.connect(neon_branch_dirty.connection_string) as conn:
|
|
1762
|
+
conn.execute("INSERT INTO users (name) VALUES ('test')")
|
|
1763
|
+
conn.commit()
|
|
1764
|
+
# Data persists - next test will see this user
|
|
1765
|
+
|
|
1766
|
+
def test_count_users(neon_branch_dirty):
|
|
1767
|
+
# This test sees data from previous tests
|
|
1768
|
+
import psycopg
|
|
1769
|
+
with psycopg.connect(neon_branch_dirty.connection_string) as conn:
|
|
1770
|
+
result = conn.execute("SELECT COUNT(*) FROM users").fetchone()
|
|
1771
|
+
# Count includes users from previous tests
|
|
1219
1772
|
"""
|
|
1220
|
-
|
|
1773
|
+
# DATABASE_URL is already set by _neon_dirty_branch
|
|
1774
|
+
return _neon_dirty_branch
|
|
1221
1775
|
|
|
1222
|
-
This is the recommended fixture for tests that only read data (SELECT queries).
|
|
1223
|
-
No branch reset occurs after each test, making it faster than
|
|
1224
|
-
``neon_branch_readwrite`` (~0.5s saved per test).
|
|
1225
1776
|
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1777
|
+
@pytest.fixture(scope="function")
|
|
1778
|
+
def neon_branch_isolated(
|
|
1779
|
+
request: pytest.FixtureRequest,
|
|
1780
|
+
_neon_config: NeonConfig,
|
|
1781
|
+
_neon_isolated_branch: NeonBranch,
|
|
1782
|
+
) -> Generator[NeonBranch, None, None]:
|
|
1783
|
+
"""
|
|
1784
|
+
Provide an isolated Neon database branch with reset after each test.
|
|
1785
|
+
|
|
1786
|
+
This is the recommended fixture for tests that modify database state and
|
|
1787
|
+
need isolation. Each xdist worker has its own branch, and the branch is
|
|
1788
|
+
reset to the migration state after each test.
|
|
1229
1789
|
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1790
|
+
Use this fixture when:
|
|
1791
|
+
- Tests modify database state (INSERT, UPDATE, DELETE)
|
|
1792
|
+
- You need test isolation (each test starts with clean state)
|
|
1793
|
+
- You're using it alongside ``neon_branch_dirty`` for specific tests
|
|
1234
1794
|
|
|
1235
1795
|
The connection string is automatically set in the DATABASE_URL environment
|
|
1236
1796
|
variable (configurable via --neon-env-var).
|
|
1237
1797
|
|
|
1798
|
+
SQLAlchemy Users:
|
|
1799
|
+
If you create your own engine (not using the neon_engine fixture),
|
|
1800
|
+
you MUST use pool_pre_ping=True::
|
|
1801
|
+
|
|
1802
|
+
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
|
1803
|
+
|
|
1804
|
+
Branch resets terminate server-side connections. Without pool_pre_ping,
|
|
1805
|
+
SQLAlchemy may reuse dead pooled connections, causing SSL errors.
|
|
1806
|
+
|
|
1807
|
+
pytest-xdist:
|
|
1808
|
+
Each worker has its own isolated branch. Resets only affect that worker's
|
|
1809
|
+
branch, so workers don't interfere with each other.
|
|
1810
|
+
|
|
1238
1811
|
Requires either:
|
|
1239
1812
|
- NEON_API_KEY and NEON_PROJECT_ID environment variables, or
|
|
1240
1813
|
- --neon-api-key and --neon-project-id command line options
|
|
@@ -1244,50 +1817,86 @@ def neon_branch_readonly(
|
|
|
1244
1817
|
|
|
1245
1818
|
Example::
|
|
1246
1819
|
|
|
1247
|
-
def
|
|
1820
|
+
def test_insert_user(neon_branch_isolated):
|
|
1248
1821
|
# DATABASE_URL is automatically set
|
|
1249
1822
|
conn_string = os.environ["DATABASE_URL"]
|
|
1250
1823
|
|
|
1251
|
-
#
|
|
1824
|
+
# Insert data - branch will reset after this test
|
|
1252
1825
|
with psycopg.connect(conn_string) as conn:
|
|
1253
|
-
|
|
1254
|
-
|
|
1826
|
+
conn.execute("INSERT INTO users (name) VALUES ('test')")
|
|
1827
|
+
conn.commit()
|
|
1828
|
+
# Next test starts with clean state
|
|
1255
1829
|
"""
|
|
1256
|
-
|
|
1830
|
+
# DATABASE_URL is already set by _neon_isolated_branch
|
|
1831
|
+
yield _neon_isolated_branch
|
|
1832
|
+
|
|
1833
|
+
# Reset branch to migration state after each test
|
|
1834
|
+
branch_manager = getattr(request.config, "_neon_isolated_branch_manager", None)
|
|
1835
|
+
if branch_manager is not None:
|
|
1836
|
+
try:
|
|
1837
|
+
branch_manager.reset_branch(_neon_isolated_branch)
|
|
1838
|
+
except Exception as e:
|
|
1839
|
+
pytest.fail(
|
|
1840
|
+
f"\n\nFailed to reset branch {_neon_isolated_branch.branch_id} "
|
|
1841
|
+
f"after test. Subsequent tests may see dirty state.\n\n"
|
|
1842
|
+
f"Error: {e}\n\n"
|
|
1843
|
+
f"To keep the branch for debugging, use --neon-keep-branches"
|
|
1844
|
+
)
|
|
1257
1845
|
|
|
1258
1846
|
|
|
1259
1847
|
@pytest.fixture(scope="function")
|
|
1260
|
-
def
|
|
1261
|
-
|
|
1262
|
-
neon_branch_readwrite: NeonBranch,
|
|
1848
|
+
def neon_branch_readwrite(
|
|
1849
|
+
neon_branch_isolated: NeonBranch,
|
|
1263
1850
|
) -> Generator[NeonBranch, None, None]:
|
|
1264
1851
|
"""
|
|
1265
|
-
Deprecated: Use ``
|
|
1852
|
+
Deprecated: Use ``neon_branch_isolated`` instead.
|
|
1853
|
+
|
|
1854
|
+
This fixture is now an alias for ``neon_branch_isolated``.
|
|
1266
1855
|
|
|
1267
|
-
|
|
1268
|
-
|
|
1856
|
+
.. deprecated:: 2.3.0
|
|
1857
|
+
Use ``neon_branch_isolated`` for tests that modify data with reset,
|
|
1858
|
+
``neon_branch_dirty`` for shared state, or ``neon_branch_readonly``
|
|
1859
|
+
for read-only access.
|
|
1860
|
+
"""
|
|
1861
|
+
warnings.warn(
|
|
1862
|
+
"neon_branch_readwrite is deprecated. Use neon_branch_isolated (for tests "
|
|
1863
|
+
"that modify data with isolation) or neon_branch_dirty (for shared state).",
|
|
1864
|
+
DeprecationWarning,
|
|
1865
|
+
stacklevel=2,
|
|
1866
|
+
)
|
|
1867
|
+
yield neon_branch_isolated
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
@pytest.fixture(scope="function")
|
|
1871
|
+
def neon_branch(
|
|
1872
|
+
neon_branch_isolated: NeonBranch,
|
|
1873
|
+
) -> Generator[NeonBranch, None, None]:
|
|
1874
|
+
"""
|
|
1875
|
+
Deprecated: Use ``neon_branch_isolated``, ``neon_branch_dirty``, or
|
|
1876
|
+
``neon_branch_readonly`` instead.
|
|
1269
1877
|
|
|
1270
|
-
|
|
1271
|
-
- ``neon_branch_readonly``: For tests that only read data (SELECT)
|
|
1878
|
+
This fixture is now an alias for ``neon_branch_isolated``.
|
|
1272
1879
|
|
|
1273
1880
|
.. deprecated:: 1.1.0
|
|
1274
|
-
Use ``
|
|
1275
|
-
|
|
1881
|
+
Use ``neon_branch_isolated`` for tests that modify data with reset,
|
|
1882
|
+
``neon_branch_dirty`` for shared state, or ``neon_branch_readonly``
|
|
1883
|
+
for read-only access.
|
|
1276
1884
|
"""
|
|
1277
1885
|
warnings.warn(
|
|
1278
|
-
"neon_branch is deprecated. Use
|
|
1279
|
-
"modify data)
|
|
1886
|
+
"neon_branch is deprecated. Use neon_branch_isolated (for tests that "
|
|
1887
|
+
"modify data), neon_branch_dirty (for shared state), or "
|
|
1888
|
+
"neon_branch_readonly (for read-only tests).",
|
|
1280
1889
|
DeprecationWarning,
|
|
1281
1890
|
stacklevel=2,
|
|
1282
1891
|
)
|
|
1283
|
-
yield
|
|
1892
|
+
yield neon_branch_isolated
|
|
1284
1893
|
|
|
1285
1894
|
|
|
1286
1895
|
@pytest.fixture(scope="module")
|
|
1287
1896
|
def neon_branch_shared(
|
|
1288
1897
|
request: pytest.FixtureRequest,
|
|
1289
1898
|
_neon_migration_branch: NeonBranch,
|
|
1290
|
-
|
|
1899
|
+
_neon_migrations_synchronized: Any, # Ensures migrations complete first
|
|
1291
1900
|
) -> Generator[NeonBranch, None, None]:
|
|
1292
1901
|
"""
|
|
1293
1902
|
Provide a shared Neon database branch for all tests in a module.
|
|
@@ -1324,7 +1933,7 @@ def neon_branch_shared(
|
|
|
1324
1933
|
|
|
1325
1934
|
|
|
1326
1935
|
@pytest.fixture
|
|
1327
|
-
def neon_connection(
|
|
1936
|
+
def neon_connection(neon_branch_isolated: NeonBranch):
|
|
1328
1937
|
"""
|
|
1329
1938
|
Provide a psycopg2 connection to the test branch.
|
|
1330
1939
|
|
|
@@ -1332,6 +1941,7 @@ def neon_connection(neon_branch: NeonBranch):
|
|
|
1332
1941
|
pip install pytest-neon[psycopg2]
|
|
1333
1942
|
|
|
1334
1943
|
The connection is rolled back and closed after each test.
|
|
1944
|
+
Uses neon_branch_isolated for test isolation.
|
|
1335
1945
|
|
|
1336
1946
|
Yields:
|
|
1337
1947
|
psycopg2 connection object
|
|
@@ -1353,21 +1963,22 @@ def neon_connection(neon_branch: NeonBranch):
|
|
|
1353
1963
|
" The 'neon_connection' fixture requires psycopg2.\n\n"
|
|
1354
1964
|
" To fix this, install the psycopg2 extra:\n\n"
|
|
1355
1965
|
" pip install pytest-neon[psycopg2]\n\n"
|
|
1356
|
-
" Or use the '
|
|
1357
|
-
" def test_example(
|
|
1966
|
+
" Or use the 'neon_branch_isolated' fixture with your own driver:\n\n"
|
|
1967
|
+
" def test_example(neon_branch_isolated):\n"
|
|
1358
1968
|
" import your_driver\n"
|
|
1359
|
-
" conn = your_driver.connect(
|
|
1969
|
+
" conn = your_driver.connect(\n"
|
|
1970
|
+
" neon_branch_isolated.connection_string)\n\n"
|
|
1360
1971
|
"═══════════════════════════════════════════════════════════════════\n"
|
|
1361
1972
|
)
|
|
1362
1973
|
|
|
1363
|
-
conn = psycopg2.connect(
|
|
1974
|
+
conn = psycopg2.connect(neon_branch_isolated.connection_string)
|
|
1364
1975
|
yield conn
|
|
1365
1976
|
conn.rollback()
|
|
1366
1977
|
conn.close()
|
|
1367
1978
|
|
|
1368
1979
|
|
|
1369
1980
|
@pytest.fixture
|
|
1370
|
-
def neon_connection_psycopg(
|
|
1981
|
+
def neon_connection_psycopg(neon_branch_isolated: NeonBranch):
|
|
1371
1982
|
"""
|
|
1372
1983
|
Provide a psycopg (v3) connection to the test branch.
|
|
1373
1984
|
|
|
@@ -1375,6 +1986,7 @@ def neon_connection_psycopg(neon_branch: NeonBranch):
|
|
|
1375
1986
|
pip install pytest-neon[psycopg]
|
|
1376
1987
|
|
|
1377
1988
|
The connection is rolled back and closed after each test.
|
|
1989
|
+
Uses neon_branch_isolated for test isolation.
|
|
1378
1990
|
|
|
1379
1991
|
Yields:
|
|
1380
1992
|
psycopg connection object
|
|
@@ -1396,21 +2008,22 @@ def neon_connection_psycopg(neon_branch: NeonBranch):
|
|
|
1396
2008
|
" The 'neon_connection_psycopg' fixture requires psycopg v3.\n\n"
|
|
1397
2009
|
" To fix this, install the psycopg extra:\n\n"
|
|
1398
2010
|
" pip install pytest-neon[psycopg]\n\n"
|
|
1399
|
-
" Or use the '
|
|
1400
|
-
" def test_example(
|
|
2011
|
+
" Or use the 'neon_branch_isolated' fixture with your own driver:\n\n"
|
|
2012
|
+
" def test_example(neon_branch_isolated):\n"
|
|
1401
2013
|
" import your_driver\n"
|
|
1402
|
-
" conn = your_driver.connect(
|
|
2014
|
+
" conn = your_driver.connect(\n"
|
|
2015
|
+
" neon_branch_isolated.connection_string)\n\n"
|
|
1403
2016
|
"═══════════════════════════════════════════════════════════════════\n"
|
|
1404
2017
|
)
|
|
1405
2018
|
|
|
1406
|
-
conn = psycopg.connect(
|
|
2019
|
+
conn = psycopg.connect(neon_branch_isolated.connection_string)
|
|
1407
2020
|
yield conn
|
|
1408
2021
|
conn.rollback()
|
|
1409
2022
|
conn.close()
|
|
1410
2023
|
|
|
1411
2024
|
|
|
1412
2025
|
@pytest.fixture
|
|
1413
|
-
def neon_engine(
|
|
2026
|
+
def neon_engine(neon_branch_isolated: NeonBranch):
|
|
1414
2027
|
"""
|
|
1415
2028
|
Provide a SQLAlchemy engine connected to the test branch.
|
|
1416
2029
|
|
|
@@ -1418,7 +2031,7 @@ def neon_engine(neon_branch: NeonBranch):
|
|
|
1418
2031
|
pip install pytest-neon[sqlalchemy]
|
|
1419
2032
|
|
|
1420
2033
|
The engine is disposed after each test, which handles stale connections
|
|
1421
|
-
after branch resets automatically.
|
|
2034
|
+
after branch resets automatically. Uses neon_branch_isolated for test isolation.
|
|
1422
2035
|
|
|
1423
2036
|
Note:
|
|
1424
2037
|
If you create your own module-level engine instead of using this
|
|
@@ -1450,13 +2063,14 @@ def neon_engine(neon_branch: NeonBranch):
|
|
|
1450
2063
|
" The 'neon_engine' fixture requires SQLAlchemy.\n\n"
|
|
1451
2064
|
" To fix this, install the sqlalchemy extra:\n\n"
|
|
1452
2065
|
" pip install pytest-neon[sqlalchemy]\n\n"
|
|
1453
|
-
" Or use the '
|
|
1454
|
-
" def test_example(
|
|
2066
|
+
" Or use the 'neon_branch_isolated' fixture with your own driver:\n\n"
|
|
2067
|
+
" def test_example(neon_branch_isolated):\n"
|
|
1455
2068
|
" from sqlalchemy import create_engine\n"
|
|
1456
|
-
" engine = create_engine(
|
|
2069
|
+
" engine = create_engine(\n"
|
|
2070
|
+
" neon_branch_isolated.connection_string)\n\n"
|
|
1457
2071
|
"═══════════════════════════════════════════════════════════════════\n"
|
|
1458
2072
|
)
|
|
1459
2073
|
|
|
1460
|
-
engine = create_engine(
|
|
2074
|
+
engine = create_engine(neon_branch_isolated.connection_string)
|
|
1461
2075
|
yield engine
|
|
1462
2076
|
engine.dispose()
|