Skip to content
Descoped Source Code Portalv2.2.13
PollingSign in

psycopg-toolkit

Up to date

Psycopg Database Library

descoped/psycopg-toolkit on GitHubLast push

last commit badgerelease badgelicense badgeissues badge

Build Status Coverage Python Version Release

A robust PostgreSQL database toolkit providing enterprise-grade connection pooling and database management capabilities for Python applications.

Features

  • Async-first design with connection pooling via psycopg-pool
  • Comprehensive transaction management with savepoint support
  • Type-safe repository pattern with Pydantic model validation
  • JSONB support with automatic field detection and psycopg JSON adapters
  • Native pgvector support with automatic vector field detection
  • PostgreSQL array field preservation (TEXT[], INTEGER[])
  • Automatic date/timestamp conversion for Pydantic models
  • SQL query builder with SQL injection protection
  • Database schema and test data lifecycle management
  • Automatic retry mechanism with exponential backoff
  • Granular exception hierarchy for error handling
  • Connection health monitoring and validation
  • Database initialization callback system
  • Statement timeout configuration
  • Fully typed with modern Python type hints

Installation

pip install psycopg-toolkit

Quick Start

from psycopg_toolkit import Database, DatabaseSettings
from uuid import uuid4

# Configure database
settings = DatabaseSettings(
    host="localhost",
    port=5432,
    dbname="your_database",
    user="your_user",
    password="your_password"
)

async def main():
    # Initialize database
    db = Database(settings)
    await db.init_db()
    
    # Get transaction manager
    tm = await db.get_transaction_manager()
    
    # Execute in transaction
    async with tm.transaction() as conn:
        async with conn.cursor() as cur:
            user_id = uuid4()
            await cur.execute(
                "INSERT INTO users (id, email) VALUES (%s, %s)",
                (user_id, "user@example.com")
            )
    
    # Clean up
    await db.cleanup()

Core Components

Database Management

# Health check
is_healthy = await db.check_pool_health()

# Connection management
async with db.connection() as conn:
    async with conn.cursor() as cur:
        await cur.execute("SELECT version()")

Transaction Management

# Basic transaction
async with tm.transaction() as conn:
    # Operations automatically rolled back on error
    pass

# With savepoint
async with tm.transaction(savepoint="user_creation") as conn:
    # Nested transaction using savepoint
    pass

Repository Pattern

from pydantic import BaseModel
from psycopg_toolkit import BaseRepository

class User(BaseModel):
    id: UUID
    email: str

class UserRepository(BaseRepository[User]):
    def __init__(self, conn: AsyncConnection):
        super().__init__(
            db_connection=conn,
            table_name="users",
            model_class=User,
            primary_key="id"
        )

# Usage
async with tm.transaction() as conn:
    repo = UserRepository(conn)
    user = await repo.get_by_id(user_id)

JSONB Support

from typing import Dict, List, Any
from pydantic import BaseModel
from psycopg_toolkit import BaseRepository

class UserProfile(BaseModel):
    id: int
    name: str
    # These fields are automatically detected as JSONB
    metadata: Dict[str, Any]
    preferences: Dict[str, str]
    tags: List[str]

class UserRepository(BaseRepository[UserProfile, int]):
    def __init__(self, conn):
        super().__init__(
            db_connection=conn,
            table_name="user_profiles",
            model_class=UserProfile,
            primary_key="id"
            # auto_detect_json=True by default
        )

# Usage - JSON fields handled automatically
user = UserProfile(
    id=1,
    name="John Doe",
    metadata={"created_at": "2024-01-01", "source": "web"},
    preferences={"theme": "dark", "language": "en"},
    tags=["premium", "beta_tester"]
)

# JSONB fields automatically serialized/deserialized
created_user = await repo.create(user)
retrieved_user = await repo.get_by_id(1)

PostgreSQL Arrays and Date Fields

from typing import List
from datetime import date
from pydantic import BaseModel
from psycopg_toolkit import BaseRepository

class User(BaseModel):
    id: UUID
    username: str
    roles: List[str]          # PostgreSQL TEXT[] array
    permissions: List[str]    # PostgreSQL TEXT[] array
    metadata: Dict[str, Any]  # JSONB field
    birthdate: str            # ISO date string (from DATE)
    created_at: str           # ISO datetime string (from TIMESTAMP)
    updated_at: str           # ISO datetime string (from TIMESTAMPTZ)
    last_login: str | None    # Optional timestamp field

class UserRepository(BaseRepository[User, UUID]):
    def __init__(self, conn):
        super().__init__(
            db_connection=conn,
            table_name="users",
            model_class=User,
            primary_key="id",
            # Preserve PostgreSQL arrays instead of JSONB
            array_fields={"roles", "permissions"},
            # Auto-convert ALL date/timestamp fields to/from strings
            date_fields={"birthdate", "created_at", "updated_at", "last_login"}
        )

