From 14f520c272e6c7304f6af80dc7685b6d299fc686 Mon Sep 17 00:00:00 2001 From: phernandez Date: Mon, 20 Jan 2025 15:35:08 -0600 Subject: [PATCH] remove activity router, replace with memory/recent --- .gitignore | 3 +- pyproject.toml | 3 +- src/basic_memory/api/app.py | 3 +- src/basic_memory/api/routers/__init__.py | 3 +- .../api/routers/activity_router.py | 47 ------ src/basic_memory/api/routers/memory_router.py | 115 +++++++------ src/basic_memory/deps.py | 15 -- src/basic_memory/mcp/tools/__init__.py | 12 +- src/basic_memory/mcp/tools/activity.py | 42 ----- src/basic_memory/mcp/tools/memory.py | 109 ++++++++++-- .../repository/search_repository.py | 13 +- src/basic_memory/schemas/activity.py | 85 ---------- src/basic_memory/schemas/base.py | 33 ++++ src/basic_memory/schemas/memory.py | 6 +- src/basic_memory/services/activity_service.py | 157 ------------------ src/basic_memory/services/context_service.py | 36 ++-- tests/api/test_activity_router.py | 45 ----- tests/conftest.py | 6 - tests/mcp/test_tool_activity.py | 0 tests/mcp/test_tool_memory.py | 68 +++++++- tests/schemas/test_activity_schemas.py | 95 ----------- tests/schemas/test_schemas.py | 36 +++- tests/services/test_search_service.py | 2 +- uv.lock | 78 ++++++++- 24 files changed, 421 insertions(+), 591 deletions(-) delete mode 100644 src/basic_memory/api/routers/activity_router.py delete mode 100644 src/basic_memory/mcp/tools/activity.py delete mode 100644 src/basic_memory/schemas/activity.py delete mode 100644 src/basic_memory/services/activity_service.py delete mode 100644 tests/api/test_activity_router.py delete mode 100644 tests/mcp/test_tool_activity.py delete mode 100644 tests/schemas/test_activity_schemas.py diff --git a/.gitignore b/.gitignore index 7d0883ef..5669fa6f 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,5 @@ projects/*.db-journal **/.DS_Store -*.log \ No newline at end of file +*.log +/.coverage.* diff --git a/pyproject.toml b/pyproject.toml index 731142c1..a6be69aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "greenlet>=3.1.1", "pydantic[email,timezone]>=2.10.3", "icecream>=2.1.3", - "mcp>=1.1.0", + "mcp>=1.2.0", "pydantic-settings>=2.6.1", "loguru>=0.7.3", "pyright>=1.1.390", @@ -22,6 +22,7 @@ dependencies = [ "python-frontmatter>=1.1.0", "rich>=13.9.4", "unidecode>=1.3.8", + "dateparser>=1.2.0", ] [project.optional-dependencies] diff --git a/src/basic_memory/api/app.py b/src/basic_memory/api/app.py index d6e5aec3..01cbe2e0 100644 --- a/src/basic_memory/api/app.py +++ b/src/basic_memory/api/app.py @@ -6,7 +6,7 @@ from fastapi import FastAPI from loguru import logger from basic_memory import db -from .routers import knowledge, discovery, search, activity, memory, resource +from .routers import knowledge, discovery, search, memory, resource @asynccontextmanager @@ -29,7 +29,6 @@ app = FastAPI( # Include routers app.include_router(knowledge.router) app.include_router(discovery.router) -app.include_router(activity.router) app.include_router(search.router) app.include_router(memory.router) app.include_router(resource.router) \ No newline at end of file diff --git a/src/basic_memory/api/routers/__init__.py b/src/basic_memory/api/routers/__init__.py index 273fc811..76cb837d 100644 --- a/src/basic_memory/api/routers/__init__.py +++ b/src/basic_memory/api/routers/__init__.py @@ -4,7 +4,6 @@ from . import knowledge_router as knowledge from . import discovery_router as discovery from . import memory_router as memory from . import resource_router as resource -from . import activity_router as activity from . import search_router as search -__all__ = ["knowledge", "discovery", "memory", "resource", "activity", "search"] +__all__ = ["knowledge", "discovery", "memory", "resource", "search"] diff --git a/src/basic_memory/api/routers/activity_router.py b/src/basic_memory/api/routers/activity_router.py deleted file mode 100644 index 2e67114f..00000000 --- a/src/basic_memory/api/routers/activity_router.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Activity router for tracking recent changes.""" - -from typing import List, Optional - -from fastapi import APIRouter, Depends, Query -from loguru import logger - -from basic_memory.deps import get_activity_service -from basic_memory.services.activity_service import ActivityService -from basic_memory.schemas.activity import RecentActivity, ActivityType - - -router = APIRouter( - prefix="/activity", - tags=["activity"] -) - - -@router.get( - "/recent", - response_model=RecentActivity, - summary="Get recent activity" -) -async def get_recent_activity( - activity_service: ActivityService = Depends(get_activity_service), - timeframe: str = "1d", - activity_types: Optional[List[ActivityType]] = Query(None), -) -> RecentActivity: - """ - Get recent activity across the knowledge base. - - Args: - timeframe: Time window to look back (1h, 1d, 1w, 1m) - activity_types: Optional list of ActivityType values to include - - Returns: - RecentActivity with changes and summary - """ - logger.debug( - f"Getting recent activity (timeframe={timeframe}, " - f"types={activity_types})" - ) - - return await activity_service.get_recent_activity( - timeframe=timeframe, - activity_types=[t.value for t in activity_types] if activity_types else None, - ) \ No newline at end of file diff --git a/src/basic_memory/api/routers/memory_router.py b/src/basic_memory/api/routers/memory_router.py index ce8c1299..aebfbc94 100644 --- a/src/basic_memory/api/routers/memory_router.py +++ b/src/basic_memory/api/routers/memory_router.py @@ -1,14 +1,17 @@ """Routes for memory:// URI operations.""" from datetime import datetime, timedelta -from typing import Optional +from typing import Optional, List, Annotated -from fastapi import APIRouter +from dateparser import parse +from fastapi import APIRouter, Query from loguru import logger from basic_memory.config import config from basic_memory.deps import ContextServiceDep, EntityRepositoryDep +from basic_memory.repository import EntityRepository from basic_memory.repository.search_repository import SearchIndexRow +from basic_memory.schemas.base import TimeFrame from basic_memory.schemas.memory import ( MemoryUrl, GraphContext, @@ -23,49 +26,8 @@ from basic_memory.services.context_service import ContextResultRow router = APIRouter(prefix="/memory", tags=["memory"]) -def parse_timeframe(timeframe: str) -> Optional[datetime]: - """Convert timeframe string to datetime. - - Formats: - - 7d: 7 days ago - - 30d: 30 days ago - - None: no time limit - """ - if not timeframe: - return None - - if not timeframe.endswith("d"): - raise ValueError("Timeframe must be in days (e.g., '7d')") - - days = int(timeframe[:-1]) - return datetime.utcnow() - timedelta(days=days) - - -@router.get("/{uri:path}", response_model=GraphContext) -async def get_memory_context( - context_service: ContextServiceDep, - entity_repository: EntityRepositoryDep, - uri: str, - depth: int = 1, - timeframe: str = "7d", - max_results: int = 10, -) -> GraphContext: - """Get rich context from memory:// URI.""" - # add the project name from the config to the url as the "host - # Parse URI - logger.debug( - f"Getting context for URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` max_results: `{max_results}`" - ) - memory_url = MemoryUrl(f"memory://{config.project}/{uri}") - - # Parse timeframe - since = parse_timeframe(timeframe) - - # Build context - context = await context_service.build_context( - memory_url, depth=depth, since=since, max_results=max_results - ) +async def to_graph_context(context, entity_repository: EntityRepository): # return results async def to_summary(item: SearchIndexRow | ContextResultRow): match item.type: @@ -81,10 +43,9 @@ async def get_memory_context( category=item.category, content=item.content, permalink=item.permalink ) case SearchItemType.RELATION: - from_entity = await entity_repository.find_by_id(item.from_id) to_entity = await entity_repository.find_by_id(item.to_id) - + return RelationSummary( permalink=item.permalink, type=item.type, @@ -96,9 +57,69 @@ async def get_memory_context( primary_results = [await to_summary(r) for r in context["primary_results"]] related_results = [await to_summary(r) for r in context["related_results"]] metadata = MemoryMetadata.model_validate(context["metadata"]) - # Transform to GraphContext return GraphContext( primary_results=primary_results, related_results=related_results, metadata=metadata ) + + +@router.get("/recent", response_model=GraphContext) +async def recent( + context_service: ContextServiceDep, + entity_repository: EntityRepositoryDep, + types: Annotated[list[SearchItemType] | None, Query()] = None, + depth: int = 1, + timeframe: TimeFrame = "7d", + max_results: int = 10, +) -> GraphContext: + # return all types by default + types = ( + [SearchItemType.ENTITY, SearchItemType.RELATION, SearchItemType.OBSERVATION] + if not types + else types + ) + + logger.debug( + f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` max_results: `{max_results}`" + ) + # Parse timeframe + since = parse(timeframe) + + # Build context + context = await context_service.build_context( + types=types, depth=depth, since=since, max_results=max_results + ) + return await to_graph_context(context, entity_repository=entity_repository) + + +# get_memory_context needs to be declared last so other paths can match + +@router.get("/{uri:path}", response_model=GraphContext) +async def get_memory_context( + context_service: ContextServiceDep, + entity_repository: EntityRepositoryDep, + uri: str, + depth: int = 1, + timeframe: TimeFrame = "7d", + max_results: int = 10, +) -> GraphContext: + """Get rich context from memory:// URI.""" + # add the project name from the config to the url as the "host + # Parse URI + logger.debug( + f"Getting context for URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` max_results: `{max_results}`" + ) + memory_url = MemoryUrl(f"memory://{config.project}/{uri}") + + # Parse timeframe + since = parse(timeframe) + + # Build context + context = await context_service.build_context( + memory_url, depth=depth, since=since, max_results=max_results + ) + return await to_graph_context(context, entity_repository=entity_repository) + + + diff --git a/src/basic_memory/deps.py b/src/basic_memory/deps.py index 18860a43..4937a2f8 100644 --- a/src/basic_memory/deps.py +++ b/src/basic_memory/deps.py @@ -21,7 +21,6 @@ from basic_memory.services import ( ObservationService, RelationService, ) -from basic_memory.services.activity_service import ActivityService from basic_memory.services.context_service import ContextService from basic_memory.services.file_service import FileService from basic_memory.services.search_service import SearchService @@ -166,20 +165,6 @@ async def get_search_service( SearchServiceDep = Annotated[SearchService, Depends(get_search_service)] -async def get_activity_service( - entity_service: EntityServiceDep, - relation_service: RelationServiceDep, -) -> ActivityService: - """Create ActivityService with dependencies.""" - return ActivityService( - entity_service=entity_service, - relation_service=relation_service, - ) - - -ActivityServiceDep = Annotated[ActivityService, Depends(get_activity_service)] - - async def get_knowledge_writer() -> KnowledgeWriter: return KnowledgeWriter() diff --git a/src/basic_memory/mcp/tools/__init__.py b/src/basic_memory/mcp/tools/__init__.py index 36364733..9bcd177f 100644 --- a/src/basic_memory/mcp/tools/__init__.py +++ b/src/basic_memory/mcp/tools/__init__.py @@ -5,15 +5,8 @@ Basic Memory through the MCP protocol. Importing this module registers all tools with the MCP server. """ -from basic_memory.mcp.tools import activity # noqa: F401 - # Import tools to register them with MCP -from basic_memory.mcp.tools import knowledge # noqa: F401 -from basic_memory.mcp.tools import search # noqa: F401 -from basic_memory.mcp.tools.activity import ( - get_recent_activity, -) -from basic_memory.mcp.tools.memory import build_context +from basic_memory.mcp.tools.memory import build_context, recent_activity from basic_memory.mcp.tools.ai_edit import ai_edit # Export the tools @@ -40,10 +33,9 @@ __all__ = [ "get_entities", # Search tools "search", - # Activity tools - "get_recent_activity", # memory tools "build_context", + "recent_activity", # file edit "ai_edit", ] diff --git a/src/basic_memory/mcp/tools/activity.py b/src/basic_memory/mcp/tools/activity.py deleted file mode 100644 index 699f39ac..00000000 --- a/src/basic_memory/mcp/tools/activity.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Tools for tracking activity and changes in the knowledge base.""" - -from typing import List, Optional - -from loguru import logger -from mcp.server.fastmcp import Context - -from basic_memory.mcp.async_client import client -from basic_memory.mcp.server import mcp -from basic_memory.schemas.activity import ActivityType, RecentActivity - - -@mcp.tool( - description="Track recent changes to documents, entities, and relations", -) -async def get_recent_activity( - context: Context, - timeframe: str = "1d", - activity_types: Optional[List[ActivityType]] = None, -) -> RecentActivity: - """Track changes across the knowledge base. - - Args: - timeframe: Time window to analyze ("1h", "1d", "1w") - activity_types: Optional list of types to filter by - context: MCP context - - Returns: - RecentActivity object with changes and summary statistics - """ - context.info(f"Getting recent activity (timeframe={timeframe}, types={activity_types})") - - # Build params - params = { - "timeframe": timeframe, - } - if activity_types: - params["activity_types"] = [t.value for t in activity_types] - - # Get activity - response = await client.get("/activity/recent", params=params) - return RecentActivity.model_validate(response.json()) diff --git a/src/basic_memory/mcp/tools/memory.py b/src/basic_memory/mcp/tools/memory.py index f0b347e6..ffa6b51f 100644 --- a/src/basic_memory/mcp/tools/memory.py +++ b/src/basic_memory/mcp/tools/memory.py @@ -1,19 +1,34 @@ """Discussion context tools for Basic Memory MCP server.""" -from typing import Optional +from typing import Optional, List from loguru import logger from basic_memory.mcp.async_client import client from basic_memory.mcp.server import mcp +from basic_memory.mcp.tools.utils import call_get from basic_memory.schemas.memory import GraphContext, MemoryUrl +from basic_memory.schemas.search import SearchItemType +from basic_memory.schemas.base import TimeFrame @mcp.tool( - description="Build context from a memory:// URI to continue conversations naturally.", + description="""Build context from a memory:// URI to continue conversations naturally. + + Use this to follow up on previous discussions or explore related topics. + Timeframes use natural language support - examples: + - "2 days ago" + - "last week" + - "today" + - "3 months ago" + Or standard formats like "7d", "24h" + """, ) async def build_context( - url: MemoryUrl, depth: Optional[int] = 1, timeframe: Optional[str] = "7d", max_results: int = 10 + url: MemoryUrl, + depth: Optional[int] = 1, + timeframe: Optional[TimeFrame] = "7d", + max_results: int = 10, ) -> GraphContext: """Get context needed to continue a discussion. @@ -22,23 +37,97 @@ async def build_context( a rich context graph of related information. Args: - ctx: MCP context url: memory:// URI pointing to discussion content (e.g. memory://specs/search) - depth: How many relation hops to traverse (default: 2) - timeframe: How far back to look, e.g. "7d", "24h" (default: "7d") - max_results: The maximum number of results to return (default: 10) + depth: How many relation hops to traverse (1-3 recommended for performance) + timeframe: How far back to look. Supports natural language like "2 days ago", "last week" + max_results: Maximum number of results to return (default: 10) Returns: GraphContext containing: - - primary_results: Directly matched content + - primary_results: Content matching the memory:// URI - related_results: Connected content via relations - - metadata: Context building info + - metadata: Context building details + + Examples: + # Continue a specific discussion + build_context("memory://specs/search") + + # Get deeper context about a component + build_context("memory://components/memory-service", depth=2) + + # Look at recent changes to a specification + build_context("memory://specs/document-format", timeframe="today") + + # Research the history of a feature + build_context("memory://features/knowledge-graph", timeframe="3 months ago") """ logger.info(f"Building context from {url}") # Map directly to the memory endpoint memory_url = MemoryUrl.validate(url) - response = await client.get( + response = await call_get( + client, f"/memory/{memory_url.relative_path()}", params={"depth": depth, "timeframe": timeframe, "max_results": max_results}, ) return GraphContext.model_validate(response.json()) + + +@mcp.tool( + description="""Get recent activity from across the knowledge base. + + Timeframe supports natural language formats like: + - "2 days ago" + - "last week" + - "3 weeks" + - "2 months" + - "yesterday" + - "today" + Or standard formats like "7d", "24h" + """, +) +async def recent_activity( + types: List[SearchItemType] = None, + depth: Optional[int] = 1, + timeframe: Optional[TimeFrame] = "7d", + max_results: int = 10, +) -> GraphContext: + """Get recent activity across the knowledge base. + + Args: + types: Filter by entity types (["entity", "relation", "observation"]). If None, returns all types. + depth: How many relation hops to traverse when building context (1-3 recommended) + timeframe: How far back to look. Supports natural language like "2 days ago", "last week" + max_results: Maximum number of results to return (default: 10) + + Returns: + GraphContext containing: + - primary_results: Latest activities matching the filters + - related_results: Connected content via relations + - metadata: Query details and statistics + + Examples: + # Get all activity from last week + recent_activity(timeframe="last week") + + # Get only entity changes from yesterday + recent_activity(types=["entity"], timeframe="yesterday") + + # Track recent specification changes + recent_activity(types=["entity"], depth=2, timeframe="3 days ago") + + # Follow recent relation changes + recent_activity(types=["relation"], timeframe="today") + + Notes: + - Higher depth values (>3) may impact performance with large result sets + - For focused queries, consider using build_context with a specific URI + - Max timeframe is 1 year in the past + """ + logger.info( + f"Getting recent activity from {types}, depth={depth}, timeframe={timeframe}, max_results={max_results}" + ) + response = await client.get( + "/memory/recent", + params={"depth": depth, "timeframe": timeframe, "max_results": max_results, "types": types}, + ) + return GraphContext.model_validate(response.json()) diff --git a/src/basic_memory/repository/search_repository.py b/src/basic_memory/repository/search_repository.py index d0c7139f..76b59ce9 100644 --- a/src/basic_memory/repository/search_repository.py +++ b/src/basic_memory/repository/search_repository.py @@ -1,6 +1,7 @@ """Repository for search operations.""" import json +import time from dataclasses import dataclass from datetime import datetime from typing import List, Optional, Any, Dict @@ -95,6 +96,7 @@ class SearchRepository: types: List[SearchItemType] = None, after_date: datetime = None, entity_types: List[str] = None, + limit: int = 10, ) -> List[SearchIndexRow]: """Search across all indexed content with fuzzy matching.""" conditions = [] @@ -131,6 +133,9 @@ class SearchRepository: params["after_date"] = after_date conditions.append("datetime(created_at) > datetime(:after_date)") + # set limit on search query + params["limit"] = limit + # Build WHERE clause where_clause = " AND ".join(conditions) if conditions else "1=1" @@ -154,6 +159,7 @@ class SearchRepository: FROM search_index WHERE {where_clause} ORDER BY score ASC + LIMIT :limit """ #logger.debug(f"Search {sql} params: {params}") @@ -182,7 +188,7 @@ class SearchRepository: for row in rows ] - logger.debug(f"Search results: {results}") + #logger.debug(f"Search results: {results}") return results async def index_item( @@ -234,9 +240,12 @@ class SearchRepository: """Execute a query asynchronously.""" #logger.debug(f"Executing query: {query}") async with db.scoped_session(self.session_maker) as session: + start_time = time.perf_counter() if params: result = await session.execute(query, params) else: result = await session.execute(query) - logger.debug("Query executed successfully") + end_time = time.perf_counter() + elapsed_time = end_time - start_time + logger.debug(f"Query executed successfully in {elapsed_time:.2f}s.") return result \ No newline at end of file diff --git a/src/basic_memory/schemas/activity.py b/src/basic_memory/schemas/activity.py deleted file mode 100644 index 3f9df3d8..00000000 --- a/src/basic_memory/schemas/activity.py +++ /dev/null @@ -1,85 +0,0 @@ -from datetime import datetime, timedelta -from enum import Enum -from typing import List, Optional - -from pydantic import BaseModel, Field - - -class TimeFrame: - """Represents a time period for querying activity.""" - - def __init__(self, timeframe_str: str): - """Parse timeframe string (e.g., '1d', '2w', '1m')""" - if not timeframe_str or len(timeframe_str) < 2: - raise ValueError("Invalid timeframe format") - - try: - self.value = int(timeframe_str[:-1]) - self.unit = timeframe_str[-1] - except ValueError: - raise ValueError("Invalid timeframe format") - - if self.unit not in ["h", "d", "w", "m"]: - raise ValueError("Invalid timeframe unit") - - if self.value < 1: - raise ValueError("Timeframe value must be positive") - - @property - def to_timedelta(self) -> timedelta: - """Convert to Python timedelta.""" - if self.unit == "h": - return timedelta(hours=self.value) - elif self.unit == "d": - return timedelta(days=self.value) - elif self.unit == "w": - return timedelta(weeks=self.value) - elif self.unit == "m": - # Approximate month as 30 days - return timedelta(days=self.value * 30) - else: - raise ValueError(f"Invalid unit: {self.unit}") - - -class ActivityType(str, Enum): - """Types of activities that can be tracked.""" - - ENTITY = "entity" - RELATION = "relation" - - -class ChangeType(str, Enum): - """Types of changes that can occur.""" - - CREATED = "created" - UPDATED = "updated" - DELETED = "deleted" - - -class ActivityChange(BaseModel): - """Represents a single change in the system.""" - - activity_type: ActivityType - change_type: ChangeType - timestamp: datetime - permalink: str - summary: str - content: Optional[str] = None - - -class ActivitySummary(BaseModel): - """Summary statistics about recent activity.""" - - entity_changes: int = Field(default=0, description="Number of entity changes") - relation_changes: int = Field(default=0, description="Number of relation changes") - most_active_paths: List[str] = Field( - default_factory=list, description="List of most frequently changed paths" - ) - - -class RecentActivity(BaseModel): - """Complete activity report.""" - - timeframe: str - changes: List[ActivityChange] = Field(default_factory=list) - summary: ActivitySummary diff --git a/src/basic_memory/schemas/base.py b/src/basic_memory/schemas/base.py index a872f55c..ee52a15c 100644 --- a/src/basic_memory/schemas/base.py +++ b/src/basic_memory/schemas/base.py @@ -13,10 +13,13 @@ Key Concepts: import mimetypes import re +from datetime import datetime from enum import Enum from typing import List, Optional, Annotated, Dict from annotated_types import MinLen, MaxLen +from dateparser import parse + from pydantic import BaseModel, BeforeValidator, Field, model_validator, ValidationError from basic_memory.utils import generate_permalink @@ -81,6 +84,36 @@ class ObservationCategory(str, Enum): return None +def validate_timeframe(timeframe: str) -> str: + """Convert human readable timeframes to a duration relative to the current time.""" + if not isinstance(timeframe, str): + raise ValueError("Timeframe must be a string") + + # Parse relative time expression + parsed = parse(timeframe) + if not parsed: + raise ValueError(f"Could not parse timeframe: {timeframe}") + + # Convert to duration + now = datetime.now() + if parsed > now: + raise ValueError("Timeframe cannot be in the future") + + # Could format the duration back to our standard format + days = (now - parsed).days + + # Could enforce reasonable limits + if days > 365: + raise ValueError("Timeframe should be <= 1 year") + + return f"{days}d" + + +TimeFrame = Annotated[ + str, + BeforeValidator(validate_timeframe) +] + PathId = Annotated[str, BeforeValidator(validate_path_format)] """Unique identifier in format '{path}/{normalized_name}'.""" diff --git a/src/basic_memory/schemas/memory.py b/src/basic_memory/schemas/memory.py index 4c379c4a..a7d714f1 100644 --- a/src/basic_memory/schemas/memory.py +++ b/src/basic_memory/schemas/memory.py @@ -1,11 +1,12 @@ """Schemas for memory context.""" from datetime import datetime -from typing import Dict, List, Any +from typing import Dict, List, Any, Optional from pydantic import AnyUrl, Field, BaseModel from basic_memory.config import config +from basic_memory.schemas.search import SearchItemType """Memory URL schema for knowledge addressing. @@ -77,7 +78,8 @@ class ObservationSummary(BaseModel): class MemoryMetadata(BaseModel): """Simplified response metadata.""" - uri: str + uri: Optional[str] = None + types: Optional[List[SearchItemType]] = None depth: int timeframe: str generated_at: datetime diff --git a/src/basic_memory/services/activity_service.py b/src/basic_memory/services/activity_service.py deleted file mode 100644 index 09aa9f7a..00000000 --- a/src/basic_memory/services/activity_service.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Service for tracking and querying activity across the knowledge base.""" - -from datetime import datetime, timezone -from typing import List, Optional - -from . import EntityService, RelationService -from ..schemas.activity import ( - ActivityChange, - ActivitySummary, - ActivityType, - ChangeType, - RecentActivity, - TimeFrame, -) - - -class ActivityService: - """Service for tracking and querying activity across the knowledge base.""" - - def __init__( - self, - entity_service: EntityService, - relation_service: RelationService, - ): - """Initialize with required services.""" - self.entity_service = entity_service - self.relation_service = relation_service - - async def get_recent_activity( - self, - timeframe: str = "1d", - activity_types: Optional[List[str]] = None, - ) -> RecentActivity: - """Get all recent activity in the knowledge base. - - Args: - timeframe: Time window to look back (1h, 1d, 1w, 1m) - activity_types: Optional list of types to include - - Returns: - RecentActivity object containing changes and summary - """ - # Parse timeframe and get cutoff date - tf = TimeFrame(timeframe) - since = datetime.now(timezone.utc) - tf.to_timedelta - - # Get changes based on requested types - changes = [] - types_to_fetch = ( - [ActivityType(t) for t in activity_types] if activity_types else list(ActivityType) - ) - - for activity_type in types_to_fetch: - if activity_type == ActivityType.ENTITY: - changes.extend(await self._get_entity_changes(since)) - elif activity_type == ActivityType.RELATION: - changes.extend(await self._get_relation_changes(since)) - - # Sort all changes by timestamp, ensuring timezone awareness - for change in changes: - if change.timestamp.tzinfo is None: - change.timestamp = change.timestamp.replace(tzinfo=timezone.utc) - changes.sort(key=lambda x: x.timestamp, reverse=True) - - # Generate summary - summary = ActivitySummary( - entity_changes=len([c for c in changes if c.activity_type == ActivityType.ENTITY]), - relation_changes=len([c for c in changes if c.activity_type == ActivityType.RELATION]), - most_active_paths=self._get_most_active_paths(changes), - ) - - return RecentActivity(timeframe=timeframe, changes=changes, summary=summary) - - async def _get_entity_changes(self, since: datetime) -> List[ActivityChange]: - """Get recent entity changes.""" - # Query entities updated since the cutoff - entities = await self.entity_service.get_modified_since(since) - - changes = [] - for entity in entities: - # Ensure timestamps are timezone-aware - created_at = ( - entity.created_at.replace(tzinfo=timezone.utc) - if entity.created_at.tzinfo is None - else entity.created_at - ) - updated_at = ( - entity.updated_at.replace(tzinfo=timezone.utc) - if entity.updated_at.tzinfo is None - else entity.updated_at - ) - - change_type = ChangeType.CREATED if created_at >= since else ChangeType.UPDATED - - changes.append( - ActivityChange( - activity_type=ActivityType.ENTITY, - change_type=change_type, - timestamp=updated_at, - permalink=entity.permalink, - summary=f"{change_type.value.title()} entity: {entity.title}", - content=entity.summary, - ) - ) - - return changes - - async def _get_relation_changes(self, since: datetime) -> List[ActivityChange]: - """Get recent relation changes.""" - # Query relations updated since the cutoff - relations = await self.relation_service.get_modified_since(since) - - changes = [] - for relation in relations: - # Ensure timestamps are timezone-aware - created_at = ( - relation.created_at.replace(tzinfo=timezone.utc) - if relation.created_at.tzinfo is None - else relation.created_at - ) - updated_at = ( - relation.updated_at.replace(tzinfo=timezone.utc) - if relation.updated_at.tzinfo is None - else relation.updated_at - ) - - change_type = ChangeType.CREATED if created_at >= since else ChangeType.UPDATED - - changes.append( - ActivityChange( - activity_type=ActivityType.RELATION, - change_type=change_type, - timestamp=updated_at, - permalink=f"{relation.from_id}->{relation.to_id}", - summary=( - f"{change_type.value.title()} relation: " - f"{relation.from_id} {relation.relation_type} {relation.to_id}" - ), - content=relation.context, - ) - ) - - return changes - - def _get_most_active_paths(self, changes: List[ActivityChange], limit: int = 5) -> List[str]: - """Get the most frequently changed paths.""" - path_counts = {} - for change in changes: - path_counts[change.permalink] = path_counts.get(change.permalink, 0) + 1 - - # Sort by count descending and take top paths - sorted_paths = sorted( - path_counts.items(), - key=lambda x: (-x[1], x[0]), # Sort by count desc, then path asc - ) - - return [path for path, _ in sorted_paths[:limit]] diff --git a/src/basic_memory/services/context_service.py b/src/basic_memory/services/context_service.py index dd277d09..5bae8766 100644 --- a/src/basic_memory/services/context_service.py +++ b/src/basic_memory/services/context_service.py @@ -50,7 +50,8 @@ class ContextService: async def build_context( self, - memory_url: MemoryUrl, + memory_url: MemoryUrl = None, + types: List[SearchItemType] = None, depth: int = 1, since: Optional[datetime] = None, max_results: int = 10, @@ -60,22 +61,26 @@ class ContextService: f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' max_results: '{max_results}'" ) - # Pattern matching - use search - if "*" in memory_url.relative_path(): - logger.debug(f"Pattern search for '{memory_url.relative_path()}'") - primary = await self.search_repository.search( - permalink_match=memory_url.relative_path() - ) - - # Direct lookup for exact path + if memory_url: + # Pattern matching - use search + if "*" in memory_url.relative_path(): + logger.debug(f"Pattern search for '{memory_url.relative_path()}'") + primary = await self.search_repository.search( + permalink_match=memory_url.relative_path() + ) + + # Direct lookup for exact path + else: + logger.debug(f"Direct lookup for '{memory_url.relative_path()}'") + primary = await self.search_repository.search(permalink=memory_url.relative_path()) else: - logger.debug(f"Direct lookup for '{memory_url.relative_path()}'") - primary = await self.search_repository.search(permalink=memory_url.relative_path()) + logger.debug(f"Build context for '{types}'") + primary = await self.search_repository.search(types=types) # Get type_id pairs for traversal type_id_pairs = [(r.type, r.id) for r in primary] if primary else [] - logger.debug(f"primary type_id_pairs: {type_id_pairs}") + logger.debug(f"found primary type_id_pairs: {len(type_id_pairs)}") # Find related content related = await self.find_related( @@ -83,14 +88,15 @@ class ContextService: ) logger.debug(f"Found {len(related)} related results") for r in related: - logger.debug(f"Found related result: {r}") + logger.debug(f"Found related {r.type}: {r.permalink}") # Build response return { "primary_results": primary, "related_results": related, "metadata": { - "uri": memory_url.relative_path(), + "uri": memory_url.relative_path() if memory_url else None, + "types": types if types else None, "depth": depth, "timeframe": since.isoformat() if since else None, "generated_at": datetime.now(timezone.utc).isoformat(), @@ -117,7 +123,7 @@ class ContextService: if not type_id_pairs: return [] - logger.debug(f"Finding connected items for {type_id_pairs} with depth {max_depth}") + logger.debug(f"Finding connected items for {len(type_id_pairs)} with depth {max_depth}") # Build the VALUES clause directly since SQLite doesn't handle parameterized IN well values = ", ".join([f"('{t}', {i})" for t, i in type_id_pairs]) diff --git a/tests/api/test_activity_router.py b/tests/api/test_activity_router.py deleted file mode 100644 index c3508d8f..00000000 --- a/tests/api/test_activity_router.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Test activity router.""" - -import pytest -from httpx import AsyncClient - -from basic_memory.schemas.activity import ActivityType - - -@pytest.mark.anyio -async def test_get_recent_activity(client: AsyncClient): - """Test getting recent activity.""" - # Get initial activity - response = await client.get("/activity/recent") - assert response.status_code == 200 - - # Parse response - data = response.json() - assert "changes" in data - assert "summary" in data - assert "timeframe" in data - assert data["timeframe"] == "1d" # Default timeframe - - -@pytest.mark.anyio -async def test_get_recent_activity_with_filters(client: AsyncClient): - """Test getting recent activity with filters.""" - # Get activity with filters - response = await client.get( - "/activity/recent", - params={ - "timeframe": "1h", - "activity_types": [ActivityType.ENTITY.value], - "include_content": False - } - ) - assert response.status_code == 200 - - # Parse response - data = response.json() - assert data["timeframe"] == "1h" - - # Verify all changes are document type - for change in data["changes"]: - assert change["activity_type"] == ActivityType.ENTITY.value - assert change["content"] is None # Content excluded \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 125c8a55..37023d79 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,7 +24,6 @@ from basic_memory.services import ( ObservationService, RelationService, ) -from basic_memory.services.activity_service import ActivityService from basic_memory.services.file_service import FileService from basic_memory.services.link_resolver import LinkResolver from basic_memory.services.search_service import SearchService @@ -158,11 +157,6 @@ def file_change_scanner(entity_repository) -> FileChangeScanner: return FileChangeScanner(entity_repository) -@pytest_asyncio.fixture -async def activity_service(entity_service, relation_service): - """Create activity service with real dependencies.""" - return ActivityService(entity_service, relation_service) - @pytest_asyncio.fixture async def entity_sync_service( diff --git a/tests/mcp/test_tool_activity.py b/tests/mcp/test_tool_activity.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/mcp/test_tool_memory.py b/tests/mcp/test_tool_memory.py index 676efa48..f2558e71 100644 --- a/tests/mcp/test_tool_memory.py +++ b/tests/mcp/test_tool_memory.py @@ -1,10 +1,13 @@ """Tests for discussion context MCP tool.""" +import pytest from datetime import datetime -import pytest +from httpx import HTTPStatusError +from mcp.server.fastmcp.exceptions import ToolError -from basic_memory.mcp.tools.memory import build_context -from basic_memory.schemas.memory import GraphContext +from basic_memory.mcp.tools.memory import build_context, recent_activity +from basic_memory.schemas.base import TimeFrame +from basic_memory.schemas.memory import GraphContext, MemoryUrl @pytest.mark.asyncio @@ -61,3 +64,62 @@ async def test_get_discussion_context_not_found(client): assert isinstance(context, GraphContext) assert len(context.primary_results) == 0 assert len(context.related_results) == 0 + + +# Test data for different timeframe formats +valid_timeframes = [ + "7d", # Standard format + "yesterday", # Natural language + "0d", # Zero duration +] + +invalid_timeframes = [ + "invalid", # Nonsense string + "tomorrow", # Future date +] + + +@pytest.mark.asyncio +async def test_recent_activity_timeframe_formats(client, test_graph): + """Test that recent_activity accepts various timeframe formats.""" + # Test each valid timeframe + for timeframe in valid_timeframes: + try: + result = await recent_activity( + types=["entity"], + timeframe=timeframe, + max_results=1 + ) + assert result is not None + except Exception as e: + pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}") + + # Test invalid timeframes should raise ValidationError + for timeframe in invalid_timeframes: + with pytest.raises(ValueError): + await recent_activity(timeframe=timeframe) + + +@pytest.mark.asyncio +async def test_build_context_timeframe_formats(client, test_graph): + """Test that build_context accepts various timeframe formats.""" + test_url = MemoryUrl.validate("memory://specs/test") + + # Test each valid timeframe + for timeframe in valid_timeframes: + try: + result = await build_context( + url=test_url, + timeframe=timeframe, + max_results=1 + ) + assert result is not None + except Exception as e: + pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}") + + # Test invalid timeframes should raise ValidationError + for timeframe in invalid_timeframes: + with pytest.raises(ToolError): + await build_context(url=test_url, timeframe=timeframe) + + diff --git a/tests/schemas/test_activity_schemas.py b/tests/schemas/test_activity_schemas.py deleted file mode 100644 index cb6ff17f..00000000 --- a/tests/schemas/test_activity_schemas.py +++ /dev/null @@ -1,95 +0,0 @@ -from datetime import datetime, timedelta -import pytest - -from basic_memory.schemas.activity import ( - TimeFrame, - ActivityType, - ChangeType, - ActivityChange, - ActivitySummary, - RecentActivity, -) - - -def test_timeframe_parsing(): - """Test TimeFrame string parsing.""" - # Valid timeframes - assert TimeFrame("1h").value == 1 - assert TimeFrame("1h").unit == "h" - assert TimeFrame("24h").value == 24 - assert TimeFrame("7d").unit == "d" - assert TimeFrame("4w").unit == "w" - assert TimeFrame("2m").unit == "m" - - # Invalid timeframes - with pytest.raises(ValueError): - TimeFrame("") - with pytest.raises(ValueError): - TimeFrame("h") - with pytest.raises(ValueError): - TimeFrame("abc") - with pytest.raises(ValueError): - TimeFrame("1x") # Invalid unit - with pytest.raises(ValueError): - TimeFrame("-1h") # Negative value - - -def test_timeframe_to_timedelta(): - """Test conversion to timedelta.""" - assert TimeFrame("1h").to_timedelta == timedelta(hours=1) - assert TimeFrame("24h").to_timedelta == timedelta(hours=24) - assert TimeFrame("1d").to_timedelta == timedelta(days=1) - assert TimeFrame("1w").to_timedelta == timedelta(weeks=1) - assert TimeFrame("1m").to_timedelta == timedelta(days=30) # Approximate - - -def test_activity_change_model(): - """Test ActivityChange model.""" - now = datetime.utcnow() - change = ActivityChange( - activity_type=ActivityType.ENTITY, - change_type=ChangeType.CREATED, - timestamp=now, - permalink="test/path", - summary="Created test document", - content="Test content", - ) - - assert change.activity_type == ActivityType.ENTITY - assert change.change_type == ChangeType.CREATED - assert change.timestamp == now - assert change.permalink == "test/path" - assert change.summary == "Created test document" - assert change.content == "Test content" - - -def test_activity_summary_model(): - """Test ActivitySummary model.""" - summary = ActivitySummary( - entity_changes=3, relation_changes=2, most_active_paths=["path1", "path2"] - ) - - assert summary.entity_changes == 3 - assert summary.relation_changes == 2 - assert summary.most_active_paths == ["path1", "path2"] - - -def test_recent_activity_model(): - """Test RecentActivity model.""" - now = datetime.utcnow() - change = ActivityChange( - activity_type=ActivityType.ENTITY, - change_type=ChangeType.CREATED, - timestamp=now, - permalink="test/path", - summary="Created test document", - ) - - summary = ActivitySummary(entity_changes=1) - - activity = RecentActivity(timeframe="1d", changes=[change], summary=summary) - - assert activity.timeframe == "1d" - assert len(activity.changes) == 1 - assert activity.changes[0].permalink == "test/path" - assert activity.summary.entity_changes == 1 diff --git a/tests/schemas/test_schemas.py b/tests/schemas/test_schemas.py index 52b1bb91..391cd40c 100644 --- a/tests/schemas/test_schemas.py +++ b/tests/schemas/test_schemas.py @@ -1,7 +1,7 @@ """Tests for Pydantic schema validation and conversion.""" import pytest -from pydantic import ValidationError +from pydantic import ValidationError, BaseModel from basic_memory.schemas import ( Entity, @@ -12,7 +12,7 @@ from basic_memory.schemas import ( GetEntitiesRequest, RelationResponse, ) -from basic_memory.schemas.base import to_snake_case +from basic_memory.schemas.base import to_snake_case, TimeFrame def test_entity_in_minimal(): @@ -210,3 +210,35 @@ def test_permalink_generation(): for input_data, expected_path in test_cases: entity = Entity.model_validate(input_data) assert entity.permalink == expected_path, f"Failed for input: {input_data}" + + +@pytest.mark.parametrize( + "timeframe,expected_valid", + [ + ("7d", True), + ("yesterday", True), + ("2 days ago", True), + ("last week", True), + ("3 weeks ago", True), + ("invalid", False), + ("tomorrow", False), + ("next week", False), + ("", False), + ("0d", True), + ], +) +def test_timeframe_validation(timeframe: str, expected_valid: bool): + """Test TimeFrame validation directly.""" + + class TimeFrameModel(BaseModel): + timeframe: TimeFrame + + if expected_valid: + try: + tf = TimeFrameModel.model_validate({"timeframe": timeframe}) + assert isinstance(tf.timeframe, str) + except ValueError as e: + pytest.fail(f"TimeFrame failed to validate '{timeframe}' with error: {e}") + else: + with pytest.raises(ValueError): + tf = TimeFrameModel.model_validate({"timeframe": timeframe}) diff --git a/tests/services/test_search_service.py b/tests/services/test_search_service.py index 553bc6b3..73151a3c 100644 --- a/tests/services/test_search_service.py +++ b/tests/services/test_search_service.py @@ -34,7 +34,7 @@ async def test_search_permalink_relation_wildcard(search_service, test_graph): results = await search_service.search(SearchQuery(permalink_match="test/root/connects_to/*")) assert len(results) == 1 permalinks = {r.permalink for r in results} - assert "test/root/connects_to/test/connected1" in permalinks + assert "test/root/connects-to/test/connected1" in permalinks @pytest.mark.skip("search prefix see:'https://sqlite.org/fts5.html#FTS5 Prefix Queries'") diff --git a/uv.lock b/uv.lock index 8f71ea0c..37379d30 100644 --- a/uv.lock +++ b/uv.lock @@ -183,6 +183,7 @@ source = { editable = "." } dependencies = [ { name = "aiosqlite" }, { name = "basic-foundation" }, + { name = "dateparser" }, { name = "greenlet" }, { name = "icecream" }, { name = "loguru" }, @@ -217,11 +218,12 @@ dev = [ requires-dist = [ { name = "aiosqlite", specifier = ">=0.20.0" }, { name = "basic-foundation", editable = "../basic-foundation" }, + { name = "dateparser", specifier = ">=1.2.0" }, { name = "greenlet", specifier = ">=3.1.1" }, { name = "icecream", specifier = ">=2.1.3" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "markdown-it-py", specifier = ">=3.0.0" }, - { name = "mcp", specifier = ">=1.1.0" }, + { name = "mcp", specifier = ">=1.2.0" }, { name = "pydantic", extras = ["email", "timezone"], specifier = ">=2.10.3" }, { name = "pydantic-settings", specifier = ">=2.6.1" }, { name = "pyright", specifier = ">=1.1.390" }, @@ -509,6 +511,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/ec/bb273b7208c606890dc36540fe667d06ce840a6f62f9fae7e658fcdc90fb/cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1", size = 385747 }, ] +[[package]] +name = "dateparser" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "regex" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/b2/f6b29ab17d7959eb1a0a5c64f5011dc85051ad4e25e401cbddcc515db00f/dateparser-1.2.0.tar.gz", hash = "sha256:7975b43a4222283e0ae15be7b4999d08c9a70e2d378ac87385b1ccf2cffbbb30", size = 307260 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/29/db12aa4dda81580be1999824a689bd52aa40061fc12c9ccdc3feab5ea718/dateparser-1.2.0-py2.py3-none-any.whl", hash = "sha256:0b21ad96534e562920a0083e97fd45fa959882d4162acc358705144520a35830", size = 294995 }, +] + [[package]] name = "distlib" version = "0.3.9" @@ -1321,6 +1338,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 }, ] +[[package]] +name = "pytz" +version = "2024.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/31/3c70bf7603cc2dca0f19bdc53b4537a797747a58875b552c8c413d963a3f/pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a", size = 319692 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 }, +] + [[package]] name = "pywin32" version = "308" @@ -1372,6 +1398,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/3f/11dd4cd4f39e05128bfd20138faea57bec56f9ffba6185d276e3107ba5b2/questionary-2.1.0-py3-none-any.whl", hash = "sha256:44174d237b68bc828e4878c763a9ad6790ee61990e0ae72927694ead57bab8ec", size = 36747 }, ] +[[package]] +name = "regex" +version = "2024.11.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/5f/bd69653fbfb76cf8604468d3b4ec4c403197144c7bfe0e6a5fc9e02a07cb/regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", size = 399494 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/30/9a87ce8336b172cc232a0db89a3af97929d06c11ceaa19d97d84fa90a8f8/regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a", size = 483781 }, + { url = "https://files.pythonhosted.org/packages/01/e8/00008ad4ff4be8b1844786ba6636035f7ef926db5686e4c0f98093612add/regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9", size = 288455 }, + { url = "https://files.pythonhosted.org/packages/60/85/cebcc0aff603ea0a201667b203f13ba75d9fc8668fab917ac5b2de3967bc/regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2", size = 284759 }, + { url = "https://files.pythonhosted.org/packages/94/2b/701a4b0585cb05472a4da28ee28fdfe155f3638f5e1ec92306d924e5faf0/regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4", size = 794976 }, + { url = "https://files.pythonhosted.org/packages/4b/bf/fa87e563bf5fee75db8915f7352e1887b1249126a1be4813837f5dbec965/regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577", size = 833077 }, + { url = "https://files.pythonhosted.org/packages/a1/56/7295e6bad94b047f4d0834e4779491b81216583c00c288252ef625c01d23/regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3", size = 823160 }, + { url = "https://files.pythonhosted.org/packages/fb/13/e3b075031a738c9598c51cfbc4c7879e26729c53aa9cca59211c44235314/regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e", size = 796896 }, + { url = "https://files.pythonhosted.org/packages/24/56/0b3f1b66d592be6efec23a795b37732682520b47c53da5a32c33ed7d84e3/regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe", size = 783997 }, + { url = "https://files.pythonhosted.org/packages/f9/a1/eb378dada8b91c0e4c5f08ffb56f25fcae47bf52ad18f9b2f33b83e6d498/regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e", size = 781725 }, + { url = "https://files.pythonhosted.org/packages/83/f2/033e7dec0cfd6dda93390089864732a3409246ffe8b042e9554afa9bff4e/regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29", size = 789481 }, + { url = "https://files.pythonhosted.org/packages/83/23/15d4552ea28990a74e7696780c438aadd73a20318c47e527b47a4a5a596d/regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39", size = 852896 }, + { url = "https://files.pythonhosted.org/packages/e3/39/ed4416bc90deedbfdada2568b2cb0bc1fdb98efe11f5378d9892b2a88f8f/regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51", size = 860138 }, + { url = "https://files.pythonhosted.org/packages/93/2d/dd56bb76bd8e95bbce684326302f287455b56242a4f9c61f1bc76e28360e/regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad", size = 787692 }, + { url = "https://files.pythonhosted.org/packages/0b/55/31877a249ab7a5156758246b9c59539abbeba22461b7d8adc9e8475ff73e/regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54", size = 262135 }, + { url = "https://files.pythonhosted.org/packages/38/ec/ad2d7de49a600cdb8dd78434a1aeffe28b9d6fc42eb36afab4a27ad23384/regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b", size = 273567 }, + { url = "https://files.pythonhosted.org/packages/90/73/bcb0e36614601016552fa9344544a3a2ae1809dc1401b100eab02e772e1f/regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84", size = 483525 }, + { url = "https://files.pythonhosted.org/packages/0f/3f/f1a082a46b31e25291d830b369b6b0c5576a6f7fb89d3053a354c24b8a83/regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4", size = 288324 }, + { url = "https://files.pythonhosted.org/packages/09/c9/4e68181a4a652fb3ef5099e077faf4fd2a694ea6e0f806a7737aff9e758a/regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0", size = 284617 }, + { url = "https://files.pythonhosted.org/packages/fc/fd/37868b75eaf63843165f1d2122ca6cb94bfc0271e4428cf58c0616786dce/regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0", size = 795023 }, + { url = "https://files.pythonhosted.org/packages/c4/7c/d4cd9c528502a3dedb5c13c146e7a7a539a3853dc20209c8e75d9ba9d1b2/regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7", size = 833072 }, + { url = "https://files.pythonhosted.org/packages/4f/db/46f563a08f969159c5a0f0e722260568425363bea43bb7ae370becb66a67/regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7", size = 823130 }, + { url = "https://files.pythonhosted.org/packages/db/60/1eeca2074f5b87df394fccaa432ae3fc06c9c9bfa97c5051aed70e6e00c2/regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c", size = 796857 }, + { url = "https://files.pythonhosted.org/packages/10/db/ac718a08fcee981554d2f7bb8402f1faa7e868c1345c16ab1ebec54b0d7b/regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3", size = 784006 }, + { url = "https://files.pythonhosted.org/packages/c2/41/7da3fe70216cea93144bf12da2b87367590bcf07db97604edeea55dac9ad/regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07", size = 781650 }, + { url = "https://files.pythonhosted.org/packages/a7/d5/880921ee4eec393a4752e6ab9f0fe28009435417c3102fc413f3fe81c4e5/regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e", size = 789545 }, + { url = "https://files.pythonhosted.org/packages/dc/96/53770115e507081122beca8899ab7f5ae28ae790bfcc82b5e38976df6a77/regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6", size = 853045 }, + { url = "https://files.pythonhosted.org/packages/31/d3/1372add5251cc2d44b451bd94f43b2ec78e15a6e82bff6a290ef9fd8f00a/regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4", size = 860182 }, + { url = "https://files.pythonhosted.org/packages/ed/e3/c446a64984ea9f69982ba1a69d4658d5014bc7a0ea468a07e1a1265db6e2/regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d", size = 787733 }, + { url = "https://files.pythonhosted.org/packages/2b/f1/e40c8373e3480e4f29f2692bd21b3e05f296d3afebc7e5dcf21b9756ca1c/regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff", size = 262122 }, + { url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545 }, +] + [[package]] name = "requests" version = "2.32.3" @@ -1570,6 +1634,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586 }, ] +[[package]] +name = "tzlocal" +version = "5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/d3/c19d65ae67636fe63953b20c2e4a8ced4497ea232c43ff8d01db16de8dc0/tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e", size = 30201 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/3f/c4c51c55ff8487f2e6d0e618dba917e3c3ee2caae6cf0fbb59c9b1876f2e/tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8", size = 17859 }, +] + [[package]] name = "unidecode" version = "1.3.8"