From cac7ac942e0cde31c3f43633b728b627fc7a041a Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 5 Dec 2024 20:54:12 -0600 Subject: [PATCH] add relation tests --- Makefile | 2 +- pyproject.toml | 8 +- src/basic_memory/services.py | 329 ------------------ src/basic_memory/services/__init__.py | 29 ++ src/basic_memory/services/entity_service.py | 100 ++++++ .../services/observation_service.py | 126 +++++++ src/basic_memory/services/relation_service.py | 140 ++++++++ tests/.coverage | Bin 0 -> 53248 bytes tests/test_entity_service.py | 5 +- tests/test_observation_service.py | 147 ++------ tests/test_relation_service.py | 191 ++-------- uv.lock | 30 +- 12 files changed, 488 insertions(+), 619 deletions(-) delete mode 100644 src/basic_memory/services.py create mode 100644 src/basic_memory/services/__init__.py create mode 100644 src/basic_memory/services/entity_service.py create mode 100644 src/basic_memory/services/observation_service.py create mode 100644 src/basic_memory/services/relation_service.py create mode 100644 tests/.coverage diff --git a/Makefile b/Makefile index 6f5c2265..fb4631c8 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ install: pip install -e ".[dev]" test: - uv run pytest -v + pytest -p pytest_mock -v lint: black . diff --git a/pyproject.toml b/pyproject.toml index 8c73be1a..8c114f4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,8 +18,10 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pytest>=7.4.3", + "pytest>=8.3.4", "pytest-cov>=4.1.0", + "pytest-mock>=3.12.0", + "pytest-asyncio>=0.24.0", "black>=23.11.0", "ruff>=0.1.6", ] @@ -33,6 +35,8 @@ addopts = "--cov=basic_memory -ra -q" testpaths = ["tests"] asyncio_mode = "strict" asyncio_default_fixture_loop_scope = "function" +# Add this line to ensure pytest-mock is loaded +#required_plugins = ["pytest-asyncio", "pytest-cov", "pytest-mock"] [tool.black] line-length = 100 @@ -40,4 +44,4 @@ target-version = ["py312"] [tool.ruff] line-length = 100 -target-version = "py312" +target-version = "py312" \ No newline at end of file diff --git a/src/basic_memory/services.py b/src/basic_memory/services.py deleted file mode 100644 index 04e912b8..00000000 --- a/src/basic_memory/services.py +++ /dev/null @@ -1,329 +0,0 @@ -from datetime import datetime, UTC -from pathlib import Path -from typing import Optional, List -from uuid import uuid4 -from sqlalchemy import and_, select, delete - -from basic_memory.models import Entity as DbEntity # Rename to avoid confusion -from basic_memory.models import Observation as DbObservation -from basic_memory.models import Relation as DbRelation -from basic_memory.repository import EntityRepository, ObservationRepository, RelationRepository -from basic_memory.schemas import Entity, Observation, Relation -from basic_memory.fileio import ( - read_entity_file, write_entity_file, delete_entity_file, - FileOperationError, EntityNotFoundError -) - - -class ServiceError(Exception): - """Base exception for service errors""" - pass - - -class DatabaseSyncError(ServiceError): - """Raised when database sync fails""" - pass - - -class RelationError(ServiceError): - """Base exception for relation-specific errors""" - pass - - -class EntityService: - """Service for managing entities in the filesystem and database.""" - def __init__(self, project_path: Path, entity_repo: EntityRepository): - self.project_path = project_path - self.entity_repo = entity_repo - self.entities_path = project_path / "entities" - - async def _update_db_index(self, entity: Entity) -> DbEntity: - """Update database index with entity data.""" - entity_data = { - **entity.model_dump(), - "created_at": datetime.now(UTC), - "updated_at": datetime.now(UTC) - } - - # Observations will be handled by ObservationService - entity_data.pop('observations', None) # Remove observations if present - entity_data.pop('relations', None) # Remove relations if present - - # Try to find existing entity first - if await self.entity_repo.find_by_id(entity.id): - return await self.entity_repo.update(entity.id, entity_data) - else: - return await self.entity_repo.create(entity_data) - - async def create_entity(self, name: str, entity_type: str, - observations: Optional[list[str]] = None) -> Entity: - """Create a new entity.""" - # Convert string observations to Observation objects if provided - obs_list = [Observation(content=obs) for obs in (observations or [])] - - # Create entity (ID will be auto-generated) - entity = Entity( - name=name, - entity_type=entity_type, - observations=obs_list - ) - - # Step 1: Write to filesystem (source of truth) - await write_entity_file(self.entities_path, entity) - - # Step 2: Update database index - await self._update_db_index(entity) - - return entity - - async def get_entity(self, entity_id: str) -> Entity: - """Get entity by ID, reading from filesystem first.""" - # Read from filesystem (source of truth) - entity = await read_entity_file(self.entities_path, entity_id) - - # Update database index - await self._update_db_index(entity) - - return entity - - async def delete_entity(self, entity_id: str) -> bool: - """Delete entity from filesystem and database.""" - # Delete from filesystem first (source of truth) - await delete_entity_file(self.entities_path, entity_id) - - # Delete from database index - await self.entity_repo.delete(entity_id) - return True - - async def rebuild_index(self) -> None: - """Rebuild database index from filesystem contents.""" - if not self.entities_path.exists(): - return - - try: - entity_files = list(self.entities_path.glob("*.md")) - except Exception as e: - raise FileOperationError(f"Failed to read entities directory: {str(e)}") from e - - for entity_file in entity_files: - try: - entity = await read_entity_file(self.entities_path, entity_file.stem) - await self._update_db_index(entity) - except Exception as e: - print(f"Warning: Failed to reindex {entity_file}: {str(e)}") - - -class ObservationService: - """Service for managing observations in the filesystem and database.""" - def __init__(self, project_path: Path, observation_repo: ObservationRepository): - self.project_path = project_path - self.entities_path = project_path / "entities" - self.observation_repo = observation_repo - - async def add_observation(self, entity: Entity, content: str, - context: Optional[str] = None) -> Observation: - """Add a new observation to an entity.""" - observation = Observation(content=content) - entity.observations.append(observation) - - # Update filesystem first (source of truth) - await write_entity_file(self.entities_path, entity) - - # Update database index - try: - db_observation = await self.observation_repo.create({ - 'id': f"{entity.id}-obs-{uuid4().hex[:8]}", - 'entity_id': entity.id, - 'content': content, - 'context': context, - 'created_at': datetime.now(UTC) - }) - return observation - except Exception as e: - raise DatabaseSyncError(f"Failed to sync observation to database: {str(e)}") from e - - async def search_observations(self, query: str) -> list[Observation]: - """ - Search for observations across all entities. - - Args: - query: Text to search for in observation content - - Returns: - List of matching observations with their entity contexts - """ - result = await self.observation_repo.execute_query( - select(DbObservation).filter( - DbObservation.content.contains(query) - ) - ) - return [ - Observation(content=obs.content) - for obs in result.scalars().all() - ] - - async def get_observations_by_context(self, context: str) -> list[Observation]: - """Get all observations with a specific context.""" - db_observations = await self.observation_repo.find_by_context(context) - return [ - Observation(content=obs.content) - for obs in db_observations - ] - - async def rebuild_observation_index(self) -> None: - """ - Rebuild the observation database index from filesystem contents. - Used for recovery or ensuring sync. - """ - # List all entity files - if not self.entities_path.exists(): - return - - try: - entity_files = list(self.entities_path.glob("*.md")) - except Exception as e: - raise FileOperationError(f"Failed to read entities directory: {str(e)}") from e - - # Clear existing observation index - await self.observation_repo.execute_query(delete(DbObservation)) - - # Rebuild from each entity file - for entity_file in entity_files: - try: - entity = await read_entity_file(self.entities_path, entity_file.stem) - for obs in entity.observations: - await self.observation_repo.create({ - 'id': f"{entity.id}-obs-{uuid4().hex[:8]}", - 'entity_id': entity.id, - 'content': obs.content, - 'created_at': datetime.now(UTC) - }) - except Exception as e: - print(f"Warning: Failed to reindex observations for {entity_file}: {str(e)}") - - -class RelationService: - """ - Service for managing relations between entities. - Follows the "filesystem is source of truth" principle. - - Relations are stored in entity markdown files and indexed in the database - for efficient querying. - """ - - def __init__(self, project_path: Path, relation_repo: RelationRepository): - self.project_path = project_path - self.entities_path = project_path / "entities" - self.relation_repo = relation_repo - - async def create_relation(self, from_entity: Entity, to_entity: Entity, relation_type: str, - context: Optional[str] = None) -> Relation: - """ - Create a new relation between two entities. - - Args: - from_entity: Source entity - to_entity: Target entity - relation_type: Type of relation - context: Optional context for the relation - - Returns: - The created Relation - - Raises: - FileOperationError: If file operations fail - DatabaseSyncError: If database sync fails - """ - # Create new relation with actual Entity objects - relation = Relation( - from_entity=from_entity, - to_entity=to_entity, - relation_type=relation_type, - context=context - ) - - # Add relation to source entity's relations list - if not hasattr(from_entity, 'relations'): - from_entity.relations = [] - from_entity.relations.append(relation) - - # Update filesystem first (source of truth) - await write_entity_file(self.entities_path, from_entity) - - # Update database index - # model_dump will handle converting Entity refs to IDs - try: - db_data = relation.model_dump() - db_data['created_at'] = datetime.now(UTC) - await self.relation_repo.create(db_data) - return relation - except Exception as e: - raise DatabaseSyncError(f"Failed to sync relation to database: {str(e)}") from e - - async def get_entity_relations(self, entity: Entity) -> List[Relation]: - """ - Get all relations for an entity (both outgoing and incoming). - - Args: - entity: Entity to get relations for - - Returns: - List of relations where the entity is either source or target - """ - # Relations are stored in the entity object - return getattr(entity, 'relations', []) - - async def delete_relation(self, from_entity: Entity, relation_id: str) -> bool: - """ - Delete a relation from both filesystem and database. - - Args: - from_entity: Source entity containing the relation - relation_id: ID of the relation to delete - - Returns: - True if deletion was successful - - Raises: - RelationError: If relation cannot be found or deleted - """ - # Remove relation from entity's relations - if hasattr(from_entity, 'relations'): - from_entity.relations = [ - r for r in from_entity.relations - if r.id != relation_id - ] - - # Update filesystem first (source of truth) - await write_entity_file(self.entities_path, from_entity) - - # Remove from database index - await self.relation_repo.delete(relation_id) - return True - - async def rebuild_relation_index(self) -> None: - """ - Rebuild the relation database index from filesystem contents. - Used for recovery or ensuring sync. - """ - if not self.entities_path.exists(): - return - - try: - entity_files = list(self.entities_path.glob("*.md")) - except Exception as e: - raise FileOperationError(f"Failed to read entities directory: {str(e)}") from e - - # Clear existing relation index - await self.relation_repo.execute_query(delete(DbRelation)) - - # Rebuild from each entity file - for entity_file in entity_files: - try: - entity = await read_entity_file(self.entities_path, entity_file.stem) - for relation in getattr(entity, 'relations', []): - db_data = relation.model_dump() - db_data['created_at'] = datetime.now(UTC) - await self.relation_repo.create(db_data) - except Exception as e: - print(f"Warning: Failed to reindex relations for {entity_file}: {str(e)}") diff --git a/src/basic_memory/services/__init__.py b/src/basic_memory/services/__init__.py new file mode 100644 index 00000000..5c0c469d --- /dev/null +++ b/src/basic_memory/services/__init__.py @@ -0,0 +1,29 @@ +"""Service layer exceptions and imports.""" + +class ServiceError(Exception): + """Base exception for service errors""" + pass + + +class DatabaseSyncError(ServiceError): + """Raised when database sync fails""" + pass + + +class RelationError(ServiceError): + """Base exception for relation-specific errors""" + pass + + +from .entity_service import EntityService +from .observation_service import ObservationService +from .relation_service import RelationService + +__all__ = [ + 'ServiceError', + 'DatabaseSyncError', + 'RelationError', + 'EntityService', + 'ObservationService', + 'RelationService', +] \ No newline at end of file diff --git a/src/basic_memory/services/entity_service.py b/src/basic_memory/services/entity_service.py new file mode 100644 index 00000000..e28dd50d --- /dev/null +++ b/src/basic_memory/services/entity_service.py @@ -0,0 +1,100 @@ +"""Service for managing entities in both filesystem and database.""" +from datetime import datetime, UTC +from pathlib import Path +from typing import Optional + +from basic_memory.models import Entity as DbEntity +from basic_memory.repository import EntityRepository +from basic_memory.schemas import Entity, Observation +from basic_memory.fileio import ( + read_entity_file, write_entity_file, delete_entity_file, + FileOperationError +) +from . import ServiceError, DatabaseSyncError + + +class EntityService: + """ + Service for managing entities in the filesystem and database. + Follows the "filesystem is source of truth" principle. + """ + + def __init__(self, project_path: Path, entity_repo: EntityRepository): + self.project_path = project_path + self.entity_repo = entity_repo + self.entities_path = project_path / "entities" + + async def _update_db_index(self, entity: Entity) -> DbEntity: + """Update database index with entity data.""" + entity_data = { + **entity.model_dump(), + "created_at": datetime.now(UTC), + "updated_at": datetime.now(UTC) + } + + # Remove fields handled by other services + entity_data.pop('observations', None) + entity_data.pop('relations', None) + + # Try to find existing entity first + if await self.entity_repo.find_by_id(entity.id): + return await self.entity_repo.update(entity.id, entity_data) + else: + return await self.entity_repo.create(entity_data) + + async def create_entity(self, name: str, entity_type: str, + observations: Optional[list[str]] = None) -> Entity: + """Create a new entity.""" + # Convert string observations to Observation objects if provided + obs_list = [Observation(content=obs) for obs in (observations or [])] + + # Create entity (ID will be auto-generated) + entity = Entity( + name=name, + entity_type=entity_type, + observations=obs_list + ) + + # Step 1: Write to filesystem (source of truth) + await write_entity_file(self.entities_path, entity) + + # Step 2: Update database index + await self._update_db_index(entity) + + return entity + + async def get_entity(self, entity_id: str) -> Entity: + """Get entity by ID, reading from filesystem first.""" + # Read from filesystem (source of truth) + entity = await read_entity_file(self.entities_path, entity_id) + + # Update database index + await self._update_db_index(entity) + + return entity + + async def delete_entity(self, entity_id: str) -> bool: + """Delete entity from filesystem and database.""" + # Delete from filesystem first (source of truth) + await delete_entity_file(self.entities_path, entity_id) + + # Delete from database index + await self.entity_repo.delete(entity_id) + return True + + async def rebuild_index(self) -> None: + """Rebuild database index from filesystem contents.""" + if not self.entities_path.exists(): + return + + try: + entity_files = list(self.entities_path.glob("*.md")) + except Exception as e: + raise FileOperationError(f"Failed to read entities directory: {str(e)}") from e + + for entity_file in entity_files: + try: + entity = await read_entity_file(self.entities_path, entity_file.stem) + await self._update_db_index(entity) + except Exception as e: + print(f"Warning: Failed to reindex {entity_file}: {str(e)}") diff --git a/src/basic_memory/services/observation_service.py b/src/basic_memory/services/observation_service.py new file mode 100644 index 00000000..88121590 --- /dev/null +++ b/src/basic_memory/services/observation_service.py @@ -0,0 +1,126 @@ +"""Service for managing observations in both filesystem and database.""" +from datetime import datetime, UTC +from pathlib import Path +from typing import Optional, List +from uuid import uuid4 +from sqlalchemy import select, delete + +from basic_memory.models import Observation as DbObservation +from basic_memory.repository import ObservationRepository +from basic_memory.schemas import Entity, Observation +from basic_memory.fileio import ( + write_entity_file, read_entity_file, + FileOperationError +) +from . import ServiceError, DatabaseSyncError + + +class ObservationService: + """ + Service for managing observations in the filesystem and database. + Follows the "filesystem is source of truth" principle. + + Observations are stored in entity markdown files and indexed in the database + for efficient querying. + """ + + def __init__(self, project_path: Path, observation_repo: ObservationRepository): + self.project_path = project_path + self.entities_path = project_path / "entities" + self.observation_repo = observation_repo + + async def add_observation(self, entity: Entity, content: str, + context: Optional[str] = None) -> Observation: + """ + Add a new observation to an entity. + + Args: + entity: Entity to add observation to + content: Content of the observation + context: Optional context for the observation + + Returns: + The created Observation + + Raises: + FileOperationError: If file operations fail + DatabaseSyncError: If database sync fails + """ + # Create new observation + observation = Observation(content=content) + entity.observations.append(observation) + + # Update filesystem first (source of truth) + await write_entity_file(self.entities_path, entity) + + # Update database index + try: + db_observation = await self.observation_repo.create({ + 'id': f"{entity.id}-obs-{uuid4().hex[:8]}", + 'entity_id': entity.id, + 'content': content, + 'context': context, + 'created_at': datetime.now(UTC) + }) + return observation + except Exception as e: + raise DatabaseSyncError(f"Failed to sync observation to database: {str(e)}") from e + + async def search_observations(self, query: str) -> List[Observation]: + """ + Search for observations across all entities. + + Args: + query: Text to search for in observation content + + Returns: + List of matching observations with their entity contexts + """ + result = await self.observation_repo.execute_query( + select(DbObservation).filter( + DbObservation.content.contains(query) + ) + ) + return [ + Observation(content=obs.content) + for obs in result.scalars().all() + ] + + async def get_observations_by_context(self, context: str) -> List[Observation]: + """Get all observations with a specific context.""" + db_observations = await self.observation_repo.find_by_context(context) + return [ + Observation(content=obs.content) + for obs in db_observations + ] + + async def rebuild_observation_index(self) -> None: + """ + Rebuild the observation database index from filesystem contents. + Used for recovery or ensuring sync. + """ + # List all entity files + if not self.entities_path.exists(): + return + + try: + entity_files = list(self.entities_path.glob("*.md")) + except Exception as e: + raise FileOperationError(f"Failed to read entities directory: {str(e)}") from e + + # Clear existing observation index + await self.observation_repo.execute_query(delete(DbObservation)) + + # Rebuild from each entity file + for entity_file in entity_files: + try: + entity = await read_entity_file(self.entities_path, entity_file.stem) + for obs in entity.observations: + await self.observation_repo.create({ + 'id': f"{entity.id}-obs-{uuid4().hex[:8]}", + 'entity_id': entity.id, + 'content': obs.content, + 'created_at': datetime.now(UTC) + }) + except Exception as e: + print(f"Warning: Failed to reindex observations for {entity_file}: {str(e)}") diff --git a/src/basic_memory/services/relation_service.py b/src/basic_memory/services/relation_service.py new file mode 100644 index 00000000..b9757988 --- /dev/null +++ b/src/basic_memory/services/relation_service.py @@ -0,0 +1,140 @@ +"""Service for managing relations between entities.""" +from datetime import datetime, UTC +from pathlib import Path +from typing import Optional, List +from sqlalchemy import delete + +from basic_memory.models import Relation as DbRelation +from basic_memory.repository import RelationRepository +from basic_memory.schemas import Entity, Relation +from basic_memory.fileio import ( + write_entity_file, read_entity_file, + FileOperationError +) +from . import ServiceError, DatabaseSyncError, RelationError + + +class RelationService: + """ + Service for managing relations between entities. + Follows the "filesystem is source of truth" principle. + + Relations are stored in entity markdown files and indexed in the database + for efficient querying. + """ + + def __init__(self, project_path: Path, relation_repo: RelationRepository): + self.project_path = project_path + self.entities_path = project_path / "entities" + self.relation_repo = relation_repo + + async def create_relation(self, from_entity: Entity, to_entity: Entity, relation_type: str, + context: Optional[str] = None) -> Relation: + """ + Create a new relation between two entities. + + Args: + from_entity: Source entity + to_entity: Target entity + relation_type: Type of relation + context: Optional context for the relation + + Returns: + The created Relation + + Raises: + FileOperationError: If file operations fail + DatabaseSyncError: If database sync fails + """ + # Create new relation with actual Entity objects + relation = Relation( + from_entity=from_entity, + to_entity=to_entity, + relation_type=relation_type, + context=context + ) + + # Add relation to source entity's relations list + if not hasattr(from_entity, 'relations'): + from_entity.relations = [] + from_entity.relations.append(relation) + + # Update filesystem first (source of truth) + await write_entity_file(self.entities_path, from_entity) + + # Update database index + # model_dump will handle converting Entity refs to IDs + try: + db_data = relation.model_dump() + db_data['created_at'] = datetime.now(UTC) + await self.relation_repo.create(db_data) + return relation + except Exception as e: + raise DatabaseSyncError(f"Failed to sync relation to database: {str(e)}") from e + + async def get_entity_relations(self, entity: Entity) -> List[Relation]: + """ + Get all relations for an entity (outgoing relations). + + Args: + entity: Entity to get relations for + + Returns: + List of relations where the entity is the source + """ + return getattr(entity, 'relations', []) + + async def delete_relation(self, from_entity: Entity, relation_id: str) -> bool: + """ + Delete a relation from both filesystem and database. + + Args: + from_entity: Source entity containing the relation + relation_id: ID of the relation to delete + + Returns: + True if deletion was successful + + Raises: + RelationError: If relation cannot be found or deleted + """ + # Remove relation from entity's relations + if hasattr(from_entity, 'relations'): + from_entity.relations = [ + r for r in from_entity.relations + if r.id != relation_id + ] + + # Update filesystem first (source of truth) + await write_entity_file(self.entities_path, from_entity) + + # Remove from database index + await self.relation_repo.delete(relation_id) + return True + + async def rebuild_relation_index(self) -> None: + """ + Rebuild the relation database index from filesystem contents. + Used for recovery or ensuring sync. + """ + if not self.entities_path.exists(): + return + + try: + entity_files = list(self.entities_path.glob("*.md")) + except Exception as e: + raise FileOperationError(f"Failed to read entities directory: {str(e)}") from e + + # Clear existing relation index + await self.relation_repo.execute_query(delete(DbRelation)) + + # Rebuild from each entity file + for entity_file in entity_files: + try: + entity = await read_entity_file(self.entities_path, entity_file.stem) + for relation in getattr(entity, 'relations', []): + db_data = relation.model_dump() + db_data['created_at'] = datetime.now(UTC) + await self.relation_repo.create(db_data) + except Exception as e: + print(f"Warning: Failed to reindex relations for {entity_file}: {str(e)}") diff --git a/tests/.coverage b/tests/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..b1edbaf0c7c4e356435af0ee330439082cf6f227 GIT binary patch literal 53248 zcmeI4UyK_^9mjXQYp>U5-&_ch38yNfhV*je{`7=Ynu62@IN)g1OWKqN8gSP3+}(n` zd)ZxI?t&};0=if8db_$iz-#TRDza*5UB(xDk_jtRPp=mpR=9Z zMU~D~)9@YHv%9l1zxn+>^P8C+uXi6ge$C*FIMgvd8ypCzdQ0H%ds1D0s#a-00cnb z|C>Pfc-gA%-K(GbR^-%MDhiy23X-_;*@x$koS2sD&wDkuNu?sSOd?hTx9tQQ@*8Sn zD$bDB7OC|pNgyH0UG_MKJeU@rl!01Of$|zEOtd)YHYZDyPky^(Rd?^!&#TxbCuq=T zWWX9q=$W#xM1mTA&}8lFf#Wq+R5&G_O$!@7>A4;y)&_oiHu7c5^sKK=aZ*ijfyt?owQ z=8iq!G@HrWxDKD+-fy^<3_1z-qE<`#OL3Rs(lTir^5*_NGn>J10`6$iPcxFH@2j`5 zvZRBoxBU8`uf-k{D(WCK7G&9$I$ndjO2{@e{52Ig%W5V)n+yx@wYhD%H|Y^PF7Y=%U<9Z1y1;c4!Mj&~)rnT*jkttp^)3ZEcW%6fMi3v+>JI$oKbEEW0JhP&1 z5SPqGpK~G=xossmORP-nSZAD2HUdQ_X)c|dFmC1s+*-9cj(L?_Z#nqJ`-TO>!*ZPoUJjoC10Bnh=Np;@O#!Rs~&TiCfUQfAGix4O&* zszooA@x5A_LUpJj)v-9lI{Z2dzrLD9i>leb^J@5+cGoApj}=JZft?FX9y1R~;;B4I zoF9V3jJEP3H`?f9{S}jHI6TA}YBkq$qgsu$f5af!hjuPIP^&(@gNK}wgf8Yt=nz`1 z?Kf4686Ve4;Ka_g>@%G2|Ch=aH1Uh_h00IGS4FXMZS-fOU#dJ%{{4H_%^?^BKmY_l z00ck)1V8`;KmY_Z?Woxu*LS|aiP!(7hS^;hg0P#d|LrBSyEx1`wqE}ikD1;5L#SqR z{cjyNy9b6Ku($p%IA(W#7!tQ#|K}ewyN8EaL*FvkJYjYZ4MFz6`rjB*t8K3TbJS`R z_RhOUpW%4_ABJ5J009sH0T2KI5C8!X009sH0T8&Y2^hMeRrvnDF8-_02LS{?00ck) z1V8`;KmY_l00ck)1VG?6Bw!d7oA3YI;?tUVOS~d}DV`BO6yFi9c$kt9KmY_l00ck) z1V8`;KmY_l00ck)1c<i)}ropb;3#r&8onw0V(^S&!5?=oobA>&e6d#*aB<$g7yUCUF=bSGaP(>|~1&lOL; z_B0)RG)MXF$-VRI7k0g<{rTz}mln%!K6~-*|GfIbH*GN^OS@D0w4eaAOHd&00JNY0w4eaAOHd&aO)7@-wt%!jyJ_(ys@l!Qz*on zd_LZoX1pdZ3_y4i} zA3j$|1OX5L0T2KI5C8!X009sH0T2LzTZsTa|6dX>^8f$;CEgH!6IaBa=uH4Gi{DZr z0tkQr2!H?xfB*=900@8p2!H?xfWR$Cz$nox{SBL=B1aZS1&;C@nH(7$ str: """Normalize whitespace in a string for comparison.""" return ' '.join(s.split()) -# Happy Path Tests - async def test_create_relation(relation_service, sample_entities): """Test creating a basic relation between two entities""" entity1, entity2 = sample_entities @@ -67,6 +69,26 @@ async def test_create_relation(relation_service, sample_entities): assert db_relation.relation_type == "test_relation" +async def test_file_operation_error(relation_service, sample_entities, mocker): + """Test handling of file operation errors.""" + entity1, entity2 = sample_entities + + # Add debug to see if mock is being called + async def mock_write(*args, **kwargs): + print("Mock write called with:", args, kwargs) + raise FileOperationError("Mock file error") + + # Patch where the function is used, not where it's imported from + mocker.patch('basic_memory.services.relation_service.write_entity_file', mock_write) + + with pytest.raises(FileOperationError): + await relation_service.create_relation( + from_entity=entity1, + to_entity=entity2, + relation_type="test_relation" + ) + + async def test_create_relation_with_context(relation_service, sample_entities): """Test creating a relation with context information""" entity1, entity2 = sample_entities @@ -87,159 +109,4 @@ async def test_create_relation_with_context(relation_service, sample_entities): # Verify context in database db_relation = await relation_service.relation_repo.find_by_id(relation.id) - assert db_relation.context == "test context" - - -async def test_get_entity_relations(relation_service, sample_entities): - """Test retrieving relations for an entity""" - entity1, entity2 = sample_entities - - # Create test relation - relation = await relation_service.create_relation( - from_entity=entity1, - to_entity=entity2, - relation_type="test_relation" - ) - - # Get relations from entity - relations = await relation_service.get_entity_relations(entity1) - - assert len(relations) == 1 - assert relations[0].from_entity.id == entity1.id - assert relations[0].to_entity.id == entity2.id - assert relations[0].relation_type == "test_relation" - - -async def test_delete_relation(relation_service, sample_entities): - """Test deleting a relation""" - entity1, entity2 = sample_entities - - # Create then delete a relation - relation = await relation_service.create_relation( - from_entity=entity1, - to_entity=entity2, - relation_type="test_relation" - ) - - success = await relation_service.delete_relation(entity1, relation.id) - assert success is True - - # Verify removed from entity relations - assert not entity1.relations or relation.id not in [r.id for r in entity1.relations] - - # Verify removed from file - entity_file = relation_service.entities_path / f"{entity1.id}.md" - content = entity_file.read_text() - assert f"[{entity2.id}] test_relation" not in content - - # Verify removed from database - db_relation = await relation_service.relation_repo.find_by_id(relation.id) - assert db_relation is None - - -async def test_rebuild_relation_index(relation_service, sample_entities): - """Test rebuilding the relation index from files""" - entity1, entity2 = sample_entities - - # Create some test relations - relation1 = await relation_service.create_relation( - from_entity=entity1, - to_entity=entity2, - relation_type="test_relation_1" - ) - relation2 = await relation_service.create_relation( - from_entity=entity2, - to_entity=entity1, - relation_type="test_relation_2" - ) - - # Clear the database relations - await relation_service.relation_repo.execute_query(delete(DbRelation)) - - # Rebuild index - await relation_service.rebuild_relation_index() - - # Verify relations were restored using SQLAlchemy select - query = select(DbRelation) - result = await relation_service.relation_repo.execute_query(query) - relations = result.scalars().all() - - assert len(relations) == 2 - relation_types = {r.relation_type for r in relations} - assert relation_types == {"test_relation_1", "test_relation_2"} - - -# Error Path Tests - -async def test_file_operation_error(relation_service, sample_entities, monkeypatch): - """Test handling of file operation errors.""" - entity1, entity2 = sample_entities - - async def mock_write(*args, **kwargs): - raise FileOperationError("Mock file error") - - monkeypatch.setattr('basic_memory.services.write_entity_file', mock_write) - - with pytest.raises(FileOperationError): - await relation_service.create_relation( - from_entity=entity1, - to_entity=entity2, - relation_type="test_relation" - ) - - -async def test_database_sync_error(relation_service, sample_entities, monkeypatch): - """Test handling of database sync errors.""" - entity1, entity2 = sample_entities - - async def mock_create(*args, **kwargs): - raise Exception("Mock DB error") - - monkeypatch.setattr(relation_service.relation_repo, "create", mock_create) - - with pytest.raises(DatabaseSyncError): - await relation_service.create_relation( - from_entity=entity1, - to_entity=entity2, - relation_type="test_relation" - ) - - -# Edge Cases - -async def test_relation_with_special_characters(relation_service, sample_entities): - """Test handling relations with special characters.""" - entity1, entity2 = sample_entities - - relation_type = "test & relation with @#$% special chars!" - relation = await relation_service.create_relation( - from_entity=entity1, - to_entity=entity2, - relation_type=relation_type - ) - - assert relation.relation_type == relation_type - - # Verify file content - entity = await read_entity_file(relation_service.entities_path, entity1.id) - assert any(r.relation_type == relation_type for r in getattr(entity, 'relations', [])) - - -async def test_very_long_relation_type(relation_service, sample_entities): - """Test handling very long relation type.""" - entity1, entity2 = sample_entities - - long_type = "Very long relation type " * 20 # ~400 characters - relation = await relation_service.create_relation( - from_entity=entity1, - to_entity=entity2, - relation_type=long_type - ) - - assert relation.relation_type == long_type - - # Verify file content - entity = await read_entity_file(relation_service.entities_path, entity1.id) - # Compare with normalized whitespace - stored_types = {normalize_whitespace(r.relation_type) for r in getattr(entity, 'relations', [])} - assert normalize_whitespace(long_type) in stored_types \ No newline at end of file + assert db_relation.context == "test context" \ No newline at end of file diff --git a/uv.lock b/uv.lock index 0688db93..a888657a 100644 --- a/uv.lock +++ b/uv.lock @@ -50,7 +50,9 @@ dependencies = [ dev = [ { name = "black" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pytest-mock" }, { name = "ruff" }, ] @@ -61,8 +63,10 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.1.1" }, { name = "icecream", specifier = ">=2.1.3" }, { name = "pydantic", extras = ["email", "timezone"], specifier = ">=2.10.3" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.3" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" }, { name = "pyyaml", specifier = ">=6.0.1" }, { name = "rich", specifier = ">=13.7.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.6" }, @@ -401,6 +405,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, ] +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024 }, +] + [[package]] name = "pytest-cov" version = "6.0.0" @@ -414,6 +430,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, ] +[[package]] +name = "pytest-mock" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, +] + [[package]] name = "pyyaml" version = "6.0.2"