# PostgreSQL arrays are preserved, dates are auto-converted
user = User(
    id=uuid4(),
    username="john",
    roles=["admin", "user"],      # Stored as TEXT[]
    permissions=["read", "write"], # Stored as TEXT[]
    metadata={"dept": "IT"},       # Stored as JSONB
    birthdate="1990-01-01",           # Converts to/from PostgreSQL DATE
    created_at="2024-01-01T12:00:00", # Converts to/from TIMESTAMP
    updated_at="2024-01-01T12:00:00", # Converts to/from TIMESTAMPTZ
    last_login=None                   # Nullable timestamp field
)

Schema Management

from psycopg_toolkit.core.transaction import SchemaManager

class UserSchemaManager(SchemaManager[None]):
    async def create_schema(self, conn: AsyncConnection) -> None:
        await conn.execute("""
            CREATE TABLE IF NOT EXISTS users (
                id UUID PRIMARY KEY,
                email TEXT UNIQUE NOT NULL
            )
        """)

    async def drop_schema(self, conn: AsyncConnection) -> None:
        await conn.execute("DROP TABLE IF EXISTS users")

# Usage
async with tm.with_schema(UserSchemaManager()) as _:
    # Schema available here
    pass  # Automatically dropped after

Error Handling

from psycopg_toolkit import (
    DatabaseConnectionError,
    DatabasePoolError,
    DatabaseNotAvailable,
    RecordNotFoundError
)

try:
    async with tm.transaction() as conn:
        repo = UserRepository(conn)
        user = await repo.get_by_id(user_id)
except DatabaseConnectionError as e:
    print(f"Connection error: {e.original_error}")
except RecordNotFoundError:
    print(f"User {user_id} not found")

Testing with Async Libraries

Understanding the pytest and Async Boundary

When testing async libraries like async-task-worker or database libraries (e.g., psycopg-toolkit), you'll encounter a fundamental challenge: pytest's session-scoped fixtures are synchronous, while modern libraries are increasingly async. This section explains the problem and provides the recommended patterns for handling this boundary.

The Core Problem

pytest's fixture system was designed before async became prevalent in Python:

  • Session fixtures are synchronous: They run once per test session and must be sync functions
  • Modern libraries are async: Database pools, task workers, and connections require await
  • Resource efficiency matters: You want to reuse expensive resources (containers, pools) across tests

This creates an awkward boundary where you need async resources in sync fixture setup.

The Standard Solution: asyncio.run()

The accepted pattern in the Python testing community is using asyncio.run() in session fixtures. While it may feel inelegant, this is the recommended approach documented in pytest-asyncio's own examples.

import pytest
import asyncio
from async_task_worker import AsyncTaskWorker

@pytest.fixture(scope="session")
def worker():
    """Session-scoped worker for test efficiency.
    
    This uses asyncio.run() to bridge the sync/async boundary.
    This is the standard pattern for async resources in session fixtures.
    """
    # Create the worker
    worker = AsyncTaskWorker(max_workers=5)
    
    # Start it using asyncio.run() - the standard workaround
    asyncio.run(worker.start())
    
    yield worker
    
    # Cleanup
    asyncio.run(worker.stop())

# Your async tests can then use the worker normally
@pytest.mark.asyncio
async def test_task_execution(worker):
    task_id = await worker.add_task(my_task, data)
    result = await worker.get_task_future(task_id)
    assert result == expected

Complete Testing Pattern

Here's a comprehensive example showing best practices for testing async-task-worker:

import pytest
import pytest_asyncio
import asyncio
from datetime import datetime
from async_task_worker import AsyncTaskWorker, task, TaskStatus

# ============================================================
# Session Fixtures (Synchronous with asyncio.run())
# ============================================================

@pytest.fixture(scope="session")
def event_loop_policy():
    """Use a specific event loop policy for tests."""
    return asyncio.get_event_loop_policy()

@pytest.fixture(scope="session")
def worker():
    """Create a session-scoped worker instance.
    
    Note: We use asyncio.run() here because session fixtures
    must be synchronous. This is the standard pattern.
    """
    worker = AsyncTaskWorker(
        max_workers=3,
        task_timeout=10,
        cache_enabled=True,
        cache_ttl=60
    )
    
    # Start the worker using asyncio.run()
    asyncio.run(worker.start())
    
    yield worker
    
    # Cleanup
    asyncio.run(worker.stop())

# ============================================================
# Function-Scoped Async Fixtures (Can use async/await)
# ============================================================

