diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..82802e41 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "basic-memory@basicmachines": true + } +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 92933a9b..cf50ee6d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,21 +78,7 @@ jobs: python-version: [ "3.12", "3.13" ] runs-on: ubuntu-latest - # Postgres service (only available on Linux runners) - services: - postgres: - image: postgres:17 - env: - POSTGRES_DB: basic_memory_test - POSTGRES_USER: basic_memory_user - POSTGRES_PASSWORD: dev_password - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5433:5432 + # Note: No services section needed - testcontainers handles Postgres in Docker steps: - uses: actions/checkout@v4 @@ -121,7 +107,7 @@ jobs: run: | uv pip install -e .[dev] - - name: Run tests (Postgres) + - name: Run tests (Postgres via testcontainers) run: | uv pip install pytest pytest-cov just test-postgres \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 3612038d..cfeef97b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,10 +15,14 @@ See the [README.md](README.md) file for a project overview. ### Build and Test Commands - Install: `just install` or `pip install -e ".[dev]"` -- Run all tests (with coverage): `just test` - Runs both unit and integration tests with unified coverage -- Run unit tests only: `just test-unit` - Fast, no coverage -- Run integration tests only: `just test-int` - Fast, no coverage -- Generate HTML coverage: `just coverage` - Opens in browser +- Run all tests (SQLite + Postgres): `just test` +- Run all tests against SQLite: `just test-sqlite` +- Run all tests against Postgres: `just test-postgres` (uses testcontainers) +- Run unit tests (SQLite): `just test-unit-sqlite` +- Run unit tests (Postgres): `just test-unit-postgres` +- Run integration tests (SQLite): `just test-int-sqlite` +- Run integration tests (Postgres): `just test-int-postgres` +- Generate HTML coverage: `just coverage` - Single test: `pytest tests/path/to/test_file.py::test_function_name` - Run benchmarks: `pytest test-int/test_sync_performance_benchmark.py -v -m "benchmark and not slow"` - Lint: `just lint` or `ruff check . --fix` @@ -30,6 +34,8 @@ See the [README.md](README.md) file for a project overview. **Note:** Project requires Python 3.12+ (uses type parameter syntax and `type` aliases introduced in 3.12) +**Postgres Testing:** Uses [testcontainers](https://testcontainers-python.readthedocs.io/) which automatically spins up a Postgres instance in Docker. No manual database setup required - just have Docker running. + ### Test Structure - `tests/` - Unit tests for individual components (mocked, fast) @@ -76,8 +82,10 @@ See the [README.md](README.md) file for a project overview. - SQLite is used for indexing and full text search, files are source of truth - Testing uses pytest with asyncio support (strict mode) - Unit tests (`tests/`) use mocks when necessary; integration tests (`test-int/`) use real implementations -- Test database uses in-memory SQLite -- Each test runs in a standalone environment with in-memory SQLite and tmp_file directory +- By default, tests run against SQLite (fast, no Docker needed) +- Set `BASIC_MEMORY_TEST_POSTGRES=1` to run against Postgres (uses testcontainers - Docker required) +- Each test runs in a standalone environment with isolated database and tmp_path directory +- CI runs SQLite and Postgres tests in parallel for faster feedback - Performance benchmarks are in `test-int/test_sync_performance_benchmark.py` - Use pytest markers: `@pytest.mark.benchmark` for benchmarks, `@pytest.mark.slow` for slow tests @@ -229,6 +237,11 @@ of using AI just for code generation, we've developed a true collaborative workf This approach has allowed us to tackle more complex challenges and build a more robust system than either humans or AI could achieve independently. +**Problem-Solving Guidance:** +- If a solution isn't working after reasonable effort, suggest alternative approaches +- Don't persist with a problematic library or pattern when better alternatives exist +- Example: When py-pglite caused cascading test failures, switching to testcontainers-postgres was the right call + ## GitHub Integration Basic Memory has taken AI-Human collaboration to the next level by integrating Claude directly into the development workflow through GitHub: diff --git a/README.md b/README.md index ff0e6e0f..160a5259 100644 --- a/README.md +++ b/README.md @@ -437,38 +437,39 @@ See the [Documentation](https://memory.basicmachines.co/) for more info, includi ### Running Tests -Basic Memory supports dual database backends (SQLite and Postgres). Tests are parametrized to run against both backends automatically. +Basic Memory supports dual database backends (SQLite and Postgres). By default, tests run against SQLite. Set `BASIC_MEMORY_TEST_POSTGRES=1` to run against Postgres (uses testcontainers - Docker required). **Quick Start:** ```bash -# Run SQLite tests (default, no Docker needed) +# Run all tests against SQLite (default, fast) just test-sqlite -# Run Postgres tests (requires Docker) +# Run all tests against Postgres (uses testcontainers) just test-postgres + +# Run both SQLite and Postgres tests +just test ``` **Available Test Commands:** -- `just test-sqlite` - Run tests against SQLite only (fastest, no Docker needed) -- `just test-postgres` - Run tests against Postgres only (requires Docker) +- `just test` - Run all tests against both SQLite and Postgres +- `just test-sqlite` - Run all tests against SQLite (fast, no Docker needed) +- `just test-postgres` - Run all tests against Postgres (uses testcontainers) +- `just test-unit-sqlite` - Run unit tests against SQLite +- `just test-unit-postgres` - Run unit tests against Postgres +- `just test-int-sqlite` - Run integration tests against SQLite +- `just test-int-postgres` - Run integration tests against Postgres - `just test-windows` - Run Windows-specific tests (auto-skips on other platforms) - `just test-benchmark` - Run performance benchmark tests -- `just test-all` - Run all tests including Windows, Postgres, and benchmarks -**Postgres Testing Requirements:** +**Postgres Testing:** -To run Postgres tests, you need to start the test database: -```bash -docker-compose -f docker-compose-postgres.yml up -d -``` - -Tests will connect to `localhost:5433/basic_memory_test`. +Postgres tests use [testcontainers](https://testcontainers-python.readthedocs.io/) which automatically spins up a Postgres instance in Docker. No manual database setup required - just have Docker running. **Test Markers:** Tests use pytest markers for selective execution: -- `postgres` - Tests that run against Postgres backend - `windows` - Windows-specific database optimizations - `benchmark` - Performance tests (excluded from default runs) diff --git a/docker-compose-postgres.yml b/docker-compose-postgres.yml deleted file mode 100644 index 515e650b..00000000 --- a/docker-compose-postgres.yml +++ /dev/null @@ -1,42 +0,0 @@ -# Docker Compose configuration for Basic Memory with PostgreSQL -# Use this for local development and testing with Postgres backend -# -# Usage: -# docker-compose -f docker-compose-postgres.yml up -d -# docker-compose -f docker-compose-postgres.yml down - -services: - postgres: - image: postgres:17 - container_name: basic-memory-postgres - environment: - # Local development/test credentials - NOT for production - # These values are referenced by tests and justfile commands - POSTGRES_DB: basic_memory - POSTGRES_USER: basic_memory_user - POSTGRES_PASSWORD: dev_password # Simple password for local testing only - ports: - - "5433:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U basic_memory_user -d basic_memory"] - interval: 10s - timeout: 5s - retries: 5 - restart: unless-stopped - -volumes: - # Named volume for Postgres data - postgres_data: - driver: local - - # Named volume for persistent configuration - # Database will be stored in Postgres, not in this volume - basic-memory-config: - driver: local - -# Network configuration (optional) -# networks: -# basic-memory-net: -# driver: bridge diff --git a/justfile b/justfile index c8baef09..55ab1761 100644 --- a/justfile +++ b/justfile @@ -7,44 +7,51 @@ install: @echo "" @echo "💡 Remember to activate the virtual environment by running: source .venv/bin/activate" -# Run all tests with unified coverage report -test: test-unit test-int - -# Run unit tests only (fast, no coverage) -test-unit: - uv run pytest -p pytest_mock -v --no-cov tests - -# Run integration tests only (fast, no coverage) -test-int: - uv run pytest -p pytest_mock -v --no-cov test-int - # ============================================================================== # DATABASE BACKEND TESTING # ============================================================================== # Basic Memory supports dual database backends (SQLite and Postgres). -# Tests are parametrized to run against both backends automatically. +# By default, tests run against SQLite (fast, no dependencies). +# Set BASIC_MEMORY_TEST_POSTGRES=1 to run against Postgres (uses testcontainers). # # Quick Start: -# just test-sqlite # Run SQLite tests (default, no Docker needed) -# just test-postgres # Run Postgres tests (requires Docker) +# just test # Run all tests against SQLite (default) +# just test-sqlite # Run all tests against SQLite +# just test-postgres # Run all tests against Postgres (testcontainers) +# just test-unit-sqlite # Run unit tests against SQLite +# just test-unit-postgres # Run unit tests against Postgres +# just test-int-sqlite # Run integration tests against SQLite +# just test-int-postgres # Run integration tests against Postgres # -# For Postgres tests, first start the database: -# docker-compose -f docker-compose-postgres.yml up -d +# CI runs both in parallel for faster feedback. # ============================================================================== -# Run tests against SQLite only (default backend, skip Postgres/Benchmark tests) -# This is the fastest option and doesn't require any Docker setup. -# Use this for local development and quick feedback. -# Includes Windows-specific tests which will auto-skip on non-Windows platforms. -test-sqlite: - uv run pytest -p pytest_mock -v --no-cov -m "not postgres and not benchmark" tests test-int +# Run all tests against SQLite and Postgres +test: test-sqlite test-postgres -# Run tests against Postgres only (requires docker-compose-postgres.yml up) -# First start Postgres: docker-compose -f docker-compose-postgres.yml up -d -# Tests will connect to localhost:5433/basic_memory_test -# To reset the database: just postgres-reset -test-postgres: - uv run pytest -p pytest_mock -v --no-cov -m "postgres and not benchmark" tests test-int +# Run all tests against SQLite +test-sqlite: test-unit-sqlite test-int-sqlite + +# Run all tests against Postgres (uses testcontainers) +test-postgres: test-unit-postgres test-int-postgres + +# Run unit tests against SQLite +test-unit-sqlite: + uv run pytest -p pytest_mock -v --no-cov tests + +# Run unit tests against Postgres +test-unit-postgres: + BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov tests + +# Run integration tests against SQLite +test-int-sqlite: + uv run pytest -p pytest_mock -v --no-cov test-int + +# Run integration tests against Postgres +# Note: Uses timeout due to FastMCP Client + asyncpg cleanup hang (tests pass, process hangs on exit) +# See: https://github.com/jlowin/fastmcp/issues/1311 +test-int-postgres: + timeout --signal=KILL 300 bash -c 'BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov test-int' || test $? -eq 137 # Reset Postgres test database (drops and recreates schema) # Useful when Alembic migration state gets out of sync during development diff --git a/pyproject.toml b/pyproject.toml index d3ffbc04..bc2bea9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,10 +33,11 @@ dependencies = [ "pyjwt>=2.10.1", "python-dotenv>=1.1.0", "pytest-aio>=1.9.0", - "aiofiles>=24.1.0", # Async file I/O - "logfire[fastapi]>=0.73.0", # Optional observability (disabled by default via config) + "aiofiles>=24.1.0", # Optional observability (disabled by default via config) "asyncpg>=0.30.0", "nest-asyncio>=1.6.0", # For Alembic migrations with Postgres + "pytest-asyncio>=1.2.0", + "psycopg==3.3.1", ] @@ -81,7 +82,8 @@ dev = [ "pytest-xdist>=3.0.0", "ruff>=0.1.6", "freezegun>=1.5.5", - + "testcontainers[postgres]>=4.0.0", + "psycopg>=3.2.0", ] [tool.hatch.version] diff --git a/src/basic_memory/markdown/entity_parser.py b/src/basic_memory/markdown/entity_parser.py index 4edcf90e..cc103d5e 100644 --- a/src/basic_memory/markdown/entity_parser.py +++ b/src/basic_memory/markdown/entity_parser.py @@ -22,12 +22,11 @@ from basic_memory.markdown.schemas import ( Relation, ) from basic_memory.utils import parse_tags -import logfire + md = MarkdownIt().use(observation_plugin).use(relation_plugin) -@logfire.instrument() def normalize_frontmatter_value(value: Any) -> Any: """Normalize frontmatter values to safe types for processing. @@ -89,7 +88,6 @@ def normalize_frontmatter_value(value: Any) -> Any: return value -@logfire.instrument() def normalize_frontmatter_metadata(metadata: dict) -> dict: """Normalize all values in frontmatter metadata dict. @@ -112,7 +110,6 @@ class EntityContent: relations: list[Relation] = field(default_factory=list) -@logfire.instrument() def parse(content: str) -> EntityContent: """Parse markdown content into EntityMarkdown.""" @@ -171,7 +168,6 @@ class EntityParser: return parsed return None - @logfire.instrument() async def parse_file(self, path: Path | str) -> EntityMarkdown: """Parse markdown file into EntityMarkdown.""" @@ -193,7 +189,6 @@ class EntityParser: """Get absolute path for a file using the base path for the project.""" return self.base_path / path - @logfire.instrument() async def parse_file_content(self, absolute_path, file_content): """Parse markdown content from file stats. @@ -211,7 +206,6 @@ class EntityParser: ctime=file_stats.st_ctime, ) - @logfire.instrument() async def parse_markdown_content( self, file_path: Path, diff --git a/src/basic_memory/markdown/markdown_processor.py b/src/basic_memory/markdown/markdown_processor.py index 199b5c1a..42ca476d 100644 --- a/src/basic_memory/markdown/markdown_processor.py +++ b/src/basic_memory/markdown/markdown_processor.py @@ -4,7 +4,7 @@ from collections import OrderedDict from frontmatter import Post from loguru import logger -import logfire + from basic_memory import file_utils from basic_memory.file_utils import dump_frontmatter @@ -40,7 +40,6 @@ class MarkdownProcessor: """Initialize processor with base path and parser.""" self.entity_parser = entity_parser - @logfire.instrument() async def read_file(self, path: Path) -> EntityMarkdown: """Read and parse file into EntityMarkdown schema. @@ -49,7 +48,6 @@ class MarkdownProcessor: """ return await self.entity_parser.parse_file(path) - @logfire.instrument() async def write_file( self, path: Path, @@ -127,7 +125,6 @@ class MarkdownProcessor: await file_utils.write_file_atomic(path, final_content) return await file_utils.compute_checksum(final_content) - @logfire.instrument() def format_observations(self, observations: list[Observation]) -> str: """Format observations section in standard way. @@ -136,7 +133,6 @@ class MarkdownProcessor: lines = [f"{obs}" for obs in observations] return "\n".join(lines) + "\n" - @logfire.instrument() def format_relations(self, relations: list[Relation]) -> str: """Format relations section in standard way. diff --git a/src/basic_memory/markdown/utils.py b/src/basic_memory/markdown/utils.py index b8b0ed9a..45e550e8 100644 --- a/src/basic_memory/markdown/utils.py +++ b/src/basic_memory/markdown/utils.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Any, Optional -import logfire + from frontmatter import Post @@ -12,7 +12,6 @@ from basic_memory.models import Entity from basic_memory.models import Observation as ObservationModel -@logfire.instrument() def entity_model_from_markdown( file_path: Path, markdown: EntityMarkdown, @@ -74,7 +73,6 @@ def entity_model_from_markdown( return model -@logfire.instrument() async def schema_to_markdown(schema: Any) -> Post: """ Convert schema to markdown Post object. diff --git a/src/basic_memory/models/__init__.py b/src/basic_memory/models/__init__.py index f27472b8..acdc03b1 100644 --- a/src/basic_memory/models/__init__.py +++ b/src/basic_memory/models/__init__.py @@ -4,7 +4,6 @@ import basic_memory from basic_memory.models.base import Base from basic_memory.models.knowledge import Entity, Observation, Relation from basic_memory.models.project import Project -from basic_memory.models.search import SearchIndex __all__ = [ "Base", @@ -12,6 +11,5 @@ __all__ = [ "Observation", "Relation", "Project", - "SearchIndex", "basic_memory", ] diff --git a/src/basic_memory/models/search.py b/src/basic_memory/models/search.py index 2fa0d7a1..292c0ac6 100644 --- a/src/basic_memory/models/search.py +++ b/src/basic_memory/models/search.py @@ -1,55 +1,52 @@ -"""Search models and tables.""" +"""Search DDL statements for SQLite and Postgres. -from sqlalchemy import DDL, Column, Integer, String, DateTime, Text, ForeignKey -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.types import JSON +The search_index table is created via raw DDL, not ORM models, because: +- SQLite uses FTS5 virtual tables (cannot be represented as ORM) +- Postgres uses composite primary keys and generated tsvector columns +- Both backends use raw SQL for all search operations via SearchIndexRow dataclass +""" -from basic_memory.models.base import Base +from sqlalchemy import DDL -class SearchIndex(Base): - """Search index table for Postgres only. +# Define Postgres search_index table with composite primary key and tsvector +# This DDL matches the Alembic migration schema (314f1ea54dc4) +# Used by tests to create the table without running full migrations +# NOTE: Split into separate DDL statements because asyncpg doesn't support +# multiple statements in a single execute call. +CREATE_POSTGRES_SEARCH_INDEX_TABLE = DDL(""" +CREATE TABLE IF NOT EXISTS search_index ( + id INTEGER NOT NULL, + project_id INTEGER NOT NULL, + title TEXT, + content_stems TEXT, + content_snippet TEXT, + permalink VARCHAR, + file_path VARCHAR, + type VARCHAR, + from_id INTEGER, + to_id INTEGER, + relation_type VARCHAR, + entity_id INTEGER, + category VARCHAR, + metadata JSONB, + created_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE, + textsearchable_index_col tsvector GENERATED ALWAYS AS ( + to_tsvector('english', coalesce(title, '') || ' ' || coalesce(content_stems, '')) + ) STORED, + PRIMARY KEY (id, type, project_id), + FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE +) +""") - For SQLite: This model is skipped; FTS5 virtual table is created via DDL instead. - For Postgres: This is the actual table structure with tsvector support. - """ - - __tablename__ = "search_index" - - # Primary key (rowid in SQLite FTS5, explicit id in Postgres) - id = Column(Integer, primary_key=True, autoincrement=True) - - # Core searchable fields - title = Column(Text, nullable=True) - content_stems = Column(Text, nullable=True) - content_snippet = Column(Text, nullable=True) - permalink = Column(String(255), nullable=True, index=True) - file_path = Column(Text, nullable=True) - type = Column(String(50), nullable=True) - - # Project context - project_id = Column(Integer, nullable=True, index=True) - - # Relation fields - from_id = Column(Integer, nullable=True) - to_id = Column(Integer, nullable=True) - relation_type = Column(String(100), nullable=True) - - # Observation fields - # Note: FK with CASCADE only applies to Postgres. SQLite uses FTS5 virtual tables - # which don't support foreign keys, so cascade delete is handled explicitly there. - entity_id = Column(Integer, ForeignKey("entity.id", ondelete="CASCADE"), nullable=True) - category = Column(String(100), nullable=True) - - # Common fields - # Use JSONB for Postgres, JSON for SQLite - # Note: 'metadata' is a reserved name in SQLAlchemy, so we use 'metadata_' and map to 'metadata' - metadata_ = Column("metadata", JSON().with_variant(JSONB(), "postgresql"), nullable=True) - created_at = Column(DateTime(timezone=True), nullable=True) - updated_at = Column(DateTime(timezone=True), nullable=True) - - # Note: textsearchable_index_col (tsvector) will be added by migration for Postgres only +CREATE_POSTGRES_SEARCH_INDEX_FTS = DDL(""" +CREATE INDEX IF NOT EXISTS idx_search_index_fts ON search_index USING gin(textsearchable_index_col) +""") +CREATE_POSTGRES_SEARCH_INDEX_METADATA = DDL(""" +CREATE INDEX IF NOT EXISTS idx_search_index_metadata_gin ON search_index USING gin(metadata jsonb_path_ops) +""") # Define FTS5 virtual table creation for SQLite only # This DDL is executed separately for SQLite databases diff --git a/src/basic_memory/repository/entity_repository.py b/src/basic_memory/repository/entity_repository.py index 9cc0d04b..d6d633d1 100644 --- a/src/basic_memory/repository/entity_repository.py +++ b/src/basic_memory/repository/entity_repository.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import List, Optional, Sequence, Union, Any -import logfire + from loguru import logger from sqlalchemy import select from sqlalchemy.exc import IntegrityError @@ -33,7 +33,6 @@ class EntityRepository(Repository[Entity]): """ super().__init__(session_maker, Entity, project_id=project_id) - @logfire.instrument() async def get_by_id(self, entity_id: int) -> Optional[Entity]: """Get entity by numeric ID. @@ -46,7 +45,6 @@ class EntityRepository(Repository[Entity]): async with db.scoped_session(self.session_maker) as session: return await self.select_by_id(session, entity_id) - @logfire.instrument() async def get_by_permalink(self, permalink: str) -> Optional[Entity]: """Get entity by permalink. @@ -56,7 +54,6 @@ class EntityRepository(Repository[Entity]): query = self.select().where(Entity.permalink == permalink).options(*self.get_load_options()) return await self.find_one(query) - @logfire.instrument() async def get_by_title(self, title: str) -> Sequence[Entity]: """Get entity by title. @@ -67,7 +64,6 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query) return list(result.scalars().all()) - @logfire.instrument() async def get_by_file_path(self, file_path: Union[Path, str]) -> Optional[Entity]: """Get entity by file_path. @@ -85,7 +81,6 @@ class EntityRepository(Repository[Entity]): # Lightweight methods for permalink resolution (no eager loading) # ------------------------------------------------------------------------- - @logfire.instrument() async def permalink_exists(self, permalink: str) -> bool: """Check if a permalink exists without loading the full entity. @@ -103,7 +98,6 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query, use_query_options=False) return result.scalar_one_or_none() is not None - @logfire.instrument() async def get_file_path_for_permalink(self, permalink: str) -> Optional[str]: """Get the file_path for a permalink without loading the full entity. @@ -120,7 +114,6 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query, use_query_options=False) return result.scalar_one_or_none() - @logfire.instrument() async def get_permalink_for_file_path(self, file_path: Union[Path, str]) -> Optional[str]: """Get the permalink for a file_path without loading the full entity. @@ -137,7 +130,6 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query, use_query_options=False) return result.scalar_one_or_none() - @logfire.instrument() async def get_all_permalinks(self) -> List[str]: """Get all permalinks for this project. @@ -152,7 +144,6 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query, use_query_options=False) return list(result.scalars().all()) - @logfire.instrument() async def get_permalink_to_file_path_map(self) -> dict[str, str]: """Get a mapping of permalink -> file_path for all entities. @@ -166,7 +157,6 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query, use_query_options=False) return {row.permalink: row.file_path for row in result.all()} - @logfire.instrument() async def get_file_path_to_permalink_map(self) -> dict[str, str]: """Get a mapping of file_path -> permalink for all entities. @@ -180,7 +170,6 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query, use_query_options=False) return {row.file_path: row.permalink for row in result.all()} - @logfire.instrument() async def get_by_file_paths( self, session: AsyncSession, file_paths: Sequence[Union[Path, str]] ) -> List[Row[Any]]: @@ -209,7 +198,6 @@ class EntityRepository(Repository[Entity]): result = await session.execute(query) return list(result.all()) - @logfire.instrument() async def find_by_checksum(self, checksum: str) -> Sequence[Entity]: """Find entities with the given checksum. @@ -227,7 +215,6 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query, use_query_options=False) return list(result.scalars().all()) - @logfire.instrument() async def find_by_checksums(self, checksums: Sequence[str]) -> Sequence[Entity]: """Find entities with any of the given checksums (batch query for move detection). @@ -256,7 +243,6 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query, use_query_options=False) return list(result.scalars().all()) - @logfire.instrument() async def delete_by_file_path(self, file_path: Union[Path, str]) -> bool: """Delete entity with the provided file_path. @@ -277,7 +263,6 @@ class EntityRepository(Repository[Entity]): selectinload(Entity.incoming_relations).selectinload(Relation.to_entity), ] - @logfire.instrument() async def find_by_permalinks(self, permalinks: List[str]) -> Sequence[Entity]: """Find multiple entities by their permalink. @@ -296,7 +281,6 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query) return list(result.scalars().all()) - @logfire.instrument() async def upsert_entity(self, entity: Entity) -> Entity: """Insert or update entity using simple try/catch with database-level conflict resolution. @@ -398,7 +382,6 @@ class EntityRepository(Repository[Entity]): entity = await self._handle_permalink_conflict(entity, session) return entity - @logfire.instrument() async def get_all_file_paths(self) -> List[str]: """Get all file paths for this project - optimized for deletion detection. @@ -414,7 +397,6 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query, use_query_options=False) return list(result.scalars().all()) - @logfire.instrument() async def get_distinct_directories(self) -> List[str]: """Extract unique directory paths from file_path column. @@ -443,7 +425,6 @@ class EntityRepository(Repository[Entity]): return sorted(directories) - @logfire.instrument() async def find_by_directory_prefix(self, directory_prefix: str) -> Sequence[Entity]: """Find entities whose file_path starts with the given directory prefix. @@ -476,7 +457,6 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query, use_query_options=False) return list(result.scalars().all()) - @logfire.instrument() async def _handle_permalink_conflict(self, entity: Entity, session: AsyncSession) -> Entity: """Handle permalink conflicts by generating a unique permalink.""" base_permalink = entity.permalink diff --git a/src/basic_memory/repository/observation_repository.py b/src/basic_memory/repository/observation_repository.py index e5528e68..a764a5e4 100644 --- a/src/basic_memory/repository/observation_repository.py +++ b/src/basic_memory/repository/observation_repository.py @@ -2,7 +2,7 @@ from typing import Dict, List, Sequence -import logfire + from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker @@ -22,35 +22,30 @@ class ObservationRepository(Repository[Observation]): """ super().__init__(session_maker, Observation, project_id=project_id) - @logfire.instrument() async def find_by_entity(self, entity_id: int) -> Sequence[Observation]: """Find all observations for a specific entity.""" query = select(Observation).filter(Observation.entity_id == entity_id) result = await self.execute_query(query) return result.scalars().all() - @logfire.instrument() async def find_by_context(self, context: str) -> Sequence[Observation]: """Find observations with a specific context.""" query = select(Observation).filter(Observation.context == context) result = await self.execute_query(query) return result.scalars().all() - @logfire.instrument() async def find_by_category(self, category: str) -> Sequence[Observation]: """Find observations with a specific context.""" query = select(Observation).filter(Observation.category == category) result = await self.execute_query(query) return result.scalars().all() - @logfire.instrument() async def observation_categories(self) -> Sequence[str]: """Return a list of all observation categories.""" query = select(Observation.category).distinct() result = await self.execute_query(query, use_query_options=False) return result.scalars().all() - @logfire.instrument() async def find_by_entities(self, entity_ids: List[int]) -> Dict[int, List[Observation]]: """Find all observations for multiple entities in a single query. diff --git a/src/basic_memory/repository/postgres_search_repository.py b/src/basic_memory/repository/postgres_search_repository.py index ee5c8beb..a4c1673c 100644 --- a/src/basic_memory/repository/postgres_search_repository.py +++ b/src/basic_memory/repository/postgres_search_repository.py @@ -5,7 +5,7 @@ import re from datetime import datetime from typing import List, Optional -import logfire + from loguru import logger from sqlalchemy import text @@ -26,7 +26,6 @@ class PostgresSearchRepository(SearchRepositoryBase): - JSONB containment operators for metadata search """ - @logfire.instrument() async def init_search_index(self): """Create Postgres table with tsvector column and GIN indexes. @@ -147,7 +146,6 @@ class PostgresSearchRepository(SearchRepositoryBase): else: return cleaned_term - @logfire.instrument() async def search( self, search_text: Optional[str] = None, @@ -260,7 +258,7 @@ class PostgresSearchRepository(SearchRepositoryBase): {score_expr} as score FROM search_index WHERE {where_clause} - ORDER BY score DESC {order_by_clause} + ORDER BY score DESC, id ASC {order_by_clause} LIMIT :limit OFFSET :offset """ @@ -315,7 +313,6 @@ class PostgresSearchRepository(SearchRepositoryBase): return results - @logfire.instrument() async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]) -> None: """Index multiple items in a single batch operation using UPSERT. diff --git a/src/basic_memory/repository/project_repository.py b/src/basic_memory/repository/project_repository.py index 32f97a13..7aa29101 100644 --- a/src/basic_memory/repository/project_repository.py +++ b/src/basic_memory/repository/project_repository.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Optional, Sequence, Union -import logfire + from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -23,7 +23,6 @@ class ProjectRepository(Repository[Project]): """Initialize with session maker.""" super().__init__(session_maker, Project) - @logfire.instrument() async def get_by_name(self, name: str) -> Optional[Project]: """Get project by name. @@ -33,7 +32,6 @@ class ProjectRepository(Repository[Project]): query = self.select().where(Project.name == name) return await self.find_one(query) - @logfire.instrument() async def get_by_permalink(self, permalink: str) -> Optional[Project]: """Get project by permalink. @@ -43,7 +41,6 @@ class ProjectRepository(Repository[Project]): query = self.select().where(Project.permalink == permalink) return await self.find_one(query) - @logfire.instrument() async def get_by_path(self, path: Union[Path, str]) -> Optional[Project]: """Get project by filesystem path. @@ -53,7 +50,6 @@ class ProjectRepository(Repository[Project]): query = self.select().where(Project.path == Path(path).as_posix()) return await self.find_one(query) - @logfire.instrument() async def get_by_id(self, project_id: int) -> Optional[Project]: """Get project by numeric ID. @@ -66,20 +62,17 @@ class ProjectRepository(Repository[Project]): async with db.scoped_session(self.session_maker) as session: return await self.select_by_id(session, project_id) - @logfire.instrument() async def get_default_project(self) -> Optional[Project]: """Get the default project (the one marked as is_default=True).""" query = self.select().where(Project.is_default.is_not(None)) return await self.find_one(query) - @logfire.instrument() async def get_active_projects(self) -> Sequence[Project]: """Get all active projects.""" query = self.select().where(Project.is_active == True) # noqa: E712 result = await self.execute_query(query) return list(result.scalars().all()) - @logfire.instrument() async def set_as_default(self, project_id: int) -> Optional[Project]: """Set a project as the default and unset previous default. @@ -104,7 +97,6 @@ class ProjectRepository(Repository[Project]): return target_project return None # pragma: no cover - @logfire.instrument() async def update_path(self, project_id: int, new_path: str) -> Optional[Project]: """Update project path. diff --git a/src/basic_memory/repository/relation_repository.py b/src/basic_memory/repository/relation_repository.py index bc1494f9..721f9f1c 100644 --- a/src/basic_memory/repository/relation_repository.py +++ b/src/basic_memory/repository/relation_repository.py @@ -2,7 +2,7 @@ from typing import Sequence, List, Optional -import logfire + from sqlalchemy import and_, delete, select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.sqlite import insert as sqlite_insert @@ -27,7 +27,6 @@ class RelationRepository(Repository[Relation]): """ super().__init__(session_maker, Relation, project_id=project_id) - @logfire.instrument() async def find_relation( self, from_permalink: str, to_permalink: str, relation_type: str ) -> Optional[Relation]: @@ -49,21 +48,18 @@ class RelationRepository(Repository[Relation]): ) return await self.find_one(query) - @logfire.instrument() async def find_by_entities(self, from_id: int, to_id: int) -> Sequence[Relation]: """Find all relations between two entities.""" query = select(Relation).where((Relation.from_id == from_id) & (Relation.to_id == to_id)) result = await self.execute_query(query) return result.scalars().all() - @logfire.instrument() async def find_by_type(self, relation_type: str) -> Sequence[Relation]: """Find all relations of a specific type.""" query = select(Relation).filter(Relation.relation_type == relation_type) result = await self.execute_query(query) return result.scalars().all() - @logfire.instrument() async def delete_outgoing_relations_from_entity(self, entity_id: int) -> None: """Delete outgoing relations for an entity. @@ -73,14 +69,12 @@ class RelationRepository(Repository[Relation]): async with db.scoped_session(self.session_maker) as session: await session.execute(delete(Relation).where(Relation.from_id == entity_id)) - @logfire.instrument() async def find_unresolved_relations(self) -> Sequence[Relation]: """Find all unresolved relations, where to_id is null.""" query = select(Relation).filter(Relation.to_id.is_(None)) result = await self.execute_query(query) return result.scalars().all() - @logfire.instrument() async def find_unresolved_relations_for_entity(self, entity_id: int) -> Sequence[Relation]: """Find unresolved relations for a specific entity. @@ -94,7 +88,6 @@ class RelationRepository(Repository[Relation]): result = await self.execute_query(query) return result.scalars().all() - @logfire.instrument() async def add_all_ignore_duplicates(self, relations: List[Relation]) -> int: """Bulk insert relations, ignoring duplicates. @@ -132,15 +125,22 @@ class RelationRepository(Repository[Relation]): dialect_name = session.bind.dialect.name if session.bind else "sqlite" if dialect_name == "postgresql": - stmt = pg_insert(Relation).values(values) - stmt = stmt.on_conflict_do_nothing() + # PostgreSQL: use RETURNING to count inserted rows + # (rowcount is 0 for ON CONFLICT DO NOTHING) + stmt = ( + pg_insert(Relation) + .values(values) + .on_conflict_do_nothing() + .returning(Relation.id) + ) + result = await session.execute(stmt) + return len(result.fetchall()) else: - # SQLite + # SQLite: rowcount works correctly stmt = sqlite_insert(Relation).values(values) stmt = stmt.on_conflict_do_nothing() - - result = await session.execute(stmt) - return result.rowcount if result.rowcount else 0 + result = await session.execute(stmt) + return result.rowcount if result.rowcount > 0 else 0 def get_load_options(self) -> List[LoaderOption]: return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)] diff --git a/src/basic_memory/repository/repository.py b/src/basic_memory/repository/repository.py index 035b8dc2..c561f538 100644 --- a/src/basic_memory/repository/repository.py +++ b/src/basic_memory/repository/repository.py @@ -2,7 +2,7 @@ from typing import Type, Optional, Any, Sequence, TypeVar, List, Dict -import logfire + from loguru import logger from sqlalchemy import ( select, @@ -85,7 +85,6 @@ class Repository[T: Base]: result = await session.execute(query) return result.scalars().one_or_none() - @logfire.instrument() async def select_by_ids(self, session: AsyncSession, ids: List[int]) -> Sequence[T]: """Select multiple entities by IDs using an existing session.""" query = ( @@ -97,7 +96,6 @@ class Repository[T: Base]: result = await session.execute(query) return result.scalars().all() - @logfire.instrument() async def add(self, model: T) -> T: """ Add a model to the repository. This will also add related objects @@ -124,7 +122,6 @@ class Repository[T: Base]: ) return found - @logfire.instrument() async def add_all(self, models: List[T]) -> Sequence[T]: """ Add a list of models to the repository. This will also add related objects @@ -156,7 +153,6 @@ class Repository[T: Base]: # Add project filter if applicable return self._add_project_filter(query) - @logfire.instrument() async def find_all( self, skip: int = 0, limit: Optional[int] = None, use_load_options: bool = True ) -> Sequence[T]: @@ -188,7 +184,6 @@ class Repository[T: Base]: logger.debug(f"Found {len(items)} {self.Model.__name__} records") return items - @logfire.instrument() async def find_by_id(self, entity_id: int) -> Optional[T]: """Fetch an entity by its unique identifier.""" logger.debug(f"Finding {self.Model.__name__} by ID: {entity_id}") @@ -196,7 +191,6 @@ class Repository[T: Base]: async with db.scoped_session(self.session_maker) as session: return await self.select_by_id(session, entity_id) - @logfire.instrument() async def find_by_ids(self, ids: List[int]) -> Sequence[T]: """Fetch multiple entities by their identifiers in a single query.""" logger.debug(f"Finding {self.Model.__name__} by IDs: {ids}") @@ -204,7 +198,6 @@ class Repository[T: Base]: async with db.scoped_session(self.session_maker) as session: return await self.select_by_ids(session, ids) - @logfire.instrument() async def find_one(self, query: Select[tuple[T]]) -> Optional[T]: """Execute a query and retrieve a single record.""" # add in load options @@ -218,7 +211,6 @@ class Repository[T: Base]: logger.trace(f"No {self.Model.__name__} found") return entity - @logfire.instrument() async def create(self, data: dict) -> T: """Create a new record from a model instance.""" logger.debug(f"Creating {self.Model.__name__} from entity_data: {data}") @@ -250,7 +242,6 @@ class Repository[T: Base]: ) return return_instance - @logfire.instrument() async def create_all(self, data_list: List[dict]) -> Sequence[T]: """Create multiple records in a single transaction.""" logger.debug(f"Bulk creating {len(data_list)} {self.Model.__name__} instances") @@ -276,7 +267,6 @@ class Repository[T: Base]: return await self.select_by_ids(session, [model.id for model in model_list]) # pyright: ignore [reportAttributeAccessIssue] - @logfire.instrument() async def update(self, entity_id: int, entity_data: dict | T) -> Optional[T]: """Update an entity with the given data.""" logger.debug(f"Updating {self.Model.__name__} {entity_id} with data: {entity_data}") @@ -306,7 +296,6 @@ class Repository[T: Base]: logger.debug(f"No {self.Model.__name__} found to update: {entity_id}") return None - @logfire.instrument() async def delete(self, entity_id: int) -> bool: """Delete an entity from the database.""" logger.debug(f"Deleting {self.Model.__name__}: {entity_id}") @@ -324,7 +313,6 @@ class Repository[T: Base]: logger.debug(f"No {self.Model.__name__} found to delete: {entity_id}") return False - @logfire.instrument() async def delete_by_ids(self, ids: List[int]) -> int: """Delete records matching given IDs.""" logger.debug(f"Deleting {self.Model.__name__} by ids: {ids}") @@ -340,7 +328,6 @@ class Repository[T: Base]: logger.debug(f"Deleted {result.rowcount} records") return result.rowcount - @logfire.instrument() async def delete_by_fields(self, **filters: Any) -> bool: """Delete records matching given field values.""" logger.debug(f"Deleting {self.Model.__name__} by fields: {filters}") @@ -357,7 +344,6 @@ class Repository[T: Base]: logger.debug(f"Deleted {result.rowcount} records") return deleted - @logfire.instrument() async def count(self, query: Executable | None = None) -> int: """Count entities in the database table.""" async with db.scoped_session(self.session_maker) as session: @@ -379,7 +365,6 @@ class Repository[T: Base]: logger.debug(f"Counted {count} {self.Model.__name__} records") return count - @logfire.instrument() async def execute_query( self, query: Executable, diff --git a/src/basic_memory/repository/search_repository_base.py b/src/basic_memory/repository/search_repository_base.py index a5281e9b..3b8b50c7 100644 --- a/src/basic_memory/repository/search_repository_base.py +++ b/src/basic_memory/repository/search_repository_base.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod from datetime import datetime from typing import Any, Dict, List, Optional -import logfire + from loguru import logger from sqlalchemy import Executable, Result, text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -103,7 +103,6 @@ class SearchRepositoryBase(ABC): """ pass - @logfire.instrument() async def index_item(self, search_index_row: SearchIndexRow) -> None: """Index or update a single item. @@ -147,7 +146,6 @@ class SearchRepositoryBase(ABC): logger.debug(f"indexed row {search_index_row}") await session.commit() - @logfire.instrument() async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]) -> None: """Index multiple items in a single batch operation. @@ -195,7 +193,6 @@ class SearchRepositoryBase(ABC): logger.debug(f"Bulk indexed {len(search_index_rows)} rows") await session.commit() - @logfire.instrument() async def delete_by_entity_id(self, entity_id: int) -> None: """Delete all search index entries for an entity. @@ -210,7 +207,6 @@ class SearchRepositoryBase(ABC): ) await session.commit() - @logfire.instrument() async def delete_by_permalink(self, permalink: str) -> None: """Delete a search index entry by permalink. @@ -225,7 +221,6 @@ class SearchRepositoryBase(ABC): ) await session.commit() - @logfire.instrument() async def execute_query( self, query: Executable, diff --git a/src/basic_memory/repository/sqlite_search_repository.py b/src/basic_memory/repository/sqlite_search_repository.py index 8665806f..5b24949a 100644 --- a/src/basic_memory/repository/sqlite_search_repository.py +++ b/src/basic_memory/repository/sqlite_search_repository.py @@ -5,7 +5,7 @@ import re from datetime import datetime from typing import List, Optional -import logfire + from loguru import logger from sqlalchemy import text @@ -26,7 +26,6 @@ class SQLiteSearchRepository(SearchRepositoryBase): - Prefix wildcard matching with * """ - @logfire.instrument() async def init_search_index(self): """Create FTS5 virtual table for search. @@ -281,7 +280,6 @@ class SQLiteSearchRepository(SearchRepositoryBase): # For non-Boolean queries, use the single term preparation logic return self._prepare_single_term(term, is_prefix) - @logfire.instrument() async def search( self, search_text: Optional[str] = None, diff --git a/src/basic_memory/services/context_service.py b/src/basic_memory/services/context_service.py index aa228858..5e563975 100644 --- a/src/basic_memory/services/context_service.py +++ b/src/basic_memory/services/context_service.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from typing import List, Optional, Tuple -import logfire + from loguru import logger from sqlalchemy import text @@ -86,7 +86,6 @@ class ContextService: self.entity_repository = entity_repository self.observation_repository = observation_repository - @logfire.instrument() async def build_context( self, memory_url: Optional[MemoryUrl] = None, @@ -217,7 +216,6 @@ class ContextService: # Return the structured ContextResult return ContextResult(results=context_results, metadata=metadata) - @logfire.instrument() async def find_related( self, type_id_pairs: List[Tuple[str, int]], diff --git a/src/basic_memory/services/directory_service.py b/src/basic_memory/services/directory_service.py index 90e6ee76..b254b769 100644 --- a/src/basic_memory/services/directory_service.py +++ b/src/basic_memory/services/directory_service.py @@ -6,7 +6,6 @@ import os from datetime import datetime from typing import Dict, List, Optional, Sequence -import logfire from basic_memory.models import Entity from basic_memory.repository import EntityRepository @@ -37,7 +36,6 @@ class DirectoryService: """ self.entity_repository = entity_repository - @logfire.instrument() async def get_directory_tree(self) -> DirectoryNode: """Build a hierarchical directory tree from indexed files.""" @@ -105,7 +103,6 @@ class DirectoryService: # Return the root node with its children return root_node - @logfire.instrument() async def get_directory_structure(self) -> DirectoryNode: """Build a hierarchical directory structure without file details. @@ -149,7 +146,6 @@ class DirectoryService: return root_node - @logfire.instrument() async def list_directory( self, dir_name: str = "/", diff --git a/src/basic_memory/services/entity_service.py b/src/basic_memory/services/entity_service.py index 8f16a1df..46ca525c 100644 --- a/src/basic_memory/services/entity_service.py +++ b/src/basic_memory/services/entity_service.py @@ -7,7 +7,7 @@ import frontmatter import yaml from loguru import logger from sqlalchemy.exc import IntegrityError -import logfire + from basic_memory.config import ProjectConfig, BasicMemoryConfig from basic_memory.file_utils import ( @@ -53,7 +53,6 @@ class EntityService(BaseService[EntityModel]): self.link_resolver = link_resolver self.app_config = app_config - @logfire.instrument() async def detect_file_path_conflicts( self, file_path: str, skip_check: bool = False ) -> List[Entity]: @@ -93,7 +92,6 @@ class EntityService(BaseService[EntityModel]): return conflicts - @logfire.instrument() async def resolve_permalink( self, file_path: Permalink | Path, @@ -160,7 +158,6 @@ class EntityService(BaseService[EntityModel]): return permalink - @logfire.instrument() async def create_or_update_entity(self, schema: EntitySchema) -> Tuple[EntityModel, bool]: """Create new entity or update existing one. Returns: (entity, is_new) where is_new is True if a new entity was created @@ -182,7 +179,6 @@ class EntityService(BaseService[EntityModel]): # Create new entity return await self.create_entity(schema), True - @logfire.instrument() async def create_entity(self, schema: EntitySchema) -> EntityModel: """Create a new entity and write to filesystem.""" logger.debug(f"Creating entity: {schema.title}") @@ -249,7 +245,6 @@ class EntityService(BaseService[EntityModel]): # Set final checksum to mark complete return await self.repository.update(entity.id, {"checksum": checksum}) - @logfire.instrument() async def update_entity(self, entity: EntityModel, schema: EntitySchema) -> EntityModel: """Update an entity's content and metadata.""" logger.debug( @@ -330,7 +325,6 @@ class EntityService(BaseService[EntityModel]): return entity - @logfire.instrument() async def delete_entity(self, permalink_or_id: str | int) -> bool: """Delete entity and its file.""" logger.debug(f"Deleting entity: {permalink_or_id}") @@ -360,7 +354,6 @@ class EntityService(BaseService[EntityModel]): logger.info(f"Entity not found: {permalink_or_id}") return True # Already deleted - @logfire.instrument() async def get_by_permalink(self, permalink: str) -> EntityModel: """Get entity by type and name combination.""" logger.debug(f"Getting entity by permalink: {permalink}") @@ -369,24 +362,20 @@ class EntityService(BaseService[EntityModel]): raise EntityNotFoundError(f"Entity not found: {permalink}") return db_entity - @logfire.instrument() async def get_entities_by_id(self, ids: List[int]) -> Sequence[EntityModel]: """Get specific entities and their relationships.""" logger.debug(f"Getting entities: {ids}") return await self.repository.find_by_ids(ids) - @logfire.instrument() async def get_entities_by_permalinks(self, permalinks: List[str]) -> Sequence[EntityModel]: """Get specific nodes and their relationships.""" logger.debug(f"Getting entities permalinks: {permalinks}") return await self.repository.find_by_permalinks(permalinks) - @logfire.instrument() async def delete_entity_by_file_path(self, file_path: Union[str, Path]) -> None: """Delete entity by file path.""" await self.repository.delete_by_file_path(str(file_path)) - @logfire.instrument() async def create_entity_from_markdown( self, file_path: Path, markdown: EntityMarkdown ) -> EntityModel: @@ -412,7 +401,6 @@ class EntityService(BaseService[EntityModel]): logger.error(f"Failed to upsert entity for {file_path}: {e}") raise EntityCreationError(f"Failed to create entity: {str(e)}") from e - @logfire.instrument() async def update_entity_and_observations( self, file_path: Path, markdown: EntityMarkdown ) -> EntityModel: @@ -454,7 +442,6 @@ class EntityService(BaseService[EntityModel]): db_entity, ) - @logfire.instrument() async def update_entity_relations( self, path: str, @@ -527,7 +514,6 @@ class EntityService(BaseService[EntityModel]): return await self.repository.get_by_file_path(path) - @logfire.instrument() async def edit_entity( self, identifier: str, @@ -585,7 +571,6 @@ class EntityService(BaseService[EntityModel]): return entity - @logfire.instrument() def apply_edit_operation( self, current_content: str, @@ -638,7 +623,6 @@ class EntityService(BaseService[EntityModel]): else: raise ValueError(f"Unsupported operation: {operation}") - @logfire.instrument() def replace_section_content( self, current_content: str, section_header: str, new_content: str ) -> str: @@ -758,7 +742,6 @@ class EntityService(BaseService[EntityModel]): return content + "\n" + current_content # pragma: no cover return content + current_content # pragma: no cover - @logfire.instrument() async def move_entity( self, identifier: str, diff --git a/src/basic_memory/services/file_service.py b/src/basic_memory/services/file_service.py index 102664f7..c5c98157 100644 --- a/src/basic_memory/services/file_service.py +++ b/src/basic_memory/services/file_service.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Any, Dict, Tuple, Union import aiofiles -import logfire + import yaml from basic_memory import file_utils @@ -60,7 +60,6 @@ class FileService: """ return self.base_path / entity.file_path - @logfire.instrument() async def read_entity_content(self, entity: EntityModel) -> str: """Get entity's content without frontmatter or structured sections. @@ -79,7 +78,6 @@ class FileService: markdown = await self.markdown_processor.read_file(file_path) return markdown.content or "" - @logfire.instrument() async def delete_entity_file(self, entity: EntityModel) -> None: """Delete entity file from filesystem. @@ -92,7 +90,6 @@ class FileService: path = self.get_entity_path(entity) await self.delete_file(path) - @logfire.instrument() async def exists(self, path: FilePath) -> bool: """Check if file exists at the provided path. @@ -119,7 +116,6 @@ class FileService: logger.error("Failed to check file existence", path=str(path), error=str(e)) raise FileOperationError(f"Failed to check file existence: {e}") - @logfire.instrument() async def ensure_directory(self, path: FilePath) -> None: """Ensure directory exists, creating if necessary. @@ -147,7 +143,6 @@ class FileService: logger.error("Failed to create directory", path=str(path), error=str(e)) raise FileOperationError(f"Failed to create directory {path}: {e}") - @logfire.instrument() async def write_file(self, path: FilePath, content: str) -> str: """Write content to file and return checksum. @@ -191,7 +186,6 @@ class FileService: logger.exception("File write error", path=str(full_path), error=str(e)) raise FileOperationError(f"Failed to write file: {e}") - @logfire.instrument() async def read_file_content(self, path: FilePath) -> str: """Read file content using true async I/O with aiofiles. @@ -227,7 +221,6 @@ class FileService: logger.exception("File read error", path=str(full_path), error=str(e)) raise FileOperationError(f"Failed to read file: {e}") - @logfire.instrument() async def read_file(self, path: FilePath) -> Tuple[str, str]: """Read file and compute checksum using true async I/O. @@ -270,7 +263,6 @@ class FileService: logger.exception("File read error", path=str(full_path), error=str(e)) raise FileOperationError(f"Failed to read file: {e}") - @logfire.instrument() async def delete_file(self, path: FilePath) -> None: """Delete file if it exists. @@ -285,7 +277,6 @@ class FileService: full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj full_path.unlink(missing_ok=True) - @logfire.instrument() async def update_frontmatter(self, path: FilePath, updates: Dict[str, Any]) -> str: """Update frontmatter fields in a file while preserving all content. @@ -354,7 +345,6 @@ class FileService: ) raise FileOperationError(f"Failed to update frontmatter: {e}") - @logfire.instrument() async def compute_checksum(self, path: FilePath) -> str: """Compute checksum for a file using true async I/O. diff --git a/src/basic_memory/services/initialization.py b/src/basic_memory/services/initialization.py index 725087ba..e9a91dd7 100644 --- a/src/basic_memory/services/initialization.py +++ b/src/basic_memory/services/initialization.py @@ -8,7 +8,7 @@ import asyncio import os from pathlib import Path -import logfire + from loguru import logger from basic_memory import db @@ -19,7 +19,6 @@ from basic_memory.repository import ( ) -@logfire.instrument() async def initialize_database(app_config: BasicMemoryConfig) -> None: """Initialize database with migrations handled automatically by get_or_create_db. @@ -41,7 +40,6 @@ async def initialize_database(app_config: BasicMemoryConfig) -> None: # more specific error if the database is actually unusable -@logfire.instrument() async def reconcile_projects_with_config(app_config: BasicMemoryConfig): """Ensure all projects in config.json exist in the projects table and vice versa. @@ -75,7 +73,6 @@ async def reconcile_projects_with_config(app_config: BasicMemoryConfig): logger.info("Continuing with initialization despite synchronization error") -@logfire.instrument() async def initialize_file_sync( app_config: BasicMemoryConfig, ): @@ -152,7 +149,6 @@ async def initialize_file_sync( return None -@logfire.instrument() async def initialize_app( app_config: BasicMemoryConfig, ): diff --git a/src/basic_memory/services/link_resolver.py b/src/basic_memory/services/link_resolver.py index 09d24b05..d22613d2 100644 --- a/src/basic_memory/services/link_resolver.py +++ b/src/basic_memory/services/link_resolver.py @@ -2,7 +2,7 @@ from typing import Optional, Tuple -import logfire + from loguru import logger from basic_memory.models import Entity @@ -27,7 +27,6 @@ class LinkResolver: self.entity_repository = entity_repository self.search_service = search_service - @logfire.instrument() async def resolve_link( self, link_text: str, use_search: bool = True, strict: bool = False ) -> Optional[Entity]: diff --git a/src/basic_memory/services/project_service.py b/src/basic_memory/services/project_service.py index dc0f605c..5f0bd13b 100644 --- a/src/basic_memory/services/project_service.py +++ b/src/basic_memory/services/project_service.py @@ -8,7 +8,7 @@ from datetime import datetime from pathlib import Path from typing import Dict, Optional, Sequence -import logfire + from loguru import logger from sqlalchemy import text @@ -82,7 +82,6 @@ class ProjectService: """ return os.environ.get("BASIC_MEMORY_PROJECT", self.config_manager.default_project) - @logfire.instrument() async def list_projects(self) -> Sequence[Project]: """List all projects without loading entity relationships. @@ -92,7 +91,6 @@ class ProjectService: """ return await self.repository.find_all(use_load_options=False) - @logfire.instrument() async def get_project(self, name: str) -> Optional[Project]: """Get the file path for a project by name or permalink.""" return await self.repository.get_by_name(name) or await self.repository.get_by_permalink( @@ -133,7 +131,6 @@ class ProjectService: # Not nested in either direction return False - @logfire.instrument() async def add_project(self, name: str, path: str, set_default: bool = False) -> None: """Add a new project to the configuration and database. @@ -225,7 +222,6 @@ class ProjectService: logger.info(f"Project '{name}' added at {resolved_path}") - @logfire.instrument() async def remove_project(self, name: str, delete_notes: bool = False) -> None: """Remove a project from configuration and database. @@ -276,7 +272,6 @@ class ProjectService: except Exception as e: logger.warning(f"Failed to delete project directory {project_path}: {e}") - @logfire.instrument() async def set_default_project(self, name: str) -> None: """Set the default project in configuration and database. @@ -301,7 +296,6 @@ class ProjectService: logger.info(f"Project '{name}' set as default in configuration and database") - @logfire.instrument() async def _ensure_single_default_project(self) -> None: """Ensure only one project has is_default=True. @@ -343,7 +337,6 @@ class ProjectService: f"Set '{config_default}' as default project (was missing)" ) # pragma: no cover - @logfire.instrument() async def synchronize_projects(self) -> None: # pragma: no cover """Synchronize projects between database and configuration. @@ -428,7 +421,6 @@ class ProjectService: logger.info("Project synchronization complete") - @logfire.instrument() async def move_project(self, name: str, new_path: str) -> None: """Move a project to a new location. @@ -470,7 +462,6 @@ class ProjectService: self.config_manager.save_config(config) raise ValueError(f"Project '{name}' not found in database") - @logfire.instrument() async def update_project( # pragma: no cover self, name: str, updated_path: Optional[str] = None, is_active: Optional[bool] = None ) -> None: @@ -530,7 +521,6 @@ class ProjectService: f"Changed default project to '{new_default.name}' as '{name}' was deactivated" ) - @logfire.instrument() async def get_project_info(self, project_name: Optional[str] = None) -> ProjectInfoResponse: """Get comprehensive information about the specified Basic Memory project. @@ -598,7 +588,6 @@ class ProjectService: system=system, ) - @logfire.instrument() async def get_statistics(self, project_id: int) -> ProjectStatistics: """Get statistics about the specified project. @@ -715,7 +704,6 @@ class ProjectService: isolated_entities=isolated_count, ) - @logfire.instrument() async def get_activity_metrics(self, project_id: int) -> ActivityMetrics: """Get activity metrics for the specified project. diff --git a/src/basic_memory/services/search_service.py b/src/basic_memory/services/search_service.py index ad975374..37cddfe9 100644 --- a/src/basic_memory/services/search_service.py +++ b/src/basic_memory/services/search_service.py @@ -4,7 +4,7 @@ import ast from datetime import datetime from typing import List, Optional, Set -import logfire + from dateparser import parse from fastapi import BackgroundTasks from loguru import logger @@ -51,12 +51,10 @@ class SearchService: self.entity_repository = entity_repository self.file_service = file_service - @logfire.instrument() async def init_search_index(self): """Create FTS5 virtual table if it doesn't exist.""" await self.repository.init_search_index() - @logfire.instrument() async def reindex_all(self, background_tasks: Optional[BackgroundTasks] = None) -> None: """Reindex all content from database.""" @@ -73,7 +71,6 @@ class SearchService: logger.info("Reindex complete") - @logfire.instrument() async def search(self, query: SearchQuery, limit=10, offset=0) -> List[SearchIndexRow]: """Search across all indexed content. @@ -171,7 +168,6 @@ class SearchService: return [] # pragma: no cover - @logfire.instrument() async def index_entity( self, entity: Entity, @@ -183,7 +179,6 @@ class SearchService: else: await self.index_entity_data(entity, content) - @logfire.instrument() async def index_entity_data( self, entity: Entity, @@ -197,7 +192,6 @@ class SearchService: entity, content ) if entity.is_markdown else await self.index_entity_file(entity) - @logfire.instrument() async def index_entity_file( self, entity: Entity, @@ -220,7 +214,6 @@ class SearchService: ) ) - @logfire.instrument() async def index_entity_markdown( self, entity: Entity, @@ -373,17 +366,14 @@ class SearchService: # Batch insert all rows at once await self.repository.bulk_index_items(rows_to_index) - @logfire.instrument() async def delete_by_permalink(self, permalink: str): """Delete an item from the search index.""" await self.repository.delete_by_permalink(permalink) - @logfire.instrument() async def delete_by_entity_id(self, entity_id: int): """Delete an item from the search index.""" await self.repository.delete_by_entity_id(entity_id) - @logfire.instrument() async def handle_delete(self, entity: Entity): """Handle complete entity deletion from search index including observations and relations. diff --git a/src/basic_memory/sync/sync_service.py b/src/basic_memory/sync/sync_service.py index 93907e1e..3ff23c8f 100644 --- a/src/basic_memory/sync/sync_service.py +++ b/src/basic_memory/sync/sync_service.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import AsyncIterator, Dict, List, Optional, Set, Tuple import aiofiles.os -import logfire + from loguru import logger from sqlalchemy.exc import IntegrityError @@ -215,17 +215,12 @@ class SyncService: f"path={path}, error={error}" ) - # Record metric for file failure - logfire.metric_counter("sync.circuit_breaker.failures").add(1) - # Log when threshold is reached if failure_info.count >= MAX_CONSECUTIVE_FAILURES: logger.error( f"File {path} has failed {MAX_CONSECUTIVE_FAILURES} times and will be skipped. " f"First failure: {failure_info.first_failure}, Last error: {error}" ) - # Record metric for file being blocked by circuit breaker - logfire.metric_counter("sync.circuit_breaker.blocked_files").add(1) else: # Create new failure record self._file_failures[path] = FileFailureInfo( @@ -255,7 +250,6 @@ class SyncService: logger.info(f"Clearing failure history for {path} after successful sync") del self._file_failures[path] - @logfire.instrument() async def sync( self, directory: Path, project_name: Optional[str] = None, force_full: bool = False ) -> SyncReport: @@ -282,63 +276,58 @@ class SyncService: ) # sync moves first - with logfire.span("process_moves", move_count=len(report.moves)): - for old_path, new_path in report.moves.items(): - # in the case where a file has been deleted and replaced by another file - # it will show up in the move and modified lists, so handle it in modified - if new_path in report.modified: - report.modified.remove(new_path) - logger.debug( - f"File marked as moved and modified: old_path={old_path}, new_path={new_path}" - ) - else: - await self.handle_move(old_path, new_path) + for old_path, new_path in report.moves.items(): + # in the case where a file has been deleted and replaced by another file + # it will show up in the move and modified lists, so handle it in modified + if new_path in report.modified: + report.modified.remove(new_path) + logger.debug( + f"File marked as moved and modified: old_path={old_path}, new_path={new_path}" + ) + else: + await self.handle_move(old_path, new_path) # deleted next - with logfire.span("process_deletes", delete_count=len(report.deleted)): - for path in report.deleted: - await self.handle_delete(path) + for path in report.deleted: + await self.handle_delete(path) # then new and modified - with logfire.span("process_new_files", new_count=len(report.new)): - for path in report.new: - entity, _ = await self.sync_file(path, new=True) + for path in report.new: + entity, _ = await self.sync_file(path, new=True) - # Track if file was skipped - if entity is None and await self._should_skip_file(path): - failure_info = self._file_failures[path] - report.skipped_files.append( - SkippedFile( - path=path, - reason=failure_info.last_error, - failure_count=failure_info.count, - first_failed=failure_info.first_failure, - ) + # Track if file was skipped + if entity is None and await self._should_skip_file(path): + failure_info = self._file_failures[path] + report.skipped_files.append( + SkippedFile( + path=path, + reason=failure_info.last_error, + failure_count=failure_info.count, + first_failed=failure_info.first_failure, ) + ) - with logfire.span("process_modified_files", modified_count=len(report.modified)): - for path in report.modified: - entity, _ = await self.sync_file(path, new=False) + for path in report.modified: + entity, _ = await self.sync_file(path, new=False) - # Track if file was skipped - if entity is None and await self._should_skip_file(path): - failure_info = self._file_failures[path] - report.skipped_files.append( - SkippedFile( - path=path, - reason=failure_info.last_error, - failure_count=failure_info.count, - first_failed=failure_info.first_failure, - ) + # Track if file was skipped + if entity is None and await self._should_skip_file(path): + failure_info = self._file_failures[path] + report.skipped_files.append( + SkippedFile( + path=path, + reason=failure_info.last_error, + failure_count=failure_info.count, + first_failed=failure_info.first_failure, ) + ) # Only resolve relations if there were actual changes # If no files changed, no new unresolved relations could have been created - with logfire.span("resolve_relations"): - if report.total > 0: - await self.resolve_relations() - else: - logger.info("Skipping relation resolution - no file changes detected") + if report.total > 0: + await self.resolve_relations() + else: + logger.info("Skipping relation resolution - no file changes detected") # Update scan watermark after successful sync # Use the timestamp from sync start (not end) to ensure we catch files @@ -361,15 +350,6 @@ class SyncService: duration_ms = int((time.time() - start_time) * 1000) - # Record metrics for sync operation - logfire.metric_histogram("sync.duration", unit="ms").record(duration_ms) - logfire.metric_counter("sync.files.new").add(len(report.new)) - logfire.metric_counter("sync.files.modified").add(len(report.modified)) - logfire.metric_counter("sync.files.deleted").add(len(report.deleted)) - logfire.metric_counter("sync.files.moved").add(len(report.moves)) - if report.skipped_files: - logfire.metric_counter("sync.files.skipped").add(len(report.skipped_files)) - # Log summary with skipped files if any if report.skipped_files: logger.warning( @@ -390,7 +370,6 @@ class SyncService: return report - @logfire.instrument() async def scan(self, directory, force_full: bool = False): """Smart scan using watermark and file count for large project optimization. @@ -472,12 +451,6 @@ class SyncService: logger.warning("No scan watermark available, falling back to full scan") file_paths_to_scan = await self._scan_directory_full(directory) - # Record scan type metric - logfire.metric_counter(f"sync.scan.{scan_type}").add(1) - logfire.metric_histogram("sync.scan.files_scanned", unit="files").record( - len(file_paths_to_scan) - ) - # Step 3: Process each file with mtime-based comparison scanned_paths: Set[str] = set() changed_checksums: Dict[str, str] = {} @@ -589,7 +562,6 @@ class SyncService: report.checksums = changed_checksums scan_duration_ms = int((time.time() - scan_start_time) * 1000) - logfire.metric_histogram("sync.scan.duration", unit="ms").record(scan_duration_ms) logger.info( f"Completed {scan_type} scan for directory {directory} in {scan_duration_ms}ms, " @@ -599,7 +571,6 @@ class SyncService: ) return report - @logfire.instrument() async def sync_file( self, path: str, new: bool = True ) -> Tuple[Optional[Entity], Optional[str]]: @@ -654,7 +625,6 @@ class SyncService: return None, None - @logfire.instrument() async def sync_markdown_file(self, path: str, new: bool = True) -> Tuple[Optional[Entity], str]: """Sync a markdown file with full processing. @@ -737,7 +707,6 @@ class SyncService: # Return the final checksum to ensure everything is consistent return entity, final_checksum - @logfire.instrument() async def sync_regular_file(self, path: str, new: bool = True) -> Tuple[Optional[Entity], str]: """Sync a non-markdown file with basic tracking. @@ -838,7 +807,6 @@ class SyncService: return updated, checksum - @logfire.instrument() async def handle_delete(self, file_path: str): """Handle complete entity deletion including search index cleanup.""" @@ -870,7 +838,6 @@ class SyncService: else: await self.search_service.delete_by_entity_id(entity.id) - @logfire.instrument() async def handle_move(self, old_path, new_path): logger.debug("Moving entity", old_path=old_path, new_path=new_path) @@ -975,7 +942,6 @@ class SyncService: # update search index await self.search_service.index_entity(updated) - @logfire.instrument() async def resolve_relations(self, entity_id: int | None = None): """Try to resolve unresolved relations. @@ -1074,8 +1040,6 @@ class SyncService: f"error: {error_msg}. Falling back to manual count. " f"This will slow down watermark detection!" ) - # Track optimization failures for visibility - logfire.metric_counter("sync.scan.file_count_failure").add(1) # Fallback: count using scan_directory count = 0 async for _ in self.scan_directory(directory): @@ -1116,8 +1080,6 @@ class SyncService: f"error: {error_msg}. Falling back to full scan. " f"This will cause slow syncs on large projects!" ) - # Track optimization failures for visibility - logfire.metric_counter("sync.scan.optimization_failure").add(1) # Fallback to full scan return await self._scan_directory_full(directory) diff --git a/src/basic_memory/sync/watch_service.py b/src/basic_memory/sync/watch_service.py index 03964d8e..5cdd2e1c 100644 --- a/src/basic_memory/sync/watch_service.py +++ b/src/basic_memory/sync/watch_service.py @@ -5,7 +5,10 @@ import os from collections import defaultdict from datetime import datetime from pathlib import Path -from typing import List, Optional, Set, Sequence +from typing import List, Optional, Set, Sequence, Callable, Awaitable, TYPE_CHECKING + +if TYPE_CHECKING: + from basic_memory.sync.sync_service import SyncService from basic_memory.config import BasicMemoryConfig, WATCH_STATUS_JSON from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path @@ -71,12 +74,17 @@ class WatchServiceState(BaseModel): self.last_error = datetime.now() +# Type alias for sync service factory function +SyncServiceFactory = Callable[[Project], Awaitable["SyncService"]] + + class WatchService: def __init__( self, app_config: BasicMemoryConfig, project_repository: ProjectRepository, quiet: bool = False, + sync_service_factory: Optional[SyncServiceFactory] = None, ): self.app_config = app_config self.project_repository = project_repository @@ -84,10 +92,20 @@ class WatchService: self.status_path = Path.home() / ".basic-memory" / WATCH_STATUS_JSON self.status_path.parent.mkdir(parents=True, exist_ok=True) self._ignore_patterns_cache: dict[Path, Set[str]] = {} + self._sync_service_factory = sync_service_factory # quiet mode for mcp so it doesn't mess up stdout self.console = Console(quiet=quiet) + async def _get_sync_service(self, project: Project) -> "SyncService": + """Get sync service for a project, using factory if provided.""" + if self._sync_service_factory: + return await self._sync_service_factory(project) + # Fall back to default factory + from basic_memory.sync.sync_service import get_sync_service + + return await get_sync_service(project) + async def _schedule_restart(self, stop_event: asyncio.Event): """Schedule a restart of the watch service after the configured interval.""" await asyncio.sleep(self.app_config.watch_project_reload_interval) @@ -233,9 +251,6 @@ class WatchService: async def handle_changes(self, project: Project, changes: Set[FileChange]) -> None: """Process a batch of file changes""" - # avoid circular imports - from basic_memory.sync.sync_service import get_sync_service - # Check if project still exists in configuration before processing # This prevents deleted projects from being recreated by background sync from basic_memory.config import ConfigManager @@ -250,7 +265,7 @@ class WatchService: ) return - sync_service = await get_sync_service(project) + sync_service = await self._get_sync_service(project) file_service = sync_service.file_service start_time = time.time() diff --git a/src/basic_memory/utils.py b/src/basic_memory/utils.py index 0e2b7ade..0ed8adf4 100644 --- a/src/basic_memory/utils.py +++ b/src/basic_memory/utils.py @@ -67,9 +67,6 @@ class PathLike(Protocol): # This preserves compatibility with existing code while we migrate FilePath = Union[Path, str] -# Disable the "Queue is full" warning -logging.getLogger("opentelemetry.sdk.metrics._internal.instrument").setLevel(logging.ERROR) - def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: bool = True) -> str: """Generate a stable permalink from a file path. diff --git a/test-int/conftest.py b/test-int/conftest.py index 0b874885..a6fa3899 100644 --- a/test-int/conftest.py +++ b/test-int/conftest.py @@ -50,18 +50,23 @@ The `app` fixture ensures FastAPI dependency overrides are active, and `mcp_server` provides the MCP server with proper project session initialization. """ +import os from typing import AsyncGenerator, Literal import pytest import pytest_asyncio from pathlib import Path from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool +from testcontainers.postgres import PostgresContainer from httpx import AsyncClient, ASGITransport from basic_memory.config import BasicMemoryConfig, ProjectConfig, ConfigManager, DatabaseBackend from basic_memory.db import engine_session_factory, DatabaseType from basic_memory.models import Project +from basic_memory.models.base import Base from basic_memory.repository.project_repository import ProjectRepository from fastapi import FastAPI @@ -72,25 +77,38 @@ from basic_memory.deps import get_project_config, get_engine_factory, get_app_co from basic_memory.mcp import tools # noqa: F401 -@pytest.fixture( - params=[ - pytest.param("sqlite", id="sqlite"), - pytest.param("postgres", id="postgres", marks=pytest.mark.postgres), - ] -) -def db_backend(request) -> Literal["sqlite", "postgres"]: - """Parametrize tests to run against both SQLite and Postgres. +# ============================================================================= +# Database Backend Selection (env var approach) +# ============================================================================= +# By default, integration tests run against SQLite. +# Set BASIC_MEMORY_TEST_POSTGRES=1 to run against Postgres (uses testcontainers). - Usage: - pytest # Runs tests against SQLite only (default) - pytest -m postgres # Runs tests against Postgres only - pytest -m "not postgres" # Runs tests against SQLite only - pytest --run-all-backends # Runs tests against both backends - Note: Only tests that use database fixtures (engine_factory, session_maker, etc.) - will be parametrized. Tests that don't use the database won't be affected. +@pytest.fixture(scope="session") +def db_backend() -> Literal["sqlite", "postgres"]: + """Determine database backend from environment variable. + + Default: sqlite + Set BASIC_MEMORY_TEST_POSTGRES=1 to use postgres """ - return request.param + if os.environ.get("BASIC_MEMORY_TEST_POSTGRES", "").lower() in ("1", "true", "yes"): + return "postgres" + return "sqlite" + + +@pytest.fixture(scope="session") +def postgres_container(db_backend): + """Session-scoped Postgres container for integration tests. + + Uses testcontainers to spin up a real Postgres instance. + Only starts if db_backend is "postgres". + """ + if db_backend != "postgres": + yield None + return + + with PostgresContainer("postgres:16-alpine") as postgres: + yield postgres @pytest_asyncio.fixture @@ -98,50 +116,57 @@ async def engine_factory( app_config, config_manager, db_backend: Literal["sqlite", "postgres"], + postgres_container, tmp_path, ) -> AsyncGenerator[tuple, None]: """Create engine and session factory for the configured database backend.""" - from basic_memory.models.search import CREATE_SEARCH_INDEX + from basic_memory.models.search import ( + CREATE_SEARCH_INDEX, + CREATE_POSTGRES_SEARCH_INDEX_TABLE, + CREATE_POSTGRES_SEARCH_INDEX_FTS, + CREATE_POSTGRES_SEARCH_INDEX_METADATA, + ) from basic_memory import db - # Determine database type based on backend if db_backend == "postgres": - db_type = DatabaseType.FILESYSTEM - else: - db_type = DatabaseType.FILESYSTEM # Integration tests use file-based SQLite + # Postgres mode using testcontainers + sync_url = postgres_container.get_connection_url() + async_url = sync_url.replace("postgresql+psycopg2", "postgresql+asyncpg") - # Use tmp_path for SQLite, use config database_path for Postgres - if db_backend == "sqlite": - db_path = tmp_path / "test.db" - else: - db_path = app_config.database_path + engine = create_async_engine( + async_url, + echo=False, + poolclass=NullPool, + ) - if db_backend == "postgres": - # Postgres: Create fresh engine for each test with full schema reset - config_manager._config = app_config + session_maker = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, + ) - # Use context manager to handle engine disposal properly - async with engine_session_factory(db_path, db_type) as (engine, session_maker): - # Drop and recreate schema for complete isolation - async with engine.begin() as conn: - await conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE")) - await conn.execute(text("CREATE SCHEMA public")) - await conn.execute(text("GRANT ALL ON SCHEMA public TO basic_memory_user")) - await conn.execute(text("GRANT ALL ON SCHEMA public TO public")) + # Drop and recreate all tables for test isolation + async with engine.begin() as conn: + await conn.execute(text("DROP TABLE IF EXISTS search_index CASCADE")) + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + # asyncpg requires separate execute calls for each statement + await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_TABLE) + await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_FTS) + await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_METADATA) - # Run migrations to create production tables - from basic_memory.db import run_migrations + yield engine, session_maker - await run_migrations(app_config, db_type) - - yield engine, session_maker + await engine.dispose() else: # SQLite: Create fresh database (fast with tmp files) + db_path = tmp_path / "test.db" + db_type = DatabaseType.FILESYSTEM + async with engine_session_factory(db_path, db_type) as (engine, session_maker): # Create all tables via ORM - from basic_memory.models.base import Base - async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) @@ -181,7 +206,11 @@ def config_home(tmp_path, monkeypatch) -> Path: @pytest.fixture def app_config( - config_home, db_backend: Literal["sqlite", "postgres"], tmp_path, monkeypatch + config_home, + db_backend: Literal["sqlite", "postgres"], + postgres_container, + tmp_path, + monkeypatch, ) -> BasicMemoryConfig: """Create test app configuration.""" # Disable cloud mode for CLI tests @@ -190,12 +219,12 @@ def app_config( # Create a basic config with test-project like unit tests do projects = {"test-project": str(config_home)} - # Configure database backend based on test parameter + # Configure database backend based on env var if db_backend == "postgres": database_backend = DatabaseBackend.POSTGRES - database_url = ( - "postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test" - ) + # Get URL from testcontainer and convert to asyncpg driver + sync_url = postgres_container.get_connection_url() + database_url = sync_url.replace("postgresql+psycopg2", "postgresql+asyncpg") else: database_backend = DatabaseBackend.SQLITE database_url = None diff --git a/test-int/test_sync_performance_benchmark.py b/test-int/test_sync_performance_benchmark.py deleted file mode 100644 index ffeb8506..00000000 --- a/test-int/test_sync_performance_benchmark.py +++ /dev/null @@ -1,372 +0,0 @@ -""" -Performance benchmark tests for sync operations. - -These tests measure baseline performance for indexing operations to track -improvements from optimizations. Tests are marked with @pytest.mark.benchmark -and can be run separately. - -Usage: - # Run all benchmarks - pytest test-int/test_sync_performance_benchmark.py -v - - # Run specific benchmark - pytest test-int/test_sync_performance_benchmark.py::test_benchmark_sync_100_files -v -""" - -import asyncio -import time -from pathlib import Path -from textwrap import dedent - -import pytest - -from basic_memory.config import BasicMemoryConfig, ProjectConfig -from basic_memory.sync.sync_service import get_sync_service - - -async def create_benchmark_file(path: Path, file_num: int, total_files: int) -> None: - """Create a realistic test markdown file with observations and relations. - - Args: - path: Path to create the file at - file_num: Current file number (for unique content) - total_files: Total number of files being created (for relation targets) - """ - # Create realistic content with varying complexity - has_relations = file_num < (total_files - 1) # Most files have relations - num_observations = min(3 + (file_num % 5), 10) # 3-10 observations per file - - # Generate relation targets (some will be forward references) - relations = [] - if has_relations: - # Reference 1-3 other files - num_relations = min(1 + (file_num % 3), 3) - for i in range(num_relations): - target_num = (file_num + i + 1) % total_files - relations.append(f"- relates_to [[test-file-{target_num:04d}]]") - - content = dedent(f""" - --- - type: note - tags: [benchmark, test, category-{file_num % 10}] - --- - # Test File {file_num:04d} - - This is benchmark test file {file_num} of {total_files}. - It contains realistic markdown content to simulate actual usage. - - ## Observations - {chr(10).join([f"- [category-{i % 5}] Observation {i} for file {file_num} with some content #tag{i}" for i in range(num_observations)])} - - ## Relations - {chr(10).join(relations) if relations else "- No relations for this file"} - - ## Additional Content - - This section contains additional prose to simulate real documents. - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod - tempor incididunt ut labore et dolore magna aliqua. - - ### Subsection - - More content here to make the file realistic. This helps test the - full indexing pipeline including content extraction and search indexing. - """).strip() - - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - - -async def generate_benchmark_files(project_dir: Path, num_files: int) -> None: - """Generate benchmark test files. - - Args: - project_dir: Directory to create files in - num_files: Number of files to generate - """ - print(f"\nGenerating {num_files} test files...") - start = time.time() - - # Create files in batches for faster generation - batch_size = 100 - for batch_start in range(0, num_files, batch_size): - batch_end = min(batch_start + batch_size, num_files) - tasks = [ - create_benchmark_file( - project_dir / f"category-{i % 10}" / f"test-file-{i:04d}.md", i, num_files - ) - for i in range(batch_start, batch_end) - ] - await asyncio.gather(*tasks) - print(f" Created files {batch_start}-{batch_end} ({batch_end}/{num_files})") - - duration = time.time() - start - print(f" File generation completed in {duration:.2f}s ({num_files / duration:.1f} files/sec)") - - -def get_db_size(db_path: Path) -> tuple[int, str]: - """Get database file size. - - Returns: - Tuple of (size_bytes, formatted_size) - """ - if not db_path.exists(): - return 0, "0 B" - - size_bytes = db_path.stat().st_size - - # Format size - for unit in ["B", "KB", "MB", "GB"]: - if size_bytes < 1024.0: - return size_bytes, f"{size_bytes:.2f} {unit}" - size_bytes /= 1024.0 - - return int(size_bytes * 1024**4), f"{size_bytes:.2f} TB" - - -async def run_sync_benchmark( - project_config: ProjectConfig, app_config: BasicMemoryConfig, num_files: int, test_name: str -) -> dict: - """Run a sync benchmark and collect metrics. - - Args: - project_config: Project configuration - app_config: App configuration - num_files: Number of files to benchmark - test_name: Name of the test for reporting - - Returns: - Dictionary with benchmark results - """ - project_dir = project_config.home - db_path = app_config.database_path - - print(f"\n{'=' * 70}") - print(f"BENCHMARK: {test_name}") - print(f"{'=' * 70}") - - # Generate test files - await generate_benchmark_files(project_dir, num_files) - - # Get initial DB size - initial_db_size, initial_db_formatted = get_db_size(db_path) - print(f"\nInitial database size: {initial_db_formatted}") - - # Create sync service - from basic_memory.repository import ProjectRepository - from basic_memory import db - - _, session_maker = await db.get_or_create_db( - db_path=app_config.database_path, - db_type=db.DatabaseType.FILESYSTEM, - ) - project_repository = ProjectRepository(session_maker) - - # Get or create project - projects = await project_repository.find_all() - if projects: - project = projects[0] - else: - project = await project_repository.create( - { - "name": project_config.name, - "path": str(project_config.home), - "is_active": True, - "is_default": True, - } - ) - - sync_service = await get_sync_service(project) - - # Initialize search index (required for FTS5 table) - await sync_service.search_service.init_search_index() - - # Run sync and measure time - print(f"\nStarting sync of {num_files} files...") - sync_start = time.time() - - report = await sync_service.sync(project_dir, project_name=project.name) - - sync_duration = time.time() - sync_start - - # Get final DB size - final_db_size, final_db_formatted = get_db_size(db_path) - db_growth = final_db_size - initial_db_size - db_growth_formatted = f"{db_growth / 1024 / 1024:.2f} MB" - - # Calculate metrics - files_per_sec = num_files / sync_duration if sync_duration > 0 else 0 - ms_per_file = (sync_duration * 1000) / num_files if num_files > 0 else 0 - - # Print results - print(f"\n{'-' * 70}") - print("RESULTS:") - print(f"{'-' * 70}") - print(f"Files processed: {num_files}") - print(f" New: {len(report.new)}") - print(f" Modified: {len(report.modified)}") - print(f" Deleted: {len(report.deleted)}") - print(f" Moved: {len(report.moves)}") - print("\nPerformance:") - print(f" Total time: {sync_duration:.2f}s") - print(f" Files/sec: {files_per_sec:.1f}") - print(f" ms/file: {ms_per_file:.1f}") - print("\nDatabase:") - print(f" Initial size: {initial_db_formatted}") - print(f" Final size: {final_db_formatted}") - print(f" Growth: {db_growth_formatted}") - print(f" Growth per file: {(db_growth / num_files / 1024):.2f} KB") - print(f"{'=' * 70}\n") - - return { - "test_name": test_name, - "num_files": num_files, - "sync_duration_sec": sync_duration, - "files_per_sec": files_per_sec, - "ms_per_file": ms_per_file, - "new_files": len(report.new), - "modified_files": len(report.modified), - "deleted_files": len(report.deleted), - "moved_files": len(report.moves), - "initial_db_size": initial_db_size, - "final_db_size": final_db_size, - "db_growth_bytes": db_growth, - "db_growth_per_file_bytes": db_growth / num_files if num_files > 0 else 0, - } - - -@pytest.mark.benchmark -@pytest.mark.asyncio -async def test_benchmark_sync_100_files(app_config, project_config, config_manager): - """Benchmark: Sync 100 files (small repository).""" - results = await run_sync_benchmark( - project_config, app_config, num_files=100, test_name="Sync 100 files (small repository)" - ) - - # Basic assertions to ensure sync worked - # Note: May be slightly more than 100 due to OS-generated files (.DS_Store, etc.) - assert results["new_files"] >= 100 - assert results["sync_duration_sec"] > 0 - assert results["files_per_sec"] > 0 - - -@pytest.mark.benchmark -@pytest.mark.asyncio -@pytest.mark.skip -async def test_benchmark_sync_500_files(app_config, project_config, config_manager): - """Benchmark: Sync 500 files (medium repository).""" - results = await run_sync_benchmark( - project_config, app_config, num_files=500, test_name="Sync 500 files (medium repository)" - ) - - # Basic assertions - # Note: May be slightly more than 500 due to OS-generated files - assert results["new_files"] >= 500 - assert results["sync_duration_sec"] > 0 - assert results["files_per_sec"] > 0 - - -@pytest.mark.benchmark -@pytest.mark.asyncio -@pytest.mark.slow -@pytest.mark.skip -async def test_benchmark_sync_1000_files(app_config, project_config, config_manager): - """Benchmark: Sync 1000 files (large repository). - - This test is marked as 'slow' and can be skipped in regular test runs: - pytest -m "not slow" - """ - results = await run_sync_benchmark( - project_config, app_config, num_files=1000, test_name="Sync 1000 files (large repository)" - ) - - # Basic assertions - # Note: May be slightly more than 1000 due to OS-generated files - assert results["new_files"] >= 1000 - assert results["sync_duration_sec"] > 0 - assert results["files_per_sec"] > 0 - - -@pytest.mark.benchmark -@pytest.mark.asyncio -@pytest.mark.skip -async def test_benchmark_resync_no_changes(app_config, project_config, config_manager): - """Benchmark: Re-sync with no changes (should be fast). - - This tests the performance of scanning files when nothing has changed, - which is important for cloud restarts. - """ - project_dir = project_config.home - num_files = 100 - - # First sync - print(f"\nFirst sync of {num_files} files...") - await generate_benchmark_files(project_dir, num_files) - - from basic_memory.repository import ProjectRepository - from basic_memory import db - - _, session_maker = await db.get_or_create_db( - db_path=app_config.database_path, - db_type=db.DatabaseType.FILESYSTEM, - ) - project_repository = ProjectRepository(session_maker) - projects = await project_repository.find_all() - if projects: - project = projects[0] - else: - project = await project_repository.create( - { - "name": project_config.name, - "path": str(project_config.home), - "is_active": True, - "is_default": True, - } - ) - - sync_service = await get_sync_service(project) - - # Initialize search index - await sync_service.search_service.init_search_index() - - await sync_service.sync(project_dir, project_name=project.name) - - # Second sync (no changes) - print("\nRe-sync with no changes...") - resync_start = time.time() - report = await sync_service.sync(project_dir, project_name=project.name) - resync_duration = time.time() - resync_start - - print(f"\n{'-' * 70}") - print("RE-SYNC RESULTS (no changes):") - print(f"{'-' * 70}") - print(f"Files scanned: {num_files}") - print(f"Changes detected: {report.total}") - print(f" New: {len(report.new)}") - print(f" Modified: {len(report.modified)}") - print(f" Deleted: {len(report.deleted)}") - print(f" Moved: {len(report.moves)}") - print(f"Duration: {resync_duration:.2f}s") - print(f"Files/sec: {num_files / resync_duration:.1f}") - - # Debug: Show what changed - if report.total > 0: - print("\n⚠️ UNEXPECTED CHANGES DETECTED:") - if report.new: - print(f" New files ({len(report.new)}): {list(report.new)[:5]}") - if report.modified: - print(f" Modified files ({len(report.modified)}): {list(report.modified)[:5]}") - if report.deleted: - print(f" Deleted files ({len(report.deleted)}): {list(report.deleted)[:5]}") - if report.moves: - print(f" Moved files ({len(report.moves)}): {dict(list(report.moves.items())[:5])}") - - print(f"{'=' * 70}\n") - - # Should be no changes - assert report.total == 0, ( - f"Expected no changes but got {report.total}: new={len(report.new)}, modified={len(report.modified)}, deleted={len(report.deleted)}, moves={len(report.moves)}" - ) - assert len(report.new) == 0 - assert len(report.modified) == 0 - assert len(report.deleted) == 0 diff --git a/tests/api/test_resource_router.py b/tests/api/test_resource_router.py index 4edf6d8c..d9bb384e 100644 --- a/tests/api/test_resource_router.py +++ b/tests/api/test_resource_router.py @@ -218,9 +218,13 @@ async def test_get_resource_entities(client, project_config, entity_repository, @pytest.mark.asyncio async def test_get_resource_entities_pagination( - client, project_config, entity_repository, project_url + client, project_config, entity_repository, project_url, db_backend ): """Test getting content by permalink match.""" + if db_backend == "postgres": + pytest.skip( + "Pagination differs: relations expand to multiple entities, ordering is undefined" + ) # Create entity content1 = "# Test Content\n" data = { diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 7aa102c8..4b94e074 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -1,5 +1,8 @@ +import os +from pathlib import Path from typing import AsyncGenerator +import pytest import pytest_asyncio from fastapi import FastAPI from httpx import AsyncClient, ASGITransport @@ -8,7 +11,31 @@ from basic_memory.api.app import app as fastapi_app from basic_memory.deps import get_project_config, get_engine_factory, get_app_config -@pytest_asyncio.fixture(autouse=True) +@pytest.fixture(autouse=True) +def isolated_home(tmp_path, monkeypatch) -> Path: + """Isolate tests from user's HOME directory. + + This prevents tests from reading/writing to ~/.basic-memory/.bmignore + or other user-specific configuration. + + Sets BASIC_MEMORY_HOME to tmp_path directly so the default project + writes files to tmp_path, which is where tests expect to find them. + """ + # Clear config cache to ensure fresh config for each test + from basic_memory import config as config_module + + config_module._CONFIG_CACHE = None + + monkeypatch.setenv("HOME", str(tmp_path)) + if os.name == "nt": + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + # Set to tmp_path directly (not tmp_path/basic-memory) so default project + # home is tmp_path - tests expect to find imported files there + monkeypatch.setenv("BASIC_MEMORY_HOME", str(tmp_path)) + return tmp_path + + +@pytest_asyncio.fixture async def app(app_config, project_config, engine_factory, test_config, aiolib) -> FastAPI: """Create test FastAPI application.""" app = fastapi_app diff --git a/tests/conftest.py b/tests/conftest.py index dc26ee49..fb7bb208 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,16 +1,23 @@ """Common test fixtures.""" +import os from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from textwrap import dedent -from typing import AsyncGenerator, Literal +from typing import AsyncGenerator -import os import pytest import pytest_asyncio from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.pool import NullPool +from testcontainers.postgres import PostgresContainer from basic_memory import db from basic_memory.config import ProjectConfig, BasicMemoryConfig, ConfigManager, DatabaseBackend @@ -37,32 +44,47 @@ from basic_memory.sync.sync_service import SyncService from basic_memory.sync.watch_service import WatchService +# ============================================================================= +# Database Backend Selection (env var approach) +# ============================================================================= +# By default, tests run against SQLite. +# Set BASIC_MEMORY_TEST_POSTGRES=1 to run against Postgres (uses testcontainers). +# This allows running sqlite/postgres tests in parallel in CI. + + +@pytest.fixture(scope="session") +def db_backend(): + """Determine database backend from environment variable. + + Default: sqlite + Set BASIC_MEMORY_TEST_POSTGRES=1 to use postgres + """ + if os.environ.get("BASIC_MEMORY_TEST_POSTGRES", "").lower() in ("1", "true", "yes"): + return "postgres" + return "sqlite" + + +@pytest.fixture(scope="session") +def postgres_container(db_backend): + """Session-scoped Postgres container for tests. + + Uses testcontainers to spin up a real Postgres instance in Docker. + The container is started once per test session and shared across all tests. + Only starts if db_backend is "postgres". + """ + if db_backend != "postgres": + yield None + return + + with PostgresContainer("postgres:16-alpine") as postgres: + yield postgres + + @pytest.fixture def anyio_backend(): return "asyncio" -@pytest.fixture( - params=[ - pytest.param("sqlite", id="sqlite"), - pytest.param("postgres", id="postgres", marks=pytest.mark.postgres), - ] -) -def db_backend(request) -> Literal["sqlite", "postgres"]: - """Parametrize tests to run against both SQLite and Postgres. - - Usage: - pytest # Runs tests against SQLite only (default) - pytest -m postgres # Runs tests against Postgres only - pytest -m "not postgres" # Runs tests against SQLite only - pytest --run-all-backends # Runs tests against both backends - - Note: Only tests that use database fixtures (engine_factory, session_maker, etc.) - will be parametrized. Tests that don't use the database won't be affected. - """ - return request.param - - @pytest.fixture def project_root() -> Path: return Path(__file__).parent.parent @@ -81,24 +103,18 @@ def config_home(tmp_path, monkeypatch) -> Path: @pytest.fixture(scope="function") -def app_config( - config_home, db_backend: Literal["sqlite", "postgres"], monkeypatch -) -> BasicMemoryConfig: - """Create test app configuration.""" - # Create a basic config without depending on test_project to avoid circular dependency +def app_config(config_home, db_backend, postgres_container, monkeypatch) -> BasicMemoryConfig: + """Create test app configuration for the appropriate backend.""" projects = {"test-project": str(config_home)} - # Configure database backend based on test parameter + # Set backend based on parameterized db_backend fixture if db_backend == "postgres": - database_backend = DatabaseBackend.POSTGRES - # Use env var if set, otherwise use default matching docker-compose-postgres.yml - # These are local test credentials only - NOT for production - database_url = os.getenv( - "POSTGRES_TEST_URL", - "postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test", - ) + backend = DatabaseBackend.POSTGRES + # Get URL from testcontainer and convert to asyncpg driver + sync_url = postgres_container.get_connection_url() + database_url = sync_url.replace("postgresql+psycopg2", "postgresql+asyncpg") else: - database_backend = DatabaseBackend.SQLITE + backend = DatabaseBackend.SQLITE database_url = None app_config = BasicMemoryConfig( @@ -106,7 +122,7 @@ def app_config( projects=projects, default_project="test-project", update_permalinks_on_move=True, - database_backend=database_backend, + database_backend=backend, database_url=database_url, ) @@ -162,76 +178,66 @@ def test_config(config_home, project_config, app_config, config_manager) -> Test async def engine_factory( app_config, config_manager, - db_backend: Literal["sqlite", "postgres"], + db_backend, + postgres_container, ) -> AsyncGenerator[tuple[AsyncEngine, async_sessionmaker[AsyncSession]], None]: - """Create engine and session factory for the configured database backend.""" + """Engine factory for SQLite or Postgres tests. + + Uses parameterized db_backend fixture to run tests against both backends. + """ from basic_memory.models.search import CREATE_SEARCH_INDEX if db_backend == "postgres": - # Postgres: Create fresh engine for each test with full schema reset - config_manager._config = app_config - db_type = DatabaseType.FILESYSTEM + # Postgres mode using testcontainers + # Get async connection URL (asyncpg driver - same as production) + sync_url = postgres_container.get_connection_url() + async_url = sync_url.replace("postgresql+psycopg2", "postgresql+asyncpg") - # Use context manager to handle engine disposal properly - async with db.engine_session_factory(db_path=app_config.database_path, db_type=db_type) as ( - engine, - session_maker, - ): - # Drop and recreate schema for complete isolation - async with engine.begin() as conn: - await conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE")) - await conn.execute(text("CREATE SCHEMA public")) - await conn.execute(text("GRANT ALL ON SCHEMA public TO basic_memory_user")) - await conn.execute(text("GRANT ALL ON SCHEMA public TO public")) + engine = create_async_engine( + async_url, + echo=False, + poolclass=NullPool, # NullPool for better test isolation + ) - # Run migrations to create production tables (including search_index with correct schema) - # Alembic handles duplicate migration checks, so it's safe to call this for each test - from basic_memory.db import run_migrations + session_maker = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, + ) - await run_migrations(app_config, db_type) + from basic_memory.models.search import ( + CREATE_POSTGRES_SEARCH_INDEX_TABLE, + CREATE_POSTGRES_SEARCH_INDEX_FTS, + CREATE_POSTGRES_SEARCH_INDEX_METADATA, + ) - # For Postgres, migrations create all production tables with correct schemas - # We only need to create test-specific tables (like ModelTest) that aren't in migrations - # Don't create search_index via ORM - it's already created by migration with composite PK - async with engine.begin() as conn: - # List of tables created by migrations - don't recreate them via ORM - production_tables = { - "entity", - "observation", - "relation", - "project", - "search_index", - "alembic_version", - } + # Drop and recreate all tables for test isolation + async with engine.begin() as conn: + # Must drop search_index first (has FK to project, blocks drop_all) + await conn.execute(text("DROP TABLE IF EXISTS search_index CASCADE")) + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + # Create search_index via DDL (not ORM - uses composite PK + tsvector) + # asyncpg requires separate execute calls for each statement + await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_TABLE) + await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_FTS) + await conn.execute(CREATE_POSTGRES_SEARCH_INDEX_METADATA) - # Get test-specific tables that aren't created by migrations - test_tables = [ - table - for table in Base.metadata.sorted_tables - if table.name not in production_tables - ] - if test_tables: - await conn.run_sync( - lambda sync_conn: Base.metadata.create_all(sync_conn, tables=test_tables) - ) + yield engine, session_maker - yield engine, session_maker + await engine.dispose() else: - # SQLite: Create fresh in-memory database for each test + # SQLite mode db_type = DatabaseType.MEMORY async with db.engine_session_factory(db_path=app_config.database_path, db_type=db_type) as ( engine, session_maker, ): - # Create all tables via ORM + # Create all tables via ORM, then add search_index via FTS5 DDL async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) - - # Drop any SearchIndex ORM table, then create FTS5 virtual table - async with db.scoped_session(session_maker) as session: - await session.execute(text("DROP TABLE IF EXISTS search_index")) - await session.execute(CREATE_SEARCH_INDEX) - await session.commit() + await conn.execute(CREATE_SEARCH_INDEX) # Yield after setup is complete yield engine, session_maker @@ -543,8 +549,22 @@ async def test_graph( @pytest.fixture -def watch_service(app_config: BasicMemoryConfig, project_repository) -> WatchService: - return WatchService(app_config=app_config, project_repository=project_repository) +def watch_service(app_config: BasicMemoryConfig, project_repository, sync_service) -> WatchService: + """Create WatchService with injected sync_service factory. + + The sync_service_factory allows tests to use the fixture-provided sync_service + instead of the production get_sync_service() which creates its own db connection. + """ + + async def sync_service_factory(project): + """Return the test fixture's sync_service regardless of project.""" + return sync_service + + return WatchService( + app_config=app_config, + project_repository=project_repository, + sync_service_factory=sync_service_factory, + ) @pytest.fixture diff --git a/tests/mcp/conftest.py b/tests/mcp/conftest.py index e18d8338..5d2f153e 100644 --- a/tests/mcp/conftest.py +++ b/tests/mcp/conftest.py @@ -50,6 +50,7 @@ def test_entity_data(): } -@pytest_asyncio.fixture(autouse=True) +@pytest_asyncio.fixture async def init_search_index(search_service: SearchService): + """Initialize search index. Request this fixture explicitly in tests that need it.""" await search_service.init_search_index() diff --git a/tests/mcp/test_tool_move_note.py b/tests/mcp/test_tool_move_note.py index 26f051b7..86c166a8 100644 --- a/tests/mcp/test_tool_move_note.py +++ b/tests/mcp/test_tool_move_note.py @@ -1,7 +1,9 @@ """Tests for the move_note MCP tool.""" import pytest -from unittest.mock import patch +from pathlib import Path +from unittest.mock import patch, MagicMock +from contextlib import asynccontextmanager from basic_memory.mcp.tools.move_note import move_note, _format_move_error_response from basic_memory.mcp.tools.write_note import write_note @@ -887,36 +889,71 @@ class TestMoveNoteSecurityValidation: class TestMoveNoteErrorHandling: - """Test move note exception handling.""" + """Test move note exception handling. + + These are pure unit tests that mock get_client and other dependencies. + They don't need the database or ASGI app. + """ + + @pytest.fixture + def mock_client(self): + """Create a mock async client context manager.""" + mock = MagicMock() + + @asynccontextmanager + async def mock_get_client(): + yield mock + + return mock_get_client, mock @pytest.mark.asyncio - async def test_move_note_exception_handling(self): + async def test_move_note_exception_handling(self, mock_client): """Test exception handling in move_note.""" - with patch("basic_memory.mcp.tools.move_note.get_active_project") as mock_get_project: - mock_get_project.return_value.project_url = "http://test" - mock_get_project.return_value.name = "test-project" + mock_get_client, _ = mock_client - with patch( - "basic_memory.mcp.tools.move_note.call_post", - side_effect=Exception("entity not found"), - ): - result = await move_note.fn("test-note", "target/file.md", project="test-project") + with patch("basic_memory.mcp.tools.move_note.get_client", mock_get_client): + with patch("basic_memory.mcp.tools.move_note.get_active_project") as mock_get_project: + mock_get_project.return_value.project_url = "http://test" + mock_get_project.return_value.name = "test-project" + mock_get_project.return_value.home = Path("/tmp/test") - assert isinstance(result, str) - assert "# Move Failed - Note Not Found" in result + with patch( + "basic_memory.mcp.tools.move_note.call_post", + side_effect=Exception("entity not found"), + ): + with patch( + "basic_memory.mcp.tools.move_note.call_get", + side_effect=Exception("not found"), + ): + result = await move_note.fn( + "test-note", "target/file.md", project="test-project" + ) + + assert isinstance(result, str) + assert "# Move Failed - Note Not Found" in result @pytest.mark.asyncio - async def test_move_note_permission_error_handling(self): + async def test_move_note_permission_error_handling(self, mock_client): """Test permission error handling in move_note.""" - with patch("basic_memory.mcp.tools.move_note.get_active_project") as mock_get_project: - mock_get_project.return_value.project_url = "http://test" - mock_get_project.return_value.name = "test-project" + mock_get_client, _ = mock_client - with patch( - "basic_memory.mcp.tools.move_note.call_post", - side_effect=Exception("permission denied"), - ): - result = await move_note.fn("test-note", "target/file.md", project="test-project") + with patch("basic_memory.mcp.tools.move_note.get_client", mock_get_client): + with patch("basic_memory.mcp.tools.move_note.get_active_project") as mock_get_project: + mock_get_project.return_value.project_url = "http://test" + mock_get_project.return_value.name = "test-project" + mock_get_project.return_value.home = Path("/tmp/test") - assert isinstance(result, str) - assert "# Move Failed - Permission Error" in result + with patch( + "basic_memory.mcp.tools.move_note.call_post", + side_effect=Exception("permission denied"), + ): + with patch( + "basic_memory.mcp.tools.move_note.call_get", + side_effect=Exception("not found"), + ): + result = await move_note.fn( + "test-note", "target/file.md", project="test-project" + ) + + assert isinstance(result, str) + assert "# Move Failed - Permission Error" in result diff --git a/tests/repository/test_entity_repository.py b/tests/repository/test_entity_repository.py index aa9e990f..f0c7c68f 100644 --- a/tests/repository/test_entity_repository.py +++ b/tests/repository/test_entity_repository.py @@ -875,7 +875,7 @@ async def test_get_all_file_paths_project_isolation( async def test_permalink_exists(entity_repository: EntityRepository, sample_entity: Entity): """Test checking if a permalink exists without loading full entity.""" # Existing permalink should return True - assert await entity_repository.permalink_exists(sample_entity.permalink) is True + assert await entity_repository.permalink_exists(sample_entity.permalink) is True # pyright: ignore [reportArgumentType] # Non-existent permalink should return False assert await entity_repository.permalink_exists("nonexistent/permalink") is False @@ -930,7 +930,7 @@ async def test_get_file_path_for_permalink( ): """Test getting file_path for a permalink without loading full entity.""" # Existing permalink should return file_path - file_path = await entity_repository.get_file_path_for_permalink(sample_entity.permalink) + file_path = await entity_repository.get_file_path_for_permalink(sample_entity.permalink) # pyright: ignore [reportArgumentType] assert file_path == sample_entity.file_path # Non-existent permalink should return None diff --git a/tests/services/test_initialization.py b/tests/services/test_initialization.py index 720c6903..ed3271f4 100644 --- a/tests/services/test_initialization.py +++ b/tests/services/test_initialization.py @@ -182,7 +182,7 @@ async def test_initialize_file_sync_background_tasks( @pytest.mark.asyncio @patch("basic_memory.services.initialization.db.get_or_create_db") -@patch("basic_memory.cli.commands.sync.get_sync_service") +@patch("basic_memory.sync.sync_service.get_sync_service") @patch("basic_memory.sync.WatchService") @patch("basic_memory.services.initialization.asyncio.create_task") @patch.dict("os.environ", {"BASIC_MEMORY_MCP_PROJECT": "project1"}) diff --git a/tests/sync/test_sync_service.py b/tests/sync/test_sync_service.py index 9cc9a7b9..155af58a 100644 --- a/tests/sync/test_sync_service.py +++ b/tests/sync/test_sync_service.py @@ -171,7 +171,7 @@ Content raise IntegrityError( "UNIQUE constraint failed: relation.from_id, relation.to_id, relation.relation_type", None, - None, + None, # pyright: ignore [reportArgumentType] ) with patch.object( @@ -712,11 +712,8 @@ async def test_sync_preserves_timestamps( sync_service: SyncService, project_config: ProjectConfig, entity_service: EntityService, - db_backend, ): """Test that sync preserves file timestamps and frontmatter dates.""" - if db_backend == "postgres": - pytest.skip("Postgres timestamp handling differs from SQLite") project_dir = project_config.home # Create a file with explicit frontmatter dates @@ -768,7 +765,6 @@ async def test_sync_updates_timestamps_on_file_modification( sync_service: SyncService, project_config: ProjectConfig, entity_service: EntityService, - db_backend, ): """Test that sync updates entity timestamps when files are modified. @@ -777,9 +773,6 @@ async def test_sync_updates_timestamps_on_file_modification( not the database operation time. This is critical for accurate temporal ordering in search and recent_activity queries. """ - if db_backend == "postgres": - pytest.skip("Postgres timestamp handling differs from SQLite") - project_dir = project_config.home # Create initial file diff --git a/tests/sync/test_watch_service.py b/tests/sync/test_watch_service.py index b5bd85a3..91898686 100644 --- a/tests/sync/test_watch_service.py +++ b/tests/sync/test_watch_service.py @@ -8,7 +8,7 @@ import pytest from watchfiles import Change from basic_memory.models.project import Project -from basic_memory.sync.watch_service import WatchService, WatchServiceState +from basic_memory.sync.watch_service import WatchServiceState async def create_test_file(path: Path, content: str = "test content") -> None: @@ -17,10 +17,7 @@ async def create_test_file(path: Path, content: str = "test content") -> None: path.write_text(content) -@pytest.fixture -def watch_service(sync_service, file_service, project_config): - """Create watch service instance.""" - return WatchService(sync_service, file_service, project_config) +# Note: watch_service fixture is defined in conftest.py with sync_service_factory def test_watch_service_init(watch_service, project_config): diff --git a/uv.lock b/uv.lock index aa0b7bb5..5ddcdf83 100644 --- a/uv.lock +++ b/uv.lock @@ -60,15 +60,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, ] -[[package]] -name = "asgiref" -version = "3.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, -] - [[package]] name = "asttokens" version = "3.0.0" @@ -135,18 +126,19 @@ dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "fastmcp" }, { name = "greenlet" }, - { name = "logfire", extra = ["fastapi"] }, { name = "loguru" }, { name = "markdown-it-py" }, { name = "mcp" }, { name = "nest-asyncio" }, { name = "pillow" }, + { name = "psycopg" }, { name = "pybars3" }, { name = "pydantic", extra = ["email", "timezone"] }, { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "pyright" }, { name = "pytest-aio" }, + { name = "pytest-asyncio" }, { name = "python-dotenv" }, { name = "python-frontmatter" }, { name = "pyyaml" }, @@ -162,12 +154,14 @@ dev = [ { name = "freezegun" }, { name = "gevent" }, { name = "icecream" }, + { name = "psycopg" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-xdist" }, { name = "ruff" }, + { name = "testcontainers" }, ] [package.metadata] @@ -180,18 +174,19 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.115.8" }, { name = "fastmcp", specifier = ">=2.10.2" }, { name = "greenlet", specifier = ">=3.1.1" }, - { name = "logfire", extras = ["fastapi"], specifier = ">=0.73.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "markdown-it-py", specifier = ">=3.0.0" }, { name = "mcp", specifier = ">=1.2.0" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, { name = "pillow", specifier = ">=11.1.0" }, + { name = "psycopg", specifier = "==3.3.1" }, { name = "pybars3", specifier = ">=0.9.7" }, { name = "pydantic", extras = ["email", "timezone"], specifier = ">=2.10.3" }, { name = "pydantic-settings", specifier = ">=2.6.1" }, { name = "pyjwt", specifier = ">=2.10.1" }, { name = "pyright", specifier = ">=1.1.390" }, { name = "pytest-aio", specifier = ">=1.9.0" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "python-frontmatter", specifier = ">=1.1.0" }, { name = "pyyaml", specifier = ">=6.0.1" }, @@ -207,12 +202,14 @@ dev = [ { name = "freezegun", specifier = ">=1.5.5" }, { name = "gevent", specifier = ">=24.11.1" }, { name = "icecream", specifier = ">=2.1.3" }, + { name = "psycopg", specifier = ">=3.2.0" }, { name = "pytest", specifier = ">=8.3.4" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-cov", specifier = ">=4.1.0" }, { name = "pytest-mock", specifier = ">=3.12.0" }, { name = "pytest-xdist", specifier = ">=3.0.0" }, { name = "ruff", specifier = ">=0.1.6" }, + { name = "testcontainers", extras = ["postgres"], specifier = ">=4.0.0" }, ] [[package]] @@ -458,6 +455,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, ] +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + [[package]] name = "docstring-parser" version = "0.17.0" @@ -646,18 +657,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/16/b71171e97ec7b4ded8669542f4369d88d5a289e2704efbbde51e858e062a/gevent-25.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:0bacf89a65489d26c7087669af89938d5bfd9f7afb12a07b57855b9fad6ccbd0", size = 2937113, upload-time = "2025-05-12T11:12:03.191Z" }, ] -[[package]] -name = "googleapis-common-protos" -version = "1.70.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/39/24/33db22342cf4a2ea27c9955e6713140fedd51e8b141b5ce5260897020f1a/googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257", size = 145903, upload-time = "2025-04-14T10:17:02.924Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530, upload-time = "2025-04-14T10:17:01.271Z" }, -] - [[package]] name = "greenlet" version = "3.2.4" @@ -789,18 +788,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] -[[package]] -name = "importlib-metadata" -version = "8.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, -] - [[package]] name = "iniconfig" version = "2.1.0" @@ -888,29 +875,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/1e/fb441c07b6662ec1fc92b249225ba6e6e5221b05623cb0131d082f782edc/lazy_object_proxy-1.11.0-py3-none-any.whl", hash = "sha256:a56a5093d433341ff7da0e89f9b486031ccd222ec8e52ec84d0ec1cdc819674b", size = 16635, upload-time = "2025-04-16T16:53:47.198Z" }, ] -[[package]] -name = "logfire" -version = "4.13.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "executing" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-sdk" }, - { name = "protobuf" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/11/51/004bbe0276fcecfea8d4a4c76ad33426c9b34428e0723f22c6bb801dbc22/logfire-4.13.2.tar.gz", hash = "sha256:4e756e140c3b8fd25653d20437ebcb75734975f5382de6ae28be775c75575d95", size = 547796, upload-time = "2025-10-13T16:17:53.392Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/5f/0803848cc5ce524ff830e5f2a2f1400fd6ee72be705d87d0432cec42b1e4/logfire-4.13.2-py3-none-any.whl", hash = "sha256:887e99897a1818864aa5bfc595b02c93264ce23d1860866369eff6b6e2dde1c6", size = 228152, upload-time = "2025-10-13T16:17:50.641Z" }, -] - -[package.optional-dependencies] -fastapi = [ - { name = "opentelemetry-instrumentation-fastapi" }, -] - [[package]] name = "loguru" version = "0.7.3" @@ -1105,144 +1069,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/dd/b3fd642260cb17532f66cc1e8250f3507d1e580483e209dc1e9d13bd980d/openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60", size = 39713, upload-time = "2025-06-07T14:48:54.077Z" }, ] -[[package]] -name = "opentelemetry-api" -version = "1.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "importlib-metadata" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/63/04/05040d7ce33a907a2a02257e601992f0cdf11c73b33f13c4492bf6c3d6d5/opentelemetry_api-1.37.0.tar.gz", hash = "sha256:540735b120355bd5112738ea53621f8d5edb35ebcd6fe21ada3ab1c61d1cd9a7", size = 64923, upload-time = "2025-09-11T10:29:01.662Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/48/28ed9e55dcf2f453128df738210a980e09f4e468a456fa3c763dbc8be70a/opentelemetry_api-1.37.0-py3-none-any.whl", hash = "sha256:accf2024d3e89faec14302213bc39550ec0f4095d1cf5ca688e1bfb1c8612f47", size = 65732, upload-time = "2025-09-11T10:28:41.826Z" }, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-common" -version = "1.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-proto" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/6c/10018cbcc1e6fff23aac67d7fd977c3d692dbe5f9ef9bb4db5c1268726cc/opentelemetry_exporter_otlp_proto_common-1.37.0.tar.gz", hash = "sha256:c87a1bdd9f41fdc408d9cc9367bb53f8d2602829659f2b90be9f9d79d0bfe62c", size = 20430, upload-time = "2025-09-11T10:29:03.605Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/13/b4ef09837409a777f3c0af2a5b4ba9b7af34872bc43609dda0c209e4060d/opentelemetry_exporter_otlp_proto_common-1.37.0-py3-none-any.whl", hash = "sha256:53038428449c559b0c564b8d718df3314da387109c4d36bd1b94c9a641b0292e", size = 18359, upload-time = "2025-09-11T10:28:44.939Z" }, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-http" -version = "1.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-common" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5d/e3/6e320aeb24f951449e73867e53c55542bebbaf24faeee7623ef677d66736/opentelemetry_exporter_otlp_proto_http-1.37.0.tar.gz", hash = "sha256:e52e8600f1720d6de298419a802108a8f5afa63c96809ff83becb03f874e44ac", size = 17281, upload-time = "2025-09-11T10:29:04.844Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/e9/70d74a664d83976556cec395d6bfedd9b85ec1498b778367d5f93e373397/opentelemetry_exporter_otlp_proto_http-1.37.0-py3-none-any.whl", hash = "sha256:54c42b39945a6cc9d9a2a33decb876eabb9547e0dcb49df090122773447f1aef", size = 19576, upload-time = "2025-09-11T10:28:46.726Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation" -version = "0.58b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "packaging" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/36/7c307d9be8ce4ee7beb86d7f1d31027f2a6a89228240405a858d6e4d64f9/opentelemetry_instrumentation-0.58b0.tar.gz", hash = "sha256:df640f3ac715a3e05af145c18f527f4422c6ab6c467e40bd24d2ad75a00cb705", size = 31549, upload-time = "2025-09-11T11:42:14.084Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/db/5ff1cd6c5ca1d12ecf1b73be16fbb2a8af2114ee46d4b0e6d4b23f4f4db7/opentelemetry_instrumentation-0.58b0-py3-none-any.whl", hash = "sha256:50f97ac03100676c9f7fc28197f8240c7290ca1baa12da8bfbb9a1de4f34cc45", size = 33019, upload-time = "2025-09-11T11:41:00.624Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-asgi" -version = "0.58b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asgiref" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7b/e2/03ff707d881d590c7adaed5e9d1979aed7e5e53fc1ed89035e5ed9f304af/opentelemetry_instrumentation_asgi-0.58b0.tar.gz", hash = "sha256:3ccc0c9c1c8c71e8d9da5945c6dcd9c0c8d147839f208536b7042c6dd98e65c9", size = 25116, upload-time = "2025-09-11T11:42:18.437Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/71/a00884c6655387c70070138acbf79a6616ad5d4489680f40708d75b598a7/opentelemetry_instrumentation_asgi-0.58b0-py3-none-any.whl", hash = "sha256:508a6d79e333d648d2afee0e140b6e80eb5d443be183be58e81d9ff88373168a", size = 16798, upload-time = "2025-09-11T11:41:08.105Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-fastapi" -version = "0.58b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-instrumentation-asgi" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/64/09/4f8fcab834af6b403e5e2d94bdfb2d0835ba8cd1049bcc156995f47b65fb/opentelemetry_instrumentation_fastapi-0.58b0.tar.gz", hash = "sha256:03da470d694116a0a40f4e76319e42f3ff9efc49abf804b2acc2c07f96661497", size = 24598, upload-time = "2025-09-11T11:42:35.325Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/fb/82de06eba54e5cb979274f073065ebc374794853502d342b5155073d1194/opentelemetry_instrumentation_fastapi-0.58b0-py3-none-any.whl", hash = "sha256:d89bfec69c9ffc5d9f3fe58655d6660a66b2bca863b9132712c06edcde68b6fa", size = 13460, upload-time = "2025-09-11T11:41:28.507Z" }, -] - -[[package]] -name = "opentelemetry-proto" -version = "1.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dd/ea/a75f36b463a36f3c5a10c0b5292c58b31dbdde74f6f905d3d0ab2313987b/opentelemetry_proto-1.37.0.tar.gz", hash = "sha256:30f5c494faf66f77faeaefa35ed4443c5edb3b0aa46dad073ed7210e1a789538", size = 46151, upload-time = "2025-09-11T10:29:11.04Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/25/f89ea66c59bd7687e218361826c969443c4fa15dfe89733f3bf1e2a9e971/opentelemetry_proto-1.37.0-py3-none-any.whl", hash = "sha256:8ed8c066ae8828bbf0c39229979bdf583a126981142378a9cbe9d6fd5701c6e2", size = 72534, upload-time = "2025-09-11T10:28:56.831Z" }, -] - -[[package]] -name = "opentelemetry-sdk" -version = "1.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f4/62/2e0ca80d7fe94f0b193135375da92c640d15fe81f636658d2acf373086bc/opentelemetry_sdk-1.37.0.tar.gz", hash = "sha256:cc8e089c10953ded765b5ab5669b198bbe0af1b3f89f1007d19acd32dc46dda5", size = 170404, upload-time = "2025-09-11T10:29:11.779Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/62/9f4ad6a54126fb00f7ed4bb5034964c6e4f00fcd5a905e115bd22707e20d/opentelemetry_sdk-1.37.0-py3-none-any.whl", hash = "sha256:8f3c3c22063e52475c5dbced7209495c2c16723d016d39287dfc215d1771257c", size = 131941, upload-time = "2025-09-11T10:28:57.83Z" }, -] - -[[package]] -name = "opentelemetry-semantic-conventions" -version = "0.58b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/aa/1b/90701d91e6300d9f2fb352153fb1721ed99ed1f6ea14fa992c756016e63a/opentelemetry_semantic_conventions-0.58b0.tar.gz", hash = "sha256:6bd46f51264279c433755767bb44ad00f1c9e2367e1b42af563372c5a6fa0c25", size = 129867, upload-time = "2025-09-11T10:29:12.597Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/90/68152b7465f50285d3ce2481b3aec2f82822e3f52e5152eeeaf516bab841/opentelemetry_semantic_conventions-0.58b0-py3-none-any.whl", hash = "sha256:5564905ab1458b96684db1340232729fce3b5375a06e140e8904c78e4f815b28", size = 207954, upload-time = "2025-09-11T10:28:59.218Z" }, -] - -[[package]] -name = "opentelemetry-util-http" -version = "0.58b0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c6/5f/02f31530faf50ef8a41ab34901c05cbbf8e9d76963ba2fb852b0b4065f4e/opentelemetry_util_http-0.58b0.tar.gz", hash = "sha256:de0154896c3472c6599311c83e0ecee856c4da1b17808d39fdc5cce5312e4d89", size = 9411, upload-time = "2025-09-11T11:43:05.602Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/a3/0a1430c42c6d34d8372a16c104e7408028f0c30270d8f3eb6cccf2e82934/opentelemetry_util_http-0.58b0-py3-none-any.whl", hash = "sha256:6c6b86762ed43025fbd593dc5f700ba0aa3e09711aedc36fd48a13b23d8cb1e7", size = 7652, upload-time = "2025-09-11T11:42:09.682Z" }, -] - [[package]] name = "packaging" version = "25.0" @@ -1346,18 +1172,16 @@ wheels = [ ] [[package]] -name = "protobuf" -version = "6.33.0" +name = "psycopg" +version = "3.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/ff/64a6c8f420818bb873713988ca5492cba3a7946be57e027ac63495157d97/protobuf-6.33.0.tar.gz", hash = "sha256:140303d5c8d2037730c548f8c7b93b20bb1dc301be280c378b82b8894589c954", size = 443463, upload-time = "2025-10-15T20:39:52.159Z" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/ed/3a30e8ef82d4128c76aa9bd6b2a7fe6c16c283811e6655997f5047801b47/psycopg-3.3.1.tar.gz", hash = "sha256:ccfa30b75874eef809c0fbbb176554a2640cc1735a612accc2e2396a92442fc6", size = 165596, upload-time = "2025-12-02T21:09:55.545Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/ee/52b3fa8feb6db4a833dfea4943e175ce645144532e8a90f72571ad85df4e/protobuf-6.33.0-cp310-abi3-win32.whl", hash = "sha256:d6101ded078042a8f17959eccd9236fb7a9ca20d3b0098bbcb91533a5680d035", size = 425593, upload-time = "2025-10-15T20:39:40.29Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c6/7a465f1825872c55e0341ff4a80198743f73b69ce5d43ab18043699d1d81/protobuf-6.33.0-cp310-abi3-win_amd64.whl", hash = "sha256:9a031d10f703f03768f2743a1c403af050b6ae1f3480e9c140f39c45f81b13ee", size = 436882, upload-time = "2025-10-15T20:39:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/e1/a9/b6eee662a6951b9c3640e8e452ab3e09f117d99fc10baa32d1581a0d4099/protobuf-6.33.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:905b07a65f1a4b72412314082c7dbfae91a9e8b68a0cc1577515f8df58ecf455", size = 427521, upload-time = "2025-10-15T20:39:43.803Z" }, - { url = "https://files.pythonhosted.org/packages/10/35/16d31e0f92c6d2f0e77c2a3ba93185130ea13053dd16200a57434c882f2b/protobuf-6.33.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e0697ece353e6239b90ee43a9231318302ad8353c70e6e45499fa52396debf90", size = 324445, upload-time = "2025-10-15T20:39:44.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/eb/2a981a13e35cda8b75b5585aaffae2eb904f8f351bdd3870769692acbd8a/protobuf-6.33.0-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:e0a1715e4f27355afd9570f3ea369735afc853a6c3951a6afe1f80d8569ad298", size = 339159, upload-time = "2025-10-15T20:39:46.186Z" }, - { url = "https://files.pythonhosted.org/packages/21/51/0b1cbad62074439b867b4e04cc09b93f6699d78fd191bed2bbb44562e077/protobuf-6.33.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:35be49fd3f4fefa4e6e2aacc35e8b837d6703c37a2168a55ac21e9b1bc7559ef", size = 323172, upload-time = "2025-10-15T20:39:47.465Z" }, - { url = "https://files.pythonhosted.org/packages/07/d1/0a28c21707807c6aacd5dc9c3704b2aa1effbf37adebd8caeaf68b17a636/protobuf-6.33.0-py3-none-any.whl", hash = "sha256:25c9e1963c6734448ea2d308cfa610e692b801304ba0908d7bfa564ac5132995", size = 170477, upload-time = "2025-10-15T20:39:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/0b4a4c25a47c2d907afa97674287dab61bc9941c9ac3972a67100e33894d/psycopg-3.3.1-py3-none-any.whl", hash = "sha256:e44d8eae209752efe46318f36dd0fdf5863e928009338d736843bb1084f6435c", size = 212760, upload-time = "2025-12-02T21:02:36.029Z" }, ] [[package]] @@ -1530,14 +1354,15 @@ wheels = [ [[package]] name = "pytest-asyncio" -version = "1.1.0" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] [[package]] @@ -2059,6 +1884,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" }, ] +[[package]] +name = "testcontainers" +version = "4.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/b3/c272537f3ea2f312555efeb86398cc382cd07b740d5f3c730918c36e64e1/testcontainers-4.13.3.tar.gz", hash = "sha256:9d82a7052c9a53c58b69e1dc31da8e7a715e8b3ec1c4df5027561b47e2efe646", size = 79064, upload-time = "2025-11-14T05:08:47.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/27/c2f24b19dafa197c514abe70eda69bc031c5152c6b1f1e5b20099e2ceedd/testcontainers-4.13.3-py3-none-any.whl", hash = "sha256:063278c4805ffa6dd85e56648a9da3036939e6c0ac1001e851c9276b19b05970", size = 124784, upload-time = "2025-11-14T05:08:46.053Z" }, +] + [[package]] name = "typer" version = "0.16.0" @@ -2346,15 +2187,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] -[[package]] -name = "zipp" -version = "3.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, -] - [[package]] name = "zope-event" version = "5.1.1"