@pytest_asyncio.fixture
async def clean_worker(worker):
    """Provides a clean worker state for each test."""
    # Clear any existing tasks
    tasks = worker.get_all_tasks()
    for task in tasks:
        if task.status in ("pending", "running"):
            await worker.cancel_task(task.id)
    
    # Clear cache if enabled
    if worker.cache:
        await worker.clear_cache()
    
    yield worker
    
    # Post-test cleanup if needed
    # ...

# ============================================================
# Test Task Definitions
# ============================================================

@task("test_computation")
async def test_computation(value, delay=0.1, progress_callback=None):
    """A test task that simulates computation."""
    steps = 5
    result = value
    
    for i in range(steps):
        await asyncio.sleep(delay)
        result = result * 2
        
        if progress_callback:
            progress_callback((i + 1) / steps)
    
    return result

@task("test_failing_task")
async def test_failing_task(should_fail=True):
    """A task that can be configured to fail."""
    if should_fail:
        raise ValueError("Task failed as expected")
    return "success"

# ============================================================
# Test Cases
# ============================================================

@pytest.mark.asyncio
async def test_basic_task_execution(clean_worker):
    """Test basic task execution flow."""
    # Add a task
    task_id = await clean_worker.add_task(
        test_computation,
        value=2,
        delay=0.01
    )
    
    # Wait for completion
    result = await clean_worker.get_task_future(task_id)
    
    # Verify result (2 * 2^5 = 64)
    assert result == 64
    
    # Check task info
    info = clean_worker.get_task_info(task_id)
    assert info.status == TaskStatus.COMPLETED
    assert info.progress == 1.0

@pytest.mark.asyncio
async def test_task_cancellation(clean_worker):
    """Test task cancellation."""
    # Start a long-running task
    task_id = await clean_worker.add_task(
        test_computation,
        value=1,
        delay=1.0  # Long delay
    )
    
    # Cancel it
    cancelled = await clean_worker.cancel_task(task_id)
    assert cancelled
    
    # Verify status
    info = clean_worker.get_task_info(task_id)
    assert info.status == TaskStatus.CANCELLED

@pytest.mark.asyncio
async def test_cache_functionality(clean_worker):
    """Test caching of task results."""
    # First execution
    task_id1 = await clean_worker.add_task(
        test_computation,
        value=3,
        delay=0.01,
        use_cache=True
    )
    result1 = await clean_worker.get_task_future(task_id1)
    
    # Second execution with same args (should hit cache)
    start_time = datetime.now()
    task_id2 = await clean_worker.add_task(
        test_computation,
        value=3,
        delay=0.01,  # This delay won't happen due to cache
        use_cache=True
    )
    result2 = await clean_worker.get_task_future(task_id2)
    elapsed = (datetime.now() - start_time).total_seconds()
    
    # Results should match
    assert result1 == result2
    
    # Second execution should be much faster (cache hit)
    assert elapsed < 0.05  # Much less than the task delay

@pytest.mark.asyncio
async def test_concurrent_tasks(clean_worker):
    """Test concurrent task execution."""
    # Add multiple tasks
    task_ids = []
    for i in range(5):
        task_id = await clean_worker.add_task(
            test_computation,
            value=i,
            delay=0.01
        )
        task_ids.append(task_id)
    
    # Wait for all to complete
    futures = clean_worker.get_task_futures(task_ids)
    results = await asyncio.gather(*futures)
    
    # Verify all completed
    assert len(results) == 5
    for i, result in enumerate(results):
        assert result == i * (2 ** 5)

# ============================================================
# Testing Utilities
# ============================================================

class TaskMonitor:
    """Utility class for monitoring task execution in tests."""
    
    def __init__(self, worker: AsyncTaskWorker):
        self.worker = worker
        self.events = []
    
    async def monitor_task(self, task_id: str, timeout: float = 5.0):
        """Monitor a task until completion or timeout."""
        start_time = asyncio.get_event_loop().time()
        
        while asyncio.get_event_loop().time() - start_time < timeout:
            info = self.worker.get_task_info(task_id)
            self.events.append({
                "time": asyncio.get_event_loop().time() - start_time,
                "status": info.status,
                "progress": info.progress
            })
            
            if info.status not in ("pending", "running"):
                return info
            
            await asyncio.sleep(0.1)
        
        raise TimeoutError(f"Task {task_id} did not complete within {timeout}s")

@pytest.mark.asyncio
async def test_with_monitor(clean_worker):
    """Example of using the TaskMonitor utility."""
    monitor = TaskMonitor(clean_worker)
    
    task_id = await clean_worker.add_task(
        test_computation,
        value=5,
        delay=0.1
    )
    
    # Monitor the task
    final_info = await monitor.monitor_task(task_id)
    
    # Check the monitoring captured progress
    assert len(monitor.events) > 0
    assert final_info.status == TaskStatus.COMPLETED
    
    # Verify progress increased over time
    progresses = [e["progress"] for e in monitor.events]
    assert progresses[-1] == 1.0

Testing with Database Libraries (e.g., psycopg-toolkit)

The same pattern applies when testing with async database libraries:

import pytest
import asyncio
from psycopg_toolkit import Database

@pytest.fixture(scope="session")
def db():
    """Session-scoped database connection.
    
    Uses asyncio.run() to handle async operations in sync fixture.
    This is the standard pattern for pytest with async resources.
    """
    db = Database("postgresql://user:pass@localhost/test")
    
    # Create the pool synchronously using asyncio.run()
    asyncio.run(db.get_pool())
    
    yield db
    
    # Cleanup
    asyncio.run(db.cleanup())

@pytest.fixture(scope="function")
async def db_connection(db):
    """Function-scoped connection from the session pool."""
    async with db.connection() as conn:
        yield conn
        # Automatic cleanup via context manager

Key Takeaways

  1. The asyncio.run() pattern is correct: Don't feel bad about using it in session fixtures - it's the standard solution
  2. Session vs Function Scope: Use session fixtures for expensive resources (pools, workers) and function fixtures for cleanup
  3. The awkwardness is pytest's limitation, not your design: Your async library doesn't need sync wrappers
  4. Document the pattern: Help users understand why asyncio.run() appears in test fixtures

Additional Testing Resources

  • pytest-asyncio: The standard pytest plugin for async testing
  • pytest-timeout: Useful for preventing hanging async tests
  • asyncio-test: Utilities for testing asyncio code
  • Testing async generators: Use async for in tests or aioitertools for utilities

Common Pitfalls and Solutions

Pitfall 1: Event Loop Conflicts

# Wrong - Creates new event loop
def test_something():
    loop = asyncio.new_event_loop()
    loop.run_until_complete(my_async_func())

# Right - Use pytest-asyncio
@pytest.mark.asyncio
async def test_something():
    await my_async_func()

Pitfall 2: Forgetting Cleanup

# Wrong - No cleanup
@pytest.fixture
def worker():
    w = AsyncTaskWorker()
    asyncio.run(w.start())
    return w

# Right - Proper cleanup
@pytest.fixture
def worker():
    w = AsyncTaskWorker()
    asyncio.run(w.start())
    yield w
    asyncio.run(w.stop())

Pitfall 3: Mixing Sync and Async Incorrectly

# Wrong - Can't await in sync function
def test_task(worker):
    result = await worker.add_task(...)  # SyntaxError

# Right - Mark as async test
@pytest.mark.asyncio
async def test_task(worker):
    result = await worker.add_task(...)

By following these patterns, you can effectively test async-task-worker and similar async libraries while maintaining clean, efficient test suites.

Documentation

Running Tests

# Install dependencies
uv sync --all-groups

# Run all tests except performance tests (default)
uv run pytest

# Run only performance tests
uv run pytest -m performance

# Run all tests including performance
uv run pytest -m ""

# Run specific test categories
uv run pytest tests/unit/  # Only unit tests
uv run pytest -m performance  # Only performance tests

# Run with coverage
uv run pytest --cov=src/psycopg_toolkit --cov-report=html

Test Categories

The test suite is organized into three categories:

  • Unit tests: Fast, isolated tests that don't require a database (in tests/unit/)
  • Integration tests: Tests that require a real PostgreSQL database (in tests/ root)
  • Performance tests: Benchmarks and performance measurements (marked with @pytest.mark.performance)

Performance tests are excluded by default to keep the regular test runs fast. Use the -m performance flag to run them explicitly.

Contributing

  1. Fork the repository
  2. Create a feature branch
  3. Add tests for new features
  4. Ensure all tests pass
  5. Submit a pull request

License

This project is licensed under the MIT License - see the LICENSE file for details.

Dependencies

pyproject.toml psycopg-toolkit (10 dependencies)
NameVersionScope
psycopg>=3.2.3runtime
psycopg-pool>=3.2.4runtime
pydantic>=2.10.5runtime
tenacity>=9.0.0runtime
psutil>=6.1.0dev
pytest>=8.3.0dev
pytest-asyncio>=0.25.2dev
pytest-cov>=6.0.0dev
ruff>=0.8.0dev
testcontainers>=4.12.0dev

Health

Health 65Watch

Technology

Up to date
Languages
Python
Build tools
uv
Toolchains
python >=3.11
Used by

Packages

  • psycopg-toolkitPyPI0.3.1at pypi.org

Pull requests

Up to date

3 open: Needs review 2 · Failing checks 1

  1. chore(deps): Lock file maintenance

    Needs review, waits for a review#12renovate[bot] (automation)

  2. chore(deps): update pytest to 9.0.3 [SECURITY]

    Needs review, waits for a review#16renovate[bot] (automation)

  3. chore(deps): Lock file maintenance

    Failing checks, waits for its author#13renovate[bot] (automation)

Issues

Up to date

1 open: Needs triage 1

  1. Dependency Dashboard

    Needs triage, waits for a maintainer to look at it#3renovate[bot] (automation)idle

Releases

Up to date
  1. v0.3.1Release v0.3.1 by oranheim
    Release notes

    🎯 Simplified pgvector Support

    This release simplifies pgvector integration by removing the pgvector-python library dependency and using native JSON parsing instead. This results in zero external dependencies, better Pydantic compatibility, and simpler code.

    ✨ What's Changed

    Removed Dependencies

    • Removed pgvector-python dependency - No longer required for vector support
    • Removed numpy dependency - Vector data returned as native Python list[float]

    Simplified Implementation

    • PostgreSQL returns vectors as strings like '[0.1,0.2,0.3]'
    • Automatically parsed to list[float] using built-in JSON parsing
    • Removed 28 lines of adapter registration complexity
    • No async adapter registration needed

    Performance

    • Negligible overhead: <0.1ms per vector for JSON parsing
    • For 1000 vectors: ~10-100ms difference vs adapter (1-10% overhead)
    • Completely acceptable for typical LLM embedding use cases

    🚀 Key Benefits

    ✅ Zero External Dependencies - No pgvector-python, no numpy required ✅ Simpler Code - Removed async adapter registration logic ✅ Perfect Pydantic Compatibility - Returns list[float], not numpy.ndarray ✅ No Breaking Changes - Same public API, drop-in upgrade ✅ All Tests Passing - 227 tests with zero regressions

    📦 Installation

    pip install psycopg-toolkit==0.3.1
    

    💻 Usage Example

    from pydantic import BaseModel
    from psycopg_toolkit import BaseRepository
    from uuid import UUID
    
    class DocumentEmbedding(BaseModel):
        id: UUID
        document_id: UUID
        embedding: list[float]  # Auto-detected as vector field
        metadata: dict[str, Any] | None = None
    
    class EmbeddingRepository(BaseRepository[DocumentEmbedding, UUID]):
        def __init__(self, db_connection):
            super().__init__(
                db_connection=db_connection,
                table_name="embeddings",
                model_class=DocumentEmbedding,
                primary_key="id",
                auto_detect_vector=True,  # Automatically detects list[float]
            )
    
    # Usage - works seamlessly with PostgreSQL pgvector extension
    embedding_vector = [0.1] * 384  # 384-dimensional vector
    doc = DocumentEmbedding(
        id=uuid4(),
        document_id=uuid4(),
        embedding=embedding_vector,
        metadata={"model": "all-MiniLM-L6-v2"}
    )
    
    created = await repo.create(doc)
    assert isinstance(created.embedding, list)  # Returns list[float]!
    

    🔧 Technical Details

    Before (v0.3.0):

    • Required pgvector-python>=0.4.1 dependency
    • Used register_vector_async() adapter registration
    • Returned vectors as numpy.ndarray
    • Required numpy as transitive dependency

    After (v0.3.1):

    • No external dependencies needed
    • Simple JSON parsing with JSONHandler.deserialize()
    • Returns vectors as native Python list[float]
    • Direct Pydantic compatibility

    📝 Full Changelog

    Commits:

    • 82ad82a - refactor: Remove pgvector-python dependency, use JSON parsing for vectors
    • Updated documentation in .claude/CLAUDE.md
    • Removed adapter registration from BaseRepository
    • Added vector field parsing in _postprocess_data()

    Files Changed: 4 files (+37, -152 lines)

    • pyproject.toml - Removed pgvector dependency
    • src/psycopg_toolkit/repositories/base.py - Simplified vector handling
    • .claude/CLAUDE.md - Updated documentation
    • uv.lock - Dependency updates

    🧪 Testing

    All 227 tests passing:

    • 12 vector field unit tests
    • 4 pgvector integration tests
    • 211 other tests (JSONB, arrays, dates, transactions, etc.)
    • Zero regressions

    ⚠️ Breaking Changes

    None - This is a drop-in replacement for v0.3.0. The public API remains identical.


    Full Changelog: https://github.com/descoped/psycopg-toolkit/compare/v0.3.0...v0.3.1

  2. v0.3.0Release Notes - v0.3.0 by oranheim
    Release notes

    🎉 Native pgvector Support

    This release adds native support for PostgreSQL's pgvector extension with automatic vector field detection, following the same pattern as our existing JSONB support.

    ✨ New Features

    pgvector Integration

    • Automatic vector field detection - Auto-detects list[float] type hints as pgvector columns
    • Lazy async adapter registration - Uses register_vector_async() for proper async handling
    • Graceful degradation - Works seamlessly even if pgvector package is not installed
    • Per-connection registration - Properly handles connection pooling
    • Type-safe operations - Full type safety with list[float] <-> PostgreSQL vector

    Repository Parameters

    • auto_detect_vector (default: True) - Automatically detect vector fields from type hints
    • vector_fields - Explicitly specify vector field names (overrides auto-detection)
    • Vector fields automatically excluded from JSON processing

    Usage Example

    from pydantic import BaseModel
    from uuid import UUID
    
    class DocumentEmbedding(BaseModel):
        id: UUID
        embedding: list[float]  # Auto-detected as vector!
        metadata: dict[str, Any]
    
    class EmbeddingRepository(BaseRepository[DocumentEmbedding, UUID]):
        def __init__(self, db_connection):
            super().__init__(
                db_connection=db_connection,
                table_name="embeddings",
                model_class=DocumentEmbedding,
                primary_key="id",
                # auto_detect_vector=True is default
            )
    
    # Just works - no manual parsing needed!
    doc = DocumentEmbedding(
        id=uuid4(),
        embedding=[0.1] * 384,
        metadata={"model": "all-MiniLM-L6-v2"}
    )
    created = await repo.create(doc)
    assert isinstance(created.embedding, list)  # ✅ Returns as list[float]
    

    🧪 Testing Improvements

    New Tests

    • 12 unit tests - Vector field detection and type inspection
    • 4 integration tests - Full CRUD operations with pgvector
    • 227 total tests - All passing with zero regressions

    Test Organization

    • Reorganized tests into proper subdirectories:
      • tests/unit/ - Unit tests (mocked, no database)
      • tests/integration/ - Integration tests (real PostgreSQL)
      • tests/performance/ - Performance benchmarks
    • Updated testcontainers to use pgvector/pgvector:pg17
    • Added float32 precision handling (pgvector uses float32, Python uses float64)

    📦 Dependencies

    • Optional dependency: pgvector>=0.4.1 in vector dependency group
    • Install with: pip install psycopg-toolkit[vector]
    • Graceful degradation if not installed

    📚 Documentation

    • ✅ Updated CLAUDE.md with pgvector support details
    • ✅ Updated README.md with pgvector feature
    • ✅ Added comprehensive usage examples
    • ✅ Documented vector field configuration options
    • ✅ Added pgvector support components section

    🛠️ Code Quality

    • Fixed all ruff linting issues (SIM102, B905, SIM222)
    • Applied consistent code formatting
    • Added comprehensive async testing guide to README
    • Improved test organization and structure

    📊 Statistics

    +681 lines added
    -388 lines removed
    18 files changed
    
    Core implementation:
    - src/psycopg_toolkit/repositories/base.py (+72 lines)
    - src/psycopg_toolkit/utils/type_inspector.py (+102 lines)
    
    New tests:
    - tests/integration/test_pgvector.py (119 lines, 4 tests)
    - tests/unit/test_type_inspector.py (+174 lines, 12 tests)
    

    🔧 Technical Details

    Type Detection (TypeInspector)

    • detect_vector_fields() - Detects list[float] fields
    • Supports Optional[list[float]] and list[float] | None
    • Handles legacy typing.List[float] syntax
    • Excludes vector fields from JSON processing

    Adapter Registration (BaseRepository)

    • Lazy registration on first database operation
    • Uses async variant register_vector_async()
    • Per-connection registration (not global)
    • Logs helpful warnings if pgvector not installed

    Float Precision

    • pgvector uses float32 (4 bytes per dimension)
    • Python uses float64 (8 bytes)
    • Tests use approximate comparison with tolerance of 0.001

    🔄 Migration Guide

    No breaking changes! Existing code continues to work without modifications.

    To use pgvector support:

    1. Install pgvector: pip install pgvector>=0.4.1
    2. Enable pgvector extension in PostgreSQL: CREATE EXTENSION vector;
    3. Use list[float] type hints in your Pydantic models
    4. Vector fields are automatically detected and handled!

    🙏 Credits

    Feature implemented following the AI instruction specification and integrated following the existing JSONB adapter pattern for consistency.


    Full Changelog: https://github.com/descoped/psycopg-toolkit/compare/v0.2.2...v0.3.0

  3. v0.2.2Release v0.2.2 by oranheim
    Release notes

    Release Notes - v0.2.2

    🕒 Enhanced Date and Timestamp Support

    This release significantly improves how psycopg-toolkit handles PostgreSQL date and timestamp fields, making it easier to work with temporal data across your applications.

    ✨ New Features

    • Full TIMESTAMP Support: The date_fields parameter now properly handles PostgreSQL TIMESTAMP and TIMESTAMPTZ columns in addition to DATE columns
    • Unified Date/DateTime Conversion: Both datetime.date and datetime.datetime objects are now seamlessly converted to ISO format strings for Pydantic models
    • Comprehensive Field Coverage: You can now include all temporal fields (DATE, TIMESTAMP, TIMESTAMPTZ) in the date_fields parameter for consistent handling

    🐛 Bug Fixes

    • Fixed Duplicate Processing: Removed duplicate date field processing logic in _postprocess_data method that could cause performance issues
    • Improved Type Handling: Better detection and conversion of both date and datetime objects from PostgreSQL

    📚 Documentation Improvements

    • Enhanced Examples: Updated array_and_date_fields.py example to demonstrate proper TIMESTAMP usage
    • Clearer Guidelines: Added comprehensive documentation explaining when and how to use date_fields for all temporal column types
    • Common Pitfalls Section: New documentation section highlighting typical mistakes and their solutions
    • Expected Warnings Note: Added clarification about expected "Failed to deserialize JSON field" warnings when using auto_detect_json=True

    🧪 Testing

    • New Test Coverage: Added specific tests for datetime field conversion to ensure reliability
    • Expanded Test Suite: Enhanced existing date field tests to cover more edge cases

    💡 Usage Updates

    Before v0.2.2:

    date_fields={"birthdate"}  # Only DATE columns
    

    After v0.2.2:

    date_fields={"birthdate", "created_at", "updated_at", "last_login"}  # ALL temporal fields
    

    ⚠️ Important Notes

    • Migration Recommended: If you're using timestamp fields, update your date_fields configuration to include all temporal columns for consistent behavior
    • No Breaking Changes: Existing code will continue to work, but you may want to expand your date_fields configuration to take advantage of the improved timestamp support

    🔧 Technical Details

    • Enhanced _preprocess_data and _postprocess_data methods for better temporal field handling
    • Improved logging to distinguish between date and datetime conversions
    • Better error handling for edge cases in temporal data processing
  4. v0.2.1Release v0.2.1 by oranheim
    Release notes

    Release Notes - v0.2.1

    🚀 New Features

    PostgreSQL Array Field Support

    The BaseRepository now supports native PostgreSQL array columns with the new array_fields parameter. This allows you to preserve list fields as PostgreSQL arrays (e.g., TEXT[], INTEGER[]) instead of converting them to JSONB.

    class OAuthClient(BaseModel):
        id: UUID
        redirect_uris: list[str]      # PostgreSQL TEXT[] array
        grant_types: list[str]        # PostgreSQL TEXT[] array  
        metadata: dict[str, Any]      # JSONB field
    
    class ClientRepository(BaseRepository[OAuthClient, UUID]):
        def __init__(self, db_connection):
            super().__init__(
                db_connection=db_connection,
                table_name="oauth_clients",
                model_class=OAuthClient,
                primary_key="id",
                auto_detect_json=True,  # Detects metadata as JSON
                array_fields={"redirect_uris", "grant_types"}  # Keep as arrays
            )
    

    Automatic Date Field Conversion

    The new date_fields parameter enables automatic conversion between PostgreSQL DATE/TIMESTAMP types and ISO format strings in Pydantic models.

    class User(BaseModel):
        id: UUID
        username: str
        birthdate: str | None  # Expects ISO date string
        created_at: str        # Expects ISO datetime string
    
    class UserRepository(BaseRepository[User, UUID]):
        def __init__(self, db_connection):
            super().__init__(
                db_connection=db_connection,
                table_name="users",
                model_class=User,
                primary_key="id",
                date_fields={"birthdate", "created_at"}  # Auto-convert dates
            )
    

    🔧 Improvements

    • Array fields take precedence over JSON auto-detection
    • Date fields handle both date objects and strings transparently
    • Better handling of mixed field types in complex models

    🐛 Bug Fixes

    • Fixed psycopg adapter check to prevent array handling conflicts
    • psycopg adapters now disabled by default to ensure correct array field processing

    📚 Documentation

    • Added comprehensive examples in examples/array_and_date_fields.py
    • Updated docs/jsonb_support.md with array and date field configuration
    • Added test coverage for array and date field functionality

    💔 Breaking Changes

    • psycopg JSON adapters are now disabled by default to prevent conflicts with array field handling. This ensures that PostgreSQL arrays are preserved correctly when using array_fields.
  5. v0.2.0Release v0.2.0 by oranheim
    Release notes

    Release Notes - v0.2.0

    🚀 New Features

    Comprehensive PostgreSQL JSONB Support

    This release introduces robust, production-ready support for PostgreSQL's JSONB data type with seamless Pydantic model integration.

    Automatic JSON Field Detection

    • Automatically detects dict, list, and complex types (including Optional and Union variants) as JSONB columns
    • Uses the new TypeInspector utility for intelligent type analysis
    • Supports model inheritance and nested type detection
    • Can be disabled for manual control when needed

    Dual Processing Modes

    1. psycopg Native Adapters (Default) - High-performance JSON handling at the driver level
    2. Custom JSON Processing - Fallback mechanism with enhanced type support for UUID, datetime, date, time, Decimal, set, and frozenset

    Flexible Configuration

    • New BaseRepository parameters: json_fields, auto_detect_json, and strict_json_processing
    • Database-level control via enable_json_adapters setting
    • Support for both strict and lenient JSON processing modes

    Enhanced Error Handling

    • New specialized exceptions: JSONProcessingError, JSONSerializationError, JSONDeserializationError
    • Comprehensive error messages for debugging
    • Protection against JSON injection and malformed data

    Real-World Examples

    • Production-ready JSONB usage patterns
    • Complex nested structure handling
    • PostgreSQL JSONB operators and querying techniques
    • Transaction support with JSONB fields
    • Performance optimization guidance

    🔧 Development Tooling Modernization

    Migration to Hatch and uv

    • Replaced Poetry with Hatch for building and uv for dependency management
    • Updated pyproject.toml to follow PEP 621 standard
    • Modern dependency resolution with uv.lock

    Enhanced CI/CD

    • New GitHub Actions workflows:
      • build-test.yml - Comprehensive CI testing
      • release.yml - Automated PyPI releases
      • benchmark.yml - Performance regression testing
    • Integrated Ruff for fast, comprehensive linting and formatting
    • Automated dependency updates via Renovate

    📊 Performance Testing

    • Comprehensive JSONB performance benchmarks
    • Memory usage analysis tools
    • Comparison metrics for JSONB vs traditional columns
    • Bulk operation performance testing

    📚 Documentation

    • Dedicated jsonb_support.md documentation
    • Multiple real-world JSONB examples
    • Migration guides for existing projects
    • Best practices for production use

    🏗️ Architecture Improvements

    • Introduced factory pattern to resolve circular dependencies
    • Better separation of concerns between Database and TransactionManager
    • Improved type hints with TYPE_CHECKING imports

    🔒 Security Enhancements

    • SQL injection prevention using SQL identifiers for table names
    • JSON injection protection in custom processing mode

    🧪 Testing Infrastructure

    • Reorganized test structure for better organization
    • Added comprehensive test utilities for JSONB schema management
    • Edge case testing for circular references, Unicode, and special numeric values

    🚀 Quick Start with JSONB

    from psycopg_toolkit import BaseRepository
    from pydantic import BaseModel
    
    class UserProfile(BaseModel):
        id: int
        name: str
        settings: dict  # Automatically stored as JSONB
        tags: list[str]  # Automatically stored as JSONB
    
    class UserRepository(BaseRepository[UserProfile, int]):
        def __init__(self, db_connection):
            super().__init__(
                db_connection=db_connection,
                table_name="user_profiles",
                model_class=UserProfile,
                primary_key="id"
            )
    
    # That's it! Your dict and list fields are now JSONB columns
    

    🔄 Migration Notes

    While this release includes some breaking changes, sensible defaults minimize disruption:

    • Python 3.11+ is now required (previously 3.9+)
    • JSON adapters are enabled by default for better performance
    • Fields typed as dict or list are automatically treated as JSONB

    For existing projects wanting to maintain previous behavior:

    # Disable automatic JSON handling
    settings = DatabaseSettings(..., enable_json_adapters=False)
    repo = BaseRepository(..., auto_detect_json=False)
    

    See the full migration guide in the documentation for detailed instructions.

  6. v0.1.6Enhance psycopg-toolkit documentation and functionality by oranheim
    Release notes

    Changes:

    • Update README with comprehensive examples and usage guides
    • Improve docstrings and code comments in core components
    • Add advanced transaction and schema management examples
    • Enhance error handling documentation
    • Refactor and expand test coverage
  7. v0.1.5Refactor database and transaction management by oranheim
    Release notes

    Changes:

    • Simplified Database and TransactionManager classes
    • Added comprehensive error handling
    • Improved connection pool management
    • Updated project dependencies
    • Enhanced test suite with test containers
  8. v0.1.0Initial Release v0.1.0 by oranheim
    Release notes

    Initial release of Psycopg Toolkit providing async-first PostgreSQL database management for Python.

    Features:

    Database:

    • Async connection pooling via psycopg-pool
    • Exponential backoff retry with configurable attempts
    • Runtime pool optimization and health checks
    • Automatic cleanup on shutdown

    Transactions:

    • Automatic rollback on errors
    • Nested transaction support via savepoints
    • Connection state management
    • Integrated repository support

    Repository Pattern:

    • Generic type-safe CRUD operations
    • Pydantic model integration
    • Parameterized query building
    • Bulk operation support

    Requires Python 3.11+ and PostgreSQL 13+.

All 8 held

Latest commits

Commit log

Documentation