Compare commits

...

18 Commits

Author SHA1 Message Date
semantic-release 6b8cefcd45 chore(release): 0.7.0 [skip ci] 2025-02-19 14:27:20 +00:00
phernandez 57984aa912 chore: fix tests 2025-02-18 23:48:22 -06:00
phernandez f5a7541da1 feat: add cli commands for mcp tools 2025-02-18 23:23:09 -06:00
phernandez bc9ca0744f fix: search query pagination params 2025-02-18 21:29:11 -06:00
phernandez 2c8ed1737d chore: remove unused tests 2025-02-18 20:45:47 -06:00
phernandez 02f8e86692 feat: add pagination to read_notes 2025-02-18 20:42:19 -06:00
phernandez 0123544556 feat: add pagination to build_context and recent_activity 2025-02-18 20:22:35 -06:00
phernandez 00d23a5ee1 fix: add logfire spans to cli 2025-02-18 19:56:20 -06:00
phernandez 812136c8c2 fix: add logfire spans to cli 2025-02-18 19:08:46 -06:00
phernandez 3e8e3e8961 fix: add logfire instrumentation to tools 2025-02-18 18:52:09 -06:00
phernandez 8544bb7966 Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-02-18 16:19:20 -06:00
phernandez 66b57e682f chore: re-add sync status console on watch 2025-02-18 16:19:14 -06:00
semantic-release 58a1296111 chore(release): 0.6.0 [skip ci] 2025-02-18 22:09:14 +00:00
Paul Hernandez 6da143898b feat: configure logfire telemetry (#12)
Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-18 15:53:38 -06:00
semantic-release a6b46908b1 chore(release): 0.5.0 [skip ci] 2025-02-18 05:34:50 +00:00
Paul Hernandez 0689e7a730 feat: return semantic info in markdown after write_note (#11)
Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-17 23:33:11 -06:00
semantic-release 16466e9269 chore(release): 0.4.3 [skip ci] 2025-02-18 00:49:28 +00:00
Paul Hernandez 39bd5ca08f fix: Re do enhanced read note format (#10)
Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-17 18:47:59 -06:00
57 changed files with 2373 additions and 654 deletions
+72
View File
@@ -1,6 +1,78 @@
# CHANGELOG
## v0.7.0 (2025-02-19)
### Bug Fixes
- Add logfire instrumentation to tools
([`3e8e3e8`](https://github.com/basicmachines-co/basic-memory/commit/3e8e3e8961eae2e82839746e28963191b0aef0a0))
- Add logfire spans to cli
([`00d23a5`](https://github.com/basicmachines-co/basic-memory/commit/00d23a5ee15ddac4ea45e702dcd02ab9f0509276))
- Add logfire spans to cli
([`812136c`](https://github.com/basicmachines-co/basic-memory/commit/812136c8c22ad191d14ff32dcad91aae076d4120))
- Search query pagination params
([`bc9ca07`](https://github.com/basicmachines-co/basic-memory/commit/bc9ca0744ffe4296d7d597b4dd9b7c73c2d63f3f))
### Chores
- Fix tests
([`57984aa`](https://github.com/basicmachines-co/basic-memory/commit/57984aa912625dcde7877afb96d874c164af2896))
- Remove unused tests
([`2c8ed17`](https://github.com/basicmachines-co/basic-memory/commit/2c8ed1737d6769fe1ef5c96f8a2bd75b9899316a))
### Features
- Add cli commands for mcp tools
([`f5a7541`](https://github.com/basicmachines-co/basic-memory/commit/f5a7541da17e97403b7a702720a05710f68b223a))
- Add pagination to build_context and recent_activity
([`0123544`](https://github.com/basicmachines-co/basic-memory/commit/0123544556513af943d399d70b849b142b834b15))
- Add pagination to read_notes
([`02f8e86`](https://github.com/basicmachines-co/basic-memory/commit/02f8e866923d5793d2620076c709c920d99f2c4f))
## v0.6.0 (2025-02-18)
### Chores
- Re-add sync status console on watch
([`66b57e6`](https://github.com/basicmachines-co/basic-memory/commit/66b57e682f2e9c432bffd4af293b0d1db1d3469b))
### Features
- Configure logfire telemetry ([#12](https://github.com/basicmachines-co/basic-memory/pull/12),
[`6da1438`](https://github.com/basicmachines-co/basic-memory/commit/6da143898bd45cdab8db95b5f2b75810fbb741ba))
Co-authored-by: phernandez <phernandez@basicmachines.co>
## v0.5.0 (2025-02-18)
### Features
- Return semantic info in markdown after write_note
([#11](https://github.com/basicmachines-co/basic-memory/pull/11),
[`0689e7a`](https://github.com/basicmachines-co/basic-memory/commit/0689e7a730497827bf4e16156ae402ddc5949077))
Co-authored-by: phernandez <phernandez@basicmachines.co>
## v0.4.3 (2025-02-18)
### Bug Fixes
- Re do enhanced read note format ([#10](https://github.com/basicmachines-co/basic-memory/pull/10),
[`39bd5ca`](https://github.com/basicmachines-co/basic-memory/commit/39bd5ca08fd057220b95a8b5d82c5e73a1f5722b))
Co-authored-by: phernandez <phernandez@basicmachines.co>
## v0.4.2 (2025-02-17)
+4 -2
View File
@@ -1,10 +1,10 @@
.PHONY: install test lint clean format type-check installer-mac installer-win
.PHONY: install test lint clean format type-check installer-mac installer-win check
install:
pip install -e ".[dev]"
test:
pytest -p pytest_mock -v
uv run pytest -p pytest_mock -v
lint:
ruff check . --fix
@@ -41,3 +41,5 @@ installer-win:
update-deps:
uv lock f--upgrade
check: lint format type-check test
+8 -1
View File
@@ -49,7 +49,14 @@ def update_claude_config():
config = {"mcpServers": {}}
# Add/update basic-memory config
config["mcpServers"]["basic-memory"] = {"command": "uvx", "args": ["basic-memory", "mcp"]}
config["mcpServers"]["basic-memory"] = {
"command": "uvx",
"args": ["basic-memory@latest", "mcp"],
"env": {
"BASIC_MEMORY_ENV": "user",
"LOGFIRE_TOKEN": "n2Fpvn34LjKYq8TdF1ZrXMgdBPXGn4HfXy6tYghZ55dB",
},
}
# Write back config
config_path.write_text(json.dumps(config, indent=2))
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "basic-memory"
version = "0.4.2"
version = "0.7.0"
description = "Local-first knowledge management combining Zettelkasten with knowledge graphs"
readme = "README.md"
requires-python = ">=3.12.1"
@@ -29,6 +29,7 @@ dependencies = [
"fastapi[standard]>=0.115.8",
"alembic>=1.14.1",
"qasync>=0.27.1",
"logfire[fastapi,httpx,sqlalchemy,sqlite3]>=3.6.0",
]
+1 -1
View File
@@ -1,3 +1,3 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
__version__ = "0.4.2"
__version__ = "0.7.0"
+7
View File
@@ -2,6 +2,7 @@
from contextlib import asynccontextmanager
import logfire
from fastapi import FastAPI, HTTPException
from fastapi.exception_handlers import http_exception_handler
from loguru import logger
@@ -10,11 +11,13 @@ import basic_memory
from basic_memory import db
from basic_memory.config import config as app_config
from basic_memory.api.routers import knowledge, search, memory, resource
from basic_memory.utils import setup_logging
@asynccontextmanager
async def lifespan(app: FastAPI): # pragma: no cover
"""Lifecycle manager for the FastAPI app."""
setup_logging(log_file=".basic-memory/basic-memory.log")
logger.info(f"Starting Basic Memory API {basic_memory.__version__}")
await db.run_migrations(app_config)
yield
@@ -30,6 +33,10 @@ app = FastAPI(
lifespan=lifespan,
)
if app_config != "test":
logfire.instrument_fastapi(app)
# Include routers
app.include_router(knowledge.router)
app.include_router(search.router)
@@ -94,11 +94,8 @@ async def get_entity(
try:
entity = await entity_service.get_by_permalink(permalink)
result = EntityResponse.model_validate(entity)
logger.info(f"response: get_entity with result={result}")
return result
except EntityNotFoundError:
logger.error(f"Error: Entity with {permalink} not found")
raise HTTPException(status_code=404, detail=f"Entity with {permalink} not found")
@@ -114,8 +111,6 @@ async def get_entities(
result = EntityListResponse(
entities=[EntityResponse.model_validate(entity) for entity in entities]
)
logger.info(f"response: get_entities with result={result}")
return result
@@ -135,7 +130,6 @@ async def delete_entity(
entity = await link_resolver.resolve_link(identifier)
if entity is None:
logger.info("response: delete_entity with result=DeleteEntitiesResponse(deleted=False)")
return DeleteEntitiesResponse(deleted=False)
# Delete the entity
@@ -145,7 +139,6 @@ async def delete_entity(
background_tasks.add_task(search_service.delete_by_permalink, entity.permalink)
result = DeleteEntitiesResponse(deleted=deleted)
logger.info(f"response: delete_entity with result={result}")
return result
@@ -166,5 +159,4 @@ async def delete_entities(
background_tasks.add_task(search_service.delete_by_permalink, permalink)
result = DeleteEntitiesResponse(deleted=deleted)
logger.info(f"response: delete_entities with result={result}")
return result
+26 -10
View File
@@ -24,7 +24,7 @@ from basic_memory.services.context_service import ContextResultRow
router = APIRouter(prefix="/memory", tags=["memory"])
async def to_graph_context(context, entity_repository: EntityRepository):
async def to_graph_context(context, entity_repository: EntityRepository, page: int, page_size: int):
# return results
async def to_summary(item: SearchIndexRow | ContextResultRow):
match item.type:
@@ -66,7 +66,11 @@ async def to_graph_context(context, entity_repository: EntityRepository):
metadata = MemoryMetadata.model_validate(context["metadata"])
# Transform to GraphContext
return GraphContext(
primary_results=primary_results, related_results=related_results, metadata=metadata
primary_results=primary_results,
related_results=related_results,
metadata=metadata,
page=page,
page_size=page_size,
)
@@ -77,7 +81,9 @@ async def recent(
type: Annotated[list[SearchItemType] | None, Query()] = None,
depth: int = 1,
timeframe: TimeFrame = "7d",
max_results: int = 10,
page: int = 1,
page_size: int = 10,
max_related: int = 10,
) -> GraphContext:
# return all types by default
types = (
@@ -87,16 +93,20 @@ async def recent(
)
logger.debug(
f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` max_results: `{max_results}`"
f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
)
# Parse timeframe
since = parse(timeframe)
limit = page_size
offset = (page - 1) * page_size
# Build context
context = await context_service.build_context(
types=types, depth=depth, since=since, max_results=max_results
types=types, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
)
return await to_graph_context(
context, entity_repository=entity_repository, page=page, page_size=page_size
)
return await to_graph_context(context, entity_repository=entity_repository)
# get_memory_context needs to be declared last so other paths can match
@@ -109,21 +119,27 @@ async def get_memory_context(
uri: str,
depth: int = 1,
timeframe: TimeFrame = "7d",
max_results: int = 10,
page: int = 1,
page_size: int = 10,
max_related: 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}`"
f"Getting context for URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
)
memory_url = normalize_memory_url(uri)
# Parse timeframe
since = parse(timeframe)
limit = page_size
offset = (page - 1) * page_size
# Build context
context = await context_service.build_context(
memory_url, depth=depth, since=since, max_results=max_results
memory_url, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
)
return await to_graph_context(
context, entity_repository=entity_repository, page=page, page_size=page_size
)
return await to_graph_context(context, entity_repository=entity_repository)
+102 -12
View File
@@ -1,34 +1,124 @@
"""Routes for getting entity content."""
import tempfile
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse
from loguru import logger
from basic_memory.deps import ProjectConfigDep, LinkResolverDep
from basic_memory.deps import (
ProjectConfigDep,
LinkResolverDep,
SearchServiceDep,
EntityServiceDep,
FileServiceDep,
)
from basic_memory.repository.search_repository import SearchIndexRow
from basic_memory.schemas.memory import normalize_memory_url
from basic_memory.schemas.search import SearchQuery, SearchItemType
router = APIRouter(prefix="/resource", tags=["resources"])
def get_entity_ids(item: SearchIndexRow) -> set[int]:
match item.type:
case SearchItemType.ENTITY:
return {item.id}
case SearchItemType.OBSERVATION:
return {item.entity_id} # pyright: ignore [reportReturnType]
case SearchItemType.RELATION:
from_entity = item.from_id
to_entity = item.to_id # pyright: ignore [reportReturnType]
return {from_entity, to_entity} if to_entity else {from_entity} # pyright: ignore [reportReturnType]
case _: # pragma: no cover
raise ValueError(f"Unexpected type: {item.type}")
@router.get("/{identifier:path}")
async def get_resource_content(
config: ProjectConfigDep,
link_resolver: LinkResolverDep,
search_service: SearchServiceDep,
entity_service: EntityServiceDep,
file_service: FileServiceDep,
background_tasks: BackgroundTasks,
identifier: str,
page: int = 1,
page_size: int = 10,
) -> FileResponse:
"""Get resource content by identifier: name or permalink."""
logger.debug(f"Getting content for permalink: {identifier}")
logger.debug(f"Getting content for: {identifier}")
# Find entity by permalink
# Find single entity by permalink
entity = await link_resolver.resolve_link(identifier)
if not entity:
raise HTTPException(status_code=404, detail=f"Entity not found: {identifier}")
results = [entity] if entity else []
file_path = Path(f"{config.home}/{entity.file_path}")
if not file_path.exists():
raise HTTPException(
status_code=404,
detail=f"File not found: {file_path}",
# pagination for multiple results
limit = page_size
offset = (page - 1) * page_size
# search using the identifier as a permalink
if not results:
# if the identifier contains a wildcard, use GLOB search
query = (
SearchQuery(permalink_match=identifier)
if "*" in identifier
else SearchQuery(permalink=identifier)
)
return FileResponse(path=file_path)
search_results = await search_service.search(query, limit, offset)
if not search_results:
raise HTTPException(status_code=404, detail=f"Resource not found: {identifier}")
# get the deduplicated entities related to the search results
entity_ids = {id for result in search_results for id in get_entity_ids(result)}
results = await entity_service.get_entities_by_id(list(entity_ids))
# return single response
if len(results) == 1:
entity = results[0]
file_path = Path(f"{config.home}/{entity.file_path}")
if not file_path.exists():
raise HTTPException(
status_code=404,
detail=f"File not found: {file_path}",
)
return FileResponse(path=file_path)
# for multiple files, initialize a temporary file for writing the results
with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".md") as tmp_file:
temp_file_path = tmp_file.name
for result in results:
# Read content for each entity
content = await file_service.read_entity_content(result)
memory_url = normalize_memory_url(result.permalink)
modified_date = result.updated_at.isoformat()
assert result.checksum
checksum = result.checksum[:8]
# Prepare the delimited content
response_content = f"--- {memory_url} {modified_date} {checksum}\n"
response_content += f"\n{content}\n"
response_content += "\n"
# Write content directly to the temporary file in append mode
tmp_file.write(response_content)
# Ensure all content is written to disk
tmp_file.flush()
# Schedule the temporary file to be deleted after the response
background_tasks.add_task(cleanup_temp_file, temp_file_path)
# Return the file response
return FileResponse(path=temp_file_path)
def cleanup_temp_file(file_path: str):
"""Delete the temporary file."""
try:
Path(file_path).unlink() # Deletes the file
logger.debug(f"Temporary file deleted: {file_path}")
except Exception as e: # pragma: no cover
logger.error(f"Error deleting temporary file {file_path}: {e}")
+17 -9
View File
@@ -2,27 +2,35 @@
from dataclasses import asdict
from fastapi import APIRouter, Depends, BackgroundTasks
from fastapi import APIRouter, BackgroundTasks
from basic_memory.services.search_service import SearchService
from basic_memory.schemas.search import SearchQuery, SearchResult, SearchResponse
from basic_memory.deps import get_search_service
from basic_memory.deps import SearchServiceDep
router = APIRouter(prefix="/search", tags=["search"])
@router.post("/", response_model=SearchResponse)
async def search(query: SearchQuery, search_service: SearchService = Depends(get_search_service)):
async def search(
query: SearchQuery,
search_service: SearchServiceDep,
page: int = 1,
page_size: int = 10,
):
"""Search across all knowledge and documents."""
results = await search_service.search(query)
limit = page_size
offset = (page - 1) * page_size
results = await search_service.search(query, limit=limit, offset=offset)
search_results = [SearchResult.model_validate(asdict(r)) for r in results]
return SearchResponse(results=search_results)
return SearchResponse(
results=search_results,
current_page=page,
page_size=page_size,
)
@router.post("/reindex")
async def reindex(
background_tasks: BackgroundTasks, search_service: SearchService = Depends(get_search_service)
):
async def reindex(background_tasks: BackgroundTasks, search_service: SearchServiceDep):
"""Recreate and populate the search index."""
await search_service.reindex_all(background_tasks=background_tasks)
return {"status": "ok", "message": "Reindex initiated"}
+1 -1
View File
@@ -6,7 +6,7 @@ from basic_memory import db
from basic_memory.config import config
from basic_memory.utils import setup_logging
setup_logging(log_file=".basic-memory/basic-memory-cli.log") # pragma: no cover
setup_logging(log_file=".basic-memory/basic-memory-cli.log", console=False) # pragma: no cover
asyncio.run(db.run_migrations(config))
+11 -8
View File
@@ -1,6 +1,8 @@
"""Database management commands."""
import asyncio
import logfire
import typer
from loguru import logger
@@ -13,13 +15,14 @@ def reset(
reindex: bool = typer.Option(False, "--reindex", help="Rebuild indices from filesystem"),
): # pragma: no cover
"""Reset database (drop all tables and recreate)."""
if typer.confirm("This will delete all data. Are you sure?"):
logger.info("Resetting database...")
asyncio.run(migrations.reset_database())
with logfire.span("reset"): # pyright: ignore [reportGeneralTypeIssues]
if typer.confirm("This will delete all data in your db. Are you sure?"):
logger.info("Resetting database...")
asyncio.run(migrations.reset_database())
if reindex:
# Import and run sync
from basic_memory.cli.commands.sync import sync
if reindex:
# Import and run sync
from basic_memory.cli.commands.sync import sync
logger.info("Rebuilding search index from filesystem...")
sync(watch=False) # pyright: ignore
logger.info("Rebuilding search index from filesystem...")
sync(watch=False) # pyright: ignore
+37 -29
View File
@@ -6,6 +6,7 @@ from datetime import datetime
from pathlib import Path
from typing import Dict, Any, List, Annotated, Set, Optional
import logfire
import typer
from loguru import logger
from rich.console import Console
@@ -69,7 +70,11 @@ def traverse_messages(
def format_chat_markdown(
title: str, mapping: Dict[str, Any], root_id: Optional[str], created_at: float, modified_at: float
title: str,
mapping: Dict[str, Any],
root_id: Optional[str],
created_at: float,
modified_at: float,
) -> str:
"""Format chat as clean markdown."""
@@ -205,7 +210,7 @@ async def get_markdown_processor() -> MarkdownProcessor:
@import_app.command(name="chatgpt", help="Import conversations from ChatGPT JSON export.")
def import_chatgpt(
conversations_json: Annotated[
Path, typer.Option(..., help="Path to ChatGPT conversations.json file")
Path, typer.Argument(help="Path to ChatGPT conversations.json file")
] = Path("conversations.json"),
folder: Annotated[
str, typer.Option(help="The folder to place the files in.")
@@ -221,35 +226,38 @@ def import_chatgpt(
After importing, run 'basic-memory sync' to index the new files.
"""
try:
if conversations_json:
if not conversations_json.exists():
typer.echo(f"Error: File not found: {conversations_json}", err=True)
raise typer.Exit(1)
with logfire.span("import chatgpt"): # pyright: ignore [reportGeneralTypeIssues]
try:
if conversations_json:
if not conversations_json.exists():
typer.echo(f"Error: File not found: {conversations_json}", err=True)
raise typer.Exit(1)
# Get markdown processor
markdown_processor = asyncio.run(get_markdown_processor())
# Get markdown processor
markdown_processor = asyncio.run(get_markdown_processor())
# Process the file
base_path = config.home / folder
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
results = asyncio.run(
process_chatgpt_json(conversations_json, folder, markdown_processor)
)
# Show results
console.print(
Panel(
f"[green]Import complete![/green]\n\n"
f"Imported {results['conversations']} conversations\n"
f"Containing {results['messages']} messages",
expand=False,
# Process the file
base_path = config.home / folder
console.print(
f"\nImporting chats from {conversations_json}...writing to {base_path}"
)
results = asyncio.run(
process_chatgpt_json(conversations_json, folder, markdown_processor)
)
)
console.print("\nRun 'basic-memory sync' to index the new files.")
# Show results
console.print(
Panel(
f"[green]Import complete![/green]\n\n"
f"Imported {results['conversations']} conversations\n"
f"Containing {results['messages']} messages",
expand=False,
)
)
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
console.print("\nRun 'basic-memory sync' to index the new files.")
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
@@ -6,6 +6,7 @@ from datetime import datetime
from pathlib import Path
from typing import Dict, Any, List, Annotated
import logfire
import typer
from loguru import logger
from rich.console import Console
@@ -178,34 +179,35 @@ def import_claude(
After importing, run 'basic-memory sync' to index the new files.
"""
try:
if not conversations_json.exists():
typer.echo(f"Error: File not found: {conversations_json}", err=True)
raise typer.Exit(1)
with logfire.span("import claude conversations"): # pyright: ignore [reportGeneralTypeIssues]
try:
if not conversations_json.exists():
typer.echo(f"Error: File not found: {conversations_json}", err=True)
raise typer.Exit(1)
# Get markdown processor
markdown_processor = asyncio.run(get_markdown_processor())
# Get markdown processor
markdown_processor = asyncio.run(get_markdown_processor())
# Process the file
base_path = config.home / folder
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
results = asyncio.run(
process_conversations_json(conversations_json, base_path, markdown_processor)
)
# Show results
console.print(
Panel(
f"[green]Import complete![/green]\n\n"
f"Imported {results['conversations']} conversations\n"
f"Containing {results['messages']} messages",
expand=False,
# Process the file
base_path = config.home / folder
console.print(f"\nImporting chats from {conversations_json}...writing to {base_path}")
results = asyncio.run(
process_conversations_json(conversations_json, base_path, markdown_processor)
)
)
console.print("\nRun 'basic-memory sync' to index the new files.")
# Show results
console.print(
Panel(
f"[green]Import complete![/green]\n\n"
f"Imported {results['conversations']} conversations\n"
f"Containing {results['messages']} messages",
expand=False,
)
)
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
console.print("\nRun 'basic-memory sync' to index the new files.")
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
@@ -5,6 +5,7 @@ import json
from pathlib import Path
from typing import Dict, Any, Annotated, Optional
import logfire
import typer
from loguru import logger
from rich.console import Console
@@ -160,36 +161,36 @@ def import_projects(
After importing, run 'basic-memory sync' to index the new files.
"""
with logfire.span("import claude projects"): # pyright: ignore [reportGeneralTypeIssues]
try:
if projects_json:
if not projects_json.exists():
typer.echo(f"Error: File not found: {projects_json}", err=True)
raise typer.Exit(1)
try:
if projects_json:
if not projects_json.exists():
typer.echo(f"Error: File not found: {projects_json}", err=True)
raise typer.Exit(1)
# Get markdown processor
markdown_processor = asyncio.run(get_markdown_processor())
# Get markdown processor
markdown_processor = asyncio.run(get_markdown_processor())
# Process the file
base_path = config.home / base_folder if base_folder else config.home
console.print(f"\nImporting projects from {projects_json}...writing to {base_path}")
results = asyncio.run(
process_projects_json(projects_json, base_path, markdown_processor)
)
# Show results
console.print(
Panel(
f"[green]Import complete![/green]\n\n"
f"Imported {results['documents']} project documents\n"
f"Imported {results['prompts']} prompt templates",
expand=False,
# Process the file
base_path = config.home / base_folder if base_folder else config.home
console.print(f"\nImporting projects from {projects_json}...writing to {base_path}")
results = asyncio.run(
process_projects_json(projects_json, base_path, markdown_processor)
)
)
console.print("\nRun 'basic-memory sync' to index the new files.")
# Show results
console.print(
Panel(
f"[green]Import complete![/green]\n\n"
f"Imported {results['documents']} project documents\n"
f"Imported {results['prompts']} prompt templates",
expand=False,
)
)
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
console.print("\nRun 'basic-memory sync' to index the new files.")
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
@@ -5,6 +5,7 @@ import json
from pathlib import Path
from typing import Dict, Any, List, Annotated
import logfire
import typer
from loguru import logger
from rich.console import Console
@@ -113,32 +114,33 @@ def memory_json(
After importing, run 'basic-memory sync' to index the new files.
"""
if not json_path.exists():
typer.echo(f"Error: File not found: {json_path}", err=True)
raise typer.Exit(1)
with logfire.span("import memory_json"): # pyright: ignore [reportGeneralTypeIssues]
if not json_path.exists():
typer.echo(f"Error: File not found: {json_path}", err=True)
raise typer.Exit(1)
try:
# Get markdown processor
markdown_processor = asyncio.run(get_markdown_processor())
try:
# Get markdown processor
markdown_processor = asyncio.run(get_markdown_processor())
# Process the file
base_path = config.home
console.print(f"\nImporting from {json_path}...writing to {base_path}")
results = asyncio.run(process_memory_json(json_path, base_path, markdown_processor))
# Process the file
base_path = config.home
console.print(f"\nImporting from {json_path}...writing to {base_path}")
results = asyncio.run(process_memory_json(json_path, base_path, markdown_processor))
# Show results
console.print(
Panel(
f"[green]Import complete![/green]\n\n"
f"Created {results['entities']} entities\n"
f"Added {results['relations']} relations",
expand=False,
# Show results
console.print(
Panel(
f"[green]Import complete![/green]\n\n"
f"Created {results['entities']} entities\n"
f"Added {results['relations']} relations",
expand=False,
)
)
)
console.print("\nRun 'basic-memory sync' to index the new files.")
console.print("\nRun 'basic-memory sync' to index the new files.")
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
except Exception as e:
logger.error("Import failed")
typer.echo(f"Error during import: {e}", err=True)
raise typer.Exit(1)
+8 -6
View File
@@ -3,6 +3,7 @@
import asyncio
from typing import Set, Dict
import logfire
import typer
from loguru import logger
from rich.console import Console
@@ -146,9 +147,10 @@ def status(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
):
"""Show sync status between files and database."""
try:
sync_service = asyncio.run(get_file_change_scanner())
asyncio.run(run_status(sync_service, verbose)) # pragma: no cover
except Exception as e:
logger.exception(f"Error checking status: {e}")
raise typer.Exit(code=1) # pragma: no cover
with logfire.span("status"): # pyright: ignore [reportGeneralTypeIssues]
try:
sync_service = asyncio.run(get_file_change_scanner())
asyncio.run(run_status(sync_service, verbose)) # pragma: no cover
except Exception as e:
logger.exception(f"Error checking status: {e}")
raise typer.Exit(code=1) # pragma: no cover
+6 -3
View File
@@ -151,7 +151,7 @@ def display_detailed_sync_results(knowledge: SyncReport):
console.print(knowledge_tree)
async def run_sync(verbose: bool = False, watch: bool = False):
async def run_sync(verbose: bool = False, watch: bool = False, console_status: bool = False):
"""Run sync operation."""
sync_service = await get_sync_service()
@@ -164,7 +164,7 @@ async def run_sync(verbose: bool = False, watch: bool = False):
config=config,
)
await watch_service.handle_changes(config.home)
await watch_service.run() # pragma: no cover
await watch_service.run(console_status=console_status) # pragma: no cover
else:
# one time sync
knowledge_changes = await sync_service.sync(config.home)
@@ -189,11 +189,14 @@ def sync(
"-w",
help="Start watching for changes after sync.",
),
console_status: bool = typer.Option(
False, "--console-status", "-c", help="Show live console status"
),
) -> None:
"""Sync knowledge files with the database."""
try:
# Run sync
asyncio.run(run_sync(verbose=verbose, watch=watch))
asyncio.run(run_sync(verbose=verbose, watch=watch, console_status=console_status))
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
+157
View File
@@ -0,0 +1,157 @@
"""Database management commands."""
import asyncio
from typing import Optional, List, Annotated
import typer
from rich import print as rprint
from basic_memory.cli.app import app
from basic_memory.mcp.tools import build_context as mcp_build_context
from basic_memory.mcp.tools import get_entity as mcp_get_entity
from basic_memory.mcp.tools import read_note as mcp_read_note
from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
from basic_memory.mcp.tools import search as mcp_search
from basic_memory.mcp.tools import write_note as mcp_write_note
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import MemoryUrl
from basic_memory.schemas.search import SearchQuery
tool_app = typer.Typer()
app.add_typer(tool_app, name="tools", help="cli versions mcp tools")
@tool_app.command()
def write_note(
title: Annotated[str, typer.Option(help="The title of the note")],
content: Annotated[str, typer.Option(help="The content of the note")],
folder: Annotated[str, typer.Option(help="The folder to create the note in")],
tags: Annotated[
Optional[List[str]], typer.Option(help="A list of tags to apply to the note")
] = None,
):
try:
note = asyncio.run(mcp_write_note(title, content, folder, tags))
rprint(note)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during write_note: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command()
def read_note(identifier: str, page: int = 1, page_size: int = 10):
try:
note = asyncio.run(mcp_read_note(identifier, page, page_size))
rprint(note)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during read_note: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command()
def build_context(
url: MemoryUrl,
depth: Optional[int] = 1,
timeframe: Optional[TimeFrame] = "7d",
page: int = 1,
page_size: int = 10,
max_related: int = 10,
):
try:
context = asyncio.run(
mcp_build_context(
url=url,
depth=depth,
timeframe=timeframe,
page=page,
page_size=page_size,
max_related=max_related,
)
)
rprint(context.model_dump())
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during build_context: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command()
def recent_activity(
type: Annotated[Optional[List[str]], typer.Option()] = ["entity", "observation", "relation"],
depth: Optional[int] = 1,
timeframe: Optional[TimeFrame] = "7d",
page: int = 1,
page_size: int = 10,
max_related: int = 10,
):
assert type is not None, "type is required"
if any(t not in ["entity", "observation", "relation"] for t in type): # pragma: no cover
print("type must be one of ['entity', 'observation', 'relation']")
raise typer.Abort()
try:
context = asyncio.run(
mcp_recent_activity(
type=type, # pyright: ignore [reportArgumentType]
depth=depth,
timeframe=timeframe,
page=page,
page_size=page_size,
max_related=max_related,
)
)
rprint(context.model_dump())
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during build_context: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command()
def search(
query: str,
permalink: Annotated[bool, typer.Option("--permalink", help="Search permalink values")] = False,
title: Annotated[bool, typer.Option("--title", help="Search title values")] = False,
after_date: Annotated[
Optional[str],
typer.Option("--after_date", help="Search results after date, eg. '2d', '1 week'"),
] = None,
page: int = 1,
page_size: int = 10,
):
if permalink and title: # pragma: no cover
print("Cannot search both permalink and title")
raise typer.Abort()
try:
search_query = SearchQuery(
permalink_match=query if permalink else None,
text=query if query else None,
title=query if title else None,
after_date=after_date,
)
results = asyncio.run(mcp_search(query=search_query, page=page, page_size=page_size))
rprint(results.model_dump())
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during search: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command()
def get_entity(identifier: str):
try:
entity = asyncio.run(mcp_get_entity(identifier=identifier))
rprint(entity.model_dump())
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during get_entity: {e}", err=True)
raise typer.Exit(1)
raise
+1 -4
View File
@@ -1,7 +1,6 @@
"""Main CLI entry point for basic-memory.""" # pragma: no cover
from basic_memory.cli.app import app # pragma: no cover
from basic_memory.utils import setup_logging # pragma: no cover
# Register commands
from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
@@ -13,11 +12,9 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
import_claude_conversations,
import_claude_projects,
import_chatgpt,
tools,
)
# Set up logging when module is imported
setup_logging(log_file=".basic-memory/basic-memory-cli.log") # pragma: no cover
if __name__ == "__main__": # pragma: no cover
app()
+5
View File
@@ -1,6 +1,7 @@
"""Configuration management for basic-memory."""
from pathlib import Path
from typing import Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -8,10 +9,14 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
DATABASE_NAME = "memory.db"
DATA_DIR_NAME = ".basic-memory"
Environment = Literal["test", "dev", "user"]
class ProjectConfig(BaseSettings):
"""Configuration for a specific basic-memory project."""
env: Environment = Field(default="dev", description="Environment name")
# Default to ~/basic-memory but allow override with env var: BASIC_MEMORY_HOME
home: Path = Field(
default_factory=lambda: Path.home() / "basic-memory",
+9 -4
View File
@@ -4,6 +4,7 @@ from enum import Enum, auto
from pathlib import Path
from typing import AsyncGenerator, Optional
from basic_memory.config import ProjectConfig
from alembic import command
from alembic.config import Config
@@ -140,11 +141,15 @@ async def run_migrations(app_config: ProjectConfig, database_type=DatabaseType.F
# Set required Alembic config options programmatically
config.set_main_option("script_location", str(alembic_dir))
config.set_main_option("file_template",
"%%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s")
config.set_main_option(
"file_template",
"%%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s",
)
config.set_main_option("timezone", "UTC")
config.set_main_option("revision_environment", "false")
config.set_main_option("sqlalchemy.url", DatabaseType.get_db_url(app_config.database_path, database_type))
config.set_main_option(
"sqlalchemy.url", DatabaseType.get_db_url(app_config.database_path, database_type)
)
command.upgrade(config, "head")
logger.info("Migrations completed successfully")
@@ -153,4 +158,4 @@ async def run_migrations(app_config: ProjectConfig, database_type=DatabaseType.F
await SearchRepository(session_maker).init_search_index()
except Exception as e: # pragma: no cover
logger.error(f"Error running migrations: {e}")
raise
raise
+5 -1
View File
@@ -2,6 +2,7 @@
from typing import Annotated
import logfire
from fastapi import Depends
from sqlalchemy.ext.asyncio import (
AsyncSession,
@@ -43,7 +44,10 @@ async def get_engine_factory(
project_config: ProjectConfigDep,
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
"""Get engine and session maker."""
return await db.get_or_create_db(project_config.database_path)
engine, session_maker = await db.get_or_create_db(project_config.database_path)
if project_config.env != "test":
logfire.instrument_sqlalchemy(engine=engine)
return engine, session_maker
EngineFactoryDep = Annotated[
+26 -14
View File
@@ -1,8 +1,10 @@
"""Knowledge graph management tools for Basic Memory MCP server."""
import logfire
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get, call_post
from basic_memory.schemas.base import Permalink
from basic_memory.schemas.memory import memory_url_path
from basic_memory.schemas.request import (
GetEntitiesRequest,
)
@@ -16,15 +18,17 @@ from basic_memory.mcp.async_client import client
@mcp.tool(
description="Get complete information about a specific entity including observations and relations",
)
async def get_entity(permalink: Permalink) -> EntityResponse:
async def get_entity(identifier: str) -> EntityResponse:
"""Get a specific entity info by its permalink.
Args:
permalink: Path identifier for the entity
identifier: Path identifier for the entity
"""
url = f"/knowledge/entities/{permalink}"
response = await call_get(client, url)
return EntityResponse.model_validate(response.json())
with logfire.span("Getting entity", permalink=identifier): # pyright: ignore [reportGeneralTypeIssues]
permalink = memory_url_path(identifier)
url = f"/knowledge/entities/{permalink}"
response = await call_get(client, url)
return EntityResponse.model_validate(response.json())
@mcp.tool(
@@ -39,11 +43,16 @@ async def get_entities(request: GetEntitiesRequest) -> EntityListResponse:
Returns:
EntityListResponse containing complete details for each requested entity
"""
url = "/knowledge/entities"
response = await call_get(
client, url, params=[("permalink", permalink) for permalink in request.permalinks]
)
return EntityListResponse.model_validate(response.json())
with logfire.span("Getting multiple entities", permalink_count=len(request.permalinks)): # pyright: ignore [reportGeneralTypeIssues]
url = "/knowledge/entities"
response = await call_get(
client,
url,
params=[
("permalink", memory_url_path(identifier)) for identifier in request.permalinks
],
)
return EntityListResponse.model_validate(response.json())
@mcp.tool(
@@ -51,6 +60,9 @@ async def get_entities(request: GetEntitiesRequest) -> EntityListResponse:
)
async def delete_entities(request: DeleteEntitiesRequest) -> DeleteEntitiesResponse:
"""Delete entities from the knowledge graph."""
url = "/knowledge/entities/delete"
response = await call_post(client, url, json=request.model_dump())
return DeleteEntitiesResponse.model_validate(response.json())
with logfire.span("Deleting entities", permalink_count=len(request.permalinks)): # pyright: ignore [reportGeneralTypeIssues]
url = "/knowledge/entities/delete"
request.permalinks = [memory_url_path(permlink) for permlink in request.permalinks]
response = await call_post(client, url, json=request.model_dump())
return DeleteEntitiesResponse.model_validate(response.json())
+47 -28
View File
@@ -3,6 +3,7 @@
from typing import Optional, Literal, List
from loguru import logger
import logfire
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
@@ -32,7 +33,9 @@ async def build_context(
url: MemoryUrl,
depth: Optional[int] = 1,
timeframe: Optional[TimeFrame] = "7d",
max_results: int = 10,
page: int = 1,
page_size: int = 10,
max_related: int = 10,
) -> GraphContext:
"""Get context needed to continue a discussion.
@@ -44,7 +47,9 @@ async def build_context(
url: memory:// URI pointing to discussion content (e.g. memory://specs/search)
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)
page: Page number of results to return (default: 1)
page_size: Number of results to return per page (default: 10)
max_related: Maximum number of related results to return (default: 10)
Returns:
GraphContext containing:
@@ -65,14 +70,21 @@ async def build_context(
# Research the history of a feature
build_context("memory://features/knowledge-graph", timeframe="3 months ago")
"""
logger.info(f"Building context from {url}")
url = normalize_memory_url(url)
response = await call_get(
client,
f"/memory/{memory_url_path(url)}",
params={"depth": depth, "timeframe": timeframe, "max_results": max_results},
)
return GraphContext.model_validate(response.json())
with logfire.span("Building context", url=url, depth=depth, timeframe=timeframe): # pyright: ignore [reportGeneralTypeIssues]
logger.info(f"Building context from {url}")
url = normalize_memory_url(url)
response = await call_get(
client,
f"/memory/{memory_url_path(url)}",
params={
"depth": depth,
"timeframe": timeframe,
"page": page,
"page_size": page_size,
"max_related": max_related,
},
)
return GraphContext.model_validate(response.json())
@mcp.tool(
@@ -91,7 +103,9 @@ async def recent_activity(
type: List[Literal["entity", "observation", "relation"]] = [],
depth: Optional[int] = 1,
timeframe: Optional[TimeFrame] = "7d",
max_results: int = 10,
page: int = 1,
page_size: int = 10,
max_related: int = 10,
) -> GraphContext:
"""Get recent activity across the knowledge base.
@@ -106,7 +120,9 @@ async def recent_activity(
- Relative: "2 days ago", "last week", "yesterday"
- Points in time: "2024-01-01", "January 1st"
- Standard format: "7d", "24h"
max_results: Maximum number of results to return (default: 10)
page: Page number of results to return (default: 1)
page_size: Number of results to return per page (default: 10)
max_related: Maximum number of related results to return (default: 10)
Returns:
GraphContext containing:
@@ -132,20 +148,23 @@ async def recent_activity(
- 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 {type}, depth={depth}, timeframe={timeframe}, max_results={max_results}"
)
params = {
"depth": depth,
"timeframe": timeframe,
"max_results": max_results,
}
if type:
params["type"] = type
with logfire.span("Getting recent activity", type=type, depth=depth, timeframe=timeframe): # pyright: ignore [reportGeneralTypeIssues]
logger.info(
f"Getting recent activity from {type}, depth={depth}, timeframe={timeframe}, page={page}, page_size={page_size}, max_related={max_related}"
)
params = {
"depth": depth,
"timeframe": timeframe,
"page": page,
"page_size": page_size,
"max_related": max_related,
}
if type:
params["type"] = type
response = await call_get(
client,
"/memory/recent",
params=params,
)
return GraphContext.model_validate(response.json())
response = await call_get(
client,
"/memory/recent",
params=params,
)
return GraphContext.model_validate(response.json())
+135 -58
View File
@@ -7,6 +7,7 @@ while leveraging the underlying knowledge graph structure.
from typing import Optional, List
from loguru import logger
import logfire
from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import client
@@ -17,90 +18,165 @@ from basic_memory.schemas.memory import memory_url_path
@mcp.tool(
description="Create or update a markdown note. Returns the permalink for referencing.",
description="Create or update a markdown note. Returns a markdown formatted summary of the semantic content.",
)
async def write_note(
title: str,
content: str,
folder: str,
tags: Optional[List[str]] = None,
verbose: bool = False,
) -> EntityResponse | str:
) -> str:
"""Write a markdown note to the knowledge base.
The content can include semantic observations and relations using markdown syntax.
Relations can be specified either explicitly or through inline wiki-style links:
Observations format:
`- [category] Observation text #tag1 #tag2 (optional context)`
Examples:
`- [design] Files are the source of truth #architecture (All state comes from files)`
`- [tech] Using SQLite for storage #implementation`
`- [note] Need to add error handling #todo`
Relations format:
- Explicit: `- relation_type [[Entity]] (optional context)`
- Inline: Any `[[Entity]]` reference creates a relation
Examples:
`- depends_on [[Content Parser]] (Need for semantic extraction)`
`- implements [[Search Spec]] (Initial implementation)`
`- This feature extends [[Base Design]] and uses [[Core Utils]]`
Args:
title: The title of the note
content: Markdown content for the note
content: Markdown content for the note, can include observations and relations
folder: the folder where the file should be saved
tags: Optional list of tags to categorize the note
verbose: If True, returns full EntityResponse with semantic info
Returns:
If verbose=False: Permalink that can be used to reference the note
If verbose=True: EntityResponse with full semantic details
Examples:
# Create a simple note
write_note(
tile="Meeting Notes: Project Planning.md",
content="# Key Points\\n\\n- Discussed timeline\\n- Set priorities"
folder="notes"
)
# Create note with tags
write_note(
title="Security Review",
content="# Findings\\n\\n1. Updated auth flow\\n2. Added rate limiting",
folder="security",
tags=["security", "development"]
)
A markdown formatted summary of the semantic content, including:
- Creation/update status
- File path and checksum
- Observation counts by category
- Relation counts (resolved/unresolved)
- Tags if present
"""
logger.info(f"Writing note folder:'{folder}' title: '{title}'")
with logfire.span("Writing note", title=title, folder=folder): # pyright: ignore [reportGeneralTypeIssues]
logger.info(f"Writing note folder:'{folder}' title: '{title}'")
# Create the entity request
metadata = {"tags": [f"#{tag}" for tag in tags]} if tags else None
entity = Entity(
title=title,
folder=folder,
entity_type="note",
content_type="text/markdown",
content=content,
entity_metadata=metadata,
)
# Create the entity request
metadata = {"tags": [f"#{tag}" for tag in tags]} if tags else None
entity = Entity(
title=title,
folder=folder,
entity_type="note",
content_type="text/markdown",
content=content,
entity_metadata=metadata,
)
# Use existing knowledge tool
logger.info(f"Creating {entity.permalink}")
url = f"/knowledge/entities/{entity.permalink}"
response = await call_put(client, url, json=entity.model_dump())
result = EntityResponse.model_validate(response.json())
return result if verbose else result.permalink
# Create or update via knowledge API
logger.info(f"Creating {entity.permalink}")
url = f"/knowledge/entities/{entity.permalink}"
response = await call_put(client, url, json=entity.model_dump())
result = EntityResponse.model_validate(response.json())
# Format semantic summary based on status code
action = "Created" if response.status_code == 201 else "Updated"
assert result.checksum is not None
summary = [
f"# {action} {result.file_path} ({result.checksum[:8]})",
f"permalink: {result.permalink}",
]
if result.observations:
categories = {}
for obs in result.observations:
categories[obs.category] = categories.get(obs.category, 0) + 1
summary.append("\n## Observations")
for category, count in sorted(categories.items()):
summary.append(f"- {category}: {count}")
if result.relations:
unresolved = sum(1 for r in result.relations if not r.to_id)
resolved = len(result.relations) - unresolved
summary.append("\n## Relations")
summary.append(f"- Resolved: {resolved}")
if unresolved:
summary.append(f"- Unresolved: {unresolved}")
summary.append("\nUnresolved relations will be retried on next sync.")
if tags:
summary.append(f"\n## Tags\n- {', '.join(tags)}")
return "\n".join(summary)
@mcp.tool(description="Read a note's content by its title or permalink")
async def read_note(identifier: str) -> str:
"""Get the markdown content of a note.
Uses the resource router to return the actual file content.
@mcp.tool(description="Read note content by title, permalink, relation, or pattern")
async def read_note(identifier: str, page: int = 1, page_size: int = 10) -> str:
"""Get note content in unified diff format.
The content is returned in a unified diff inspired format:
```
--- memory://docs/example 2025-01-31T19:32:49 7d9f1c8b
<document content>
```
Multiple documents (from relations or pattern matches) are separated by
additional headers.
Args:
identifier: Note title or permalink
identifier: Can be one of:
- Note title ("Project Planning")
- Note permalink ("docs/example")
- Relation path ("docs/example/depends-on/other-doc")
- Pattern match ("docs/*-architecture")
page: the page number of results to return (default 1)
page_size: the number of results to return per page (default 10)
Returns:
The note's markdown content
Document content in unified diff format. For single documents, returns
just that document's content. For relations or pattern matches, returns
multiple documents separated by unified diff headers.
Examples:
# Read by title
read_note("Meeting Notes: Project Planning")
# Single document
content = await read_note("Project Planning")
# Read by permalink
read_note("notes/project-planning")
content = await read_note("docs/architecture/file-first")
Raises:
ValueError: If the note cannot be found
# Follow relation
content = await read_note("docs/architecture/depends-on/docs/content-parser")
# Pattern matching
content = await read_note("docs/*-architecture") # All architecture docs
content = await read_note("docs/*/implements/*") # Find implementations
Output format:
```
--- memory://docs/example 2025-01-31T19:32:49 7d9f1c8b
<first document content>
--- memory://docs/other 2025-01-30T15:45:22 a1b2c3d4
<second document content>
```
The headers include:
- Full memory:// URI for the document
- Last modified timestamp
- Content checksum
"""
logger.info(f"Reading note {identifier}")
url = memory_url_path(identifier)
response = await call_get(client, f"/resource/{url}")
return response.text
with logfire.span("Reading note", identifier=identifier): # pyright: ignore [reportGeneralTypeIssues]
logger.info(f"Reading note {identifier}")
url = memory_url_path(identifier)
response = await call_get(
client, f"/resource/{url}", params={"page": page, "page_size": page_size}
)
return response.text
@mcp.tool(description="Delete a note by title or permalink")
@@ -120,6 +196,7 @@ async def delete_note(identifier: str) -> bool:
# Delete by permalink
delete_note("notes/project-planning")
"""
response = await call_delete(client, f"/knowledge/entities/{identifier}")
result = DeleteEntitiesResponse.model_validate(response.json())
return result.deleted
with logfire.span("Deleting note", identifier=identifier): # pyright: ignore [reportGeneralTypeIssues]
response = await call_delete(client, f"/knowledge/entities/{identifier}")
result = DeleteEntitiesResponse.model_validate(response.json())
return result.deleted
+13 -4
View File
@@ -1,5 +1,6 @@
"""Search tools for Basic Memory MCP server."""
import logfire
from loguru import logger
from basic_memory.mcp.server import mcp
@@ -11,7 +12,7 @@ from basic_memory.mcp.async_client import client
@mcp.tool(
description="Search across all content in basic-memory, including documents and entities",
)
async def search(query: SearchQuery) -> SearchResponse:
async def search(query: SearchQuery, page: int = 1, page_size: int = 10) -> SearchResponse:
"""Search across all content in basic-memory.
Args:
@@ -20,10 +21,18 @@ async def search(query: SearchQuery) -> SearchResponse:
- types: Optional list of content types to search ("document" or "entity")
- entity_types: Optional list of entity types to filter by
- after_date: Optional date filter for recent content
page: the page number of results to return (default 1)
page_size: the number of results to return per page (default 10)
Returns:
SearchResponse with search results and metadata
"""
logger.info(f"Searching for {query.text}")
response = await call_post(client, "/search/", json=query.model_dump())
return SearchResponse.model_validate(response.json())
with logfire.span("Searching for {query}", query=query): # pyright: ignore [reportGeneralTypeIssues]
logger.info(f"Searching for {query}")
response = await call_post(
client,
"/search/",
json=query.model_dump(),
params={"page": page, "page_size": page_size},
)
return SearchResponse.model_validate(response.json())
@@ -68,24 +68,40 @@ class SearchRepository:
async def init_search_index(self):
"""Create or recreate the search index."""
logger.info("Initializing search index")
async with db.scoped_session(self.session_maker) as session:
await session.execute(CREATE_SEARCH_INDEX)
await session.commit()
try:
async with db.scoped_session(self.session_maker) as session:
await session.execute(CREATE_SEARCH_INDEX)
await session.commit()
except Exception as e: # pragma: no cover
logger.error(f"Error initializing search index: {e}")
raise e
def _quote_search_term(self, term: str) -> str:
"""Add quotes if term contains special characters.
For FTS5, special characters and phrases need to be quoted to be treated as a single token.
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
"""Prepare a search term for FTS5 query.
Args:
term: The search term to prepare
is_prefix: Whether to add prefix search capability (* suffix)
For FTS5:
- Special characters and phrases need to be quoted
- Terms with spaces or special chars need quotes
"""
# List of special characters that need quoting
special_chars = ["/", "*", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
if "*" in term:
return term
# List of special characters that need quoting (excluding *)
special_chars = ["/", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
# Check if term contains any special characters
if any(c in term for c in special_chars):
# If the term already contains quotes, escape them
needs_quotes = any(c in term for c in special_chars)
if needs_quotes:
# If the term already contains quotes, escape them and add a wildcard
term = term.replace('"', '""')
return f'"{term}"'
term = f'"{term}"*'
return term
async def search(
@@ -98,6 +114,7 @@ class SearchRepository:
after_date: Optional[datetime] = None,
entity_types: Optional[List[str]] = None,
limit: int = 10,
offset: int = 0,
) -> List[SearchIndexRow]:
"""Search across all indexed content with fuzzy matching."""
conditions = []
@@ -106,14 +123,14 @@ class SearchRepository:
# Handle text search for title and content
if search_text:
search_text = self._quote_search_term(search_text.lower().strip())
params["text"] = f"{search_text}*"
search_text = self._prepare_search_term(search_text.strip())
params["text"] = search_text
conditions.append("(title MATCH :text OR content MATCH :text)")
# Handle title match search
if title:
title_text = self._quote_search_term(title.lower().strip())
params["text"] = f"{title_text}*"
title_text = self._prepare_search_term(title.strip())
params["text"] = title_text
conditions.append("title MATCH :text")
# Handle permalink exact search
@@ -123,8 +140,15 @@ class SearchRepository:
# Handle permalink match search, supports *
if permalink_match:
params["permalink"] = self._quote_search_term(permalink_match)
conditions.append("permalink MATCH :permalink")
# Clean and prepare permalink for FTS5 GLOB match
permalink_text = self._prepare_search_term(
permalink_match.lower().strip(), is_prefix=False
)
params["permalink"] = permalink_text
if "*" in permalink_match:
conditions.append("permalink GLOB :permalink")
else:
conditions.append("permalink MATCH :permalink")
# Handle type filter
if types:
@@ -146,6 +170,7 @@ class SearchRepository:
# set limit on search query
params["limit"] = limit
params["offset"] = offset
# Build WHERE clause
where_clause = " AND ".join(conditions) if conditions else "1=1"
@@ -171,9 +196,10 @@ class SearchRepository:
WHERE {where_clause}
ORDER BY score ASC {order_by_clause}
LIMIT :limit
OFFSET :offset
"""
# logger.debug(f"Search {sql} params: {params}")
logger.debug(f"Search {sql} params: {params}")
async with db.scoped_session(self.session_maker) as session:
result = await session.execute(text(sql), params)
rows = result.fetchall()
@@ -199,8 +225,11 @@ class SearchRepository:
for row in rows
]
# for r in results:
# logger.debug(f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}")
logger.debug(f"Found {len(results)} search results")
for r in results:
logger.debug(
f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
)
return results
@@ -233,7 +262,7 @@ class SearchRepository:
"""),
search_index_row.to_insert(),
)
logger.debug(f"indexed permalink {search_index_row.permalink}")
logger.debug(f"indexed row {search_index_row}")
await session.commit()
async def delete_by_permalink(self, permalink: str):
+3
View File
@@ -111,3 +111,6 @@ class GraphContext(BaseModel):
# Context metadata
metadata: MemoryMetadata
page: int = 1
page_size: int = 1
+1 -1
View File
@@ -51,7 +51,7 @@ class GetEntitiesRequest(BaseModel):
discovered through search.
"""
permalinks: Annotated[List[Permalink], MinLen(1)]
permalinks: Annotated[List[Permalink], MinLen(1), MaxLen(10)]
class CreateRelationsRequest(BaseModel):
+8
View File
@@ -11,6 +11,7 @@ Key Features:
4. Bulk operations return all affected items
"""
from datetime import datetime
from typing import List, Optional, Dict
from pydantic import BaseModel, ConfigDict, Field, AliasPath, AliasChoices
@@ -43,6 +44,8 @@ class ObservationResponse(Observation, SQLAlchemyModel):
}
"""
permalink: Permalink
class RelationResponse(Relation, SQLAlchemyModel):
"""Response schema for relation operations.
@@ -59,6 +62,8 @@ class RelationResponse(Relation, SQLAlchemyModel):
}
"""
permalink: Permalink
from_id: Permalink = Field(
# use the permalink from the associated Entity
# or the from_id value
@@ -131,9 +136,12 @@ class EntityResponse(SQLAlchemyModel):
file_path: str
entity_type: EntityType
entity_metadata: Optional[Dict] = None
checksum: Optional[str] = None
content_type: ContentType
observations: List[ObservationResponse] = []
relations: List[RelationResponse] = []
created_at: datetime
updated_at: datetime
class EntityListResponse(SQLAlchemyModel):
+2
View File
@@ -102,6 +102,8 @@ class SearchResponse(BaseModel):
"""Wrapper for search results."""
results: List[SearchResult]
current_page: int
page_size: int
# Schema for future advanced search endpoint
+14 -6
View File
@@ -54,11 +54,13 @@ class ContextService:
types: Optional[List[SearchItemType]] = None,
depth: int = 1,
since: Optional[datetime] = None,
max_results: int = 10,
limit=10,
offset=0,
max_related: int = 10,
):
"""Build rich context from a memory:// URI."""
logger.debug(
f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' max_results: '{max_results}'"
f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' limit: '{limit}' offset: '{offset}' max_related: '{max_related}'"
)
if memory_url:
@@ -66,15 +68,21 @@ class ContextService:
# Pattern matching - use search
if "*" in path:
logger.debug(f"Pattern search for '{path}'")
primary = await self.search_repository.search(permalink_match=path)
primary = await self.search_repository.search(
permalink_match=path, limit=limit, offset=offset
)
# Direct lookup for exact path
else:
logger.debug(f"Direct lookup for '{path}'")
primary = await self.search_repository.search(permalink=path)
primary = await self.search_repository.search(
permalink=path, limit=limit, offset=offset
)
else:
logger.debug(f"Build context for '{types}'")
primary = await self.search_repository.search(types=types, after_date=since)
primary = await self.search_repository.search(
types=types, after_date=since, limit=limit, offset=offset
)
# Get type_id pairs for traversal
@@ -83,7 +91,7 @@ class ContextService:
# Find related content
related = await self.find_related(
type_id_pairs, max_depth=depth, since=since, max_results=max_results
type_id_pairs, max_depth=depth, since=since, max_results=max_related
)
logger.debug(f"Found {len(related)} related results")
for r in related:
@@ -185,6 +185,11 @@ class EntityService(BaseService[EntityModel]):
raise EntityNotFoundError(f"Entity not found: {permalink}")
return db_entity
async def get_entities_by_id(self, ids: List[int]) -> Sequence[EntityModel]:
"""Get specific entities and their relationships."""
logger.debug(f"Getting entities: {ids}")
return await self.repository.find_by_ids(ids)
async def get_entities_by_permalinks(self, permalinks: List[str]) -> Sequence[EntityModel]:
"""Get specific nodes and their relationships."""
logger.debug(f"Getting entities permalinks: {permalinks}")
+8 -7
View File
@@ -16,9 +16,10 @@ class LinkResolver:
Uses a combination of exact matching and search-based resolution:
1. Try exact permalink match (fastest)
2. Try exact title match
3. Fall back to search for fuzzy matching
4. Generate new permalink if no match found
2. Try permalink pattern match (for wildcards)
3. Try exact title match
4. Fall back to search for fuzzy matching
5. Generate new permalink if no match found
"""
def __init__(self, entity_repository: EntityRepository, search_service: SearchService):
@@ -45,8 +46,8 @@ class LinkResolver:
logger.debug(f"Found title match: {entity.title}")
return entity
if use_search:
# 3. Fall back to search for fuzzy matching on title if specified
if use_search and "*" not in clean_text:
# 3. Fall back to search for fuzzy matching on title
results = await self.search_service.search(
query=SearchQuery(title=clean_text, types=[SearchItemType.ENTITY]),
)
@@ -59,7 +60,7 @@ class LinkResolver:
)
return await self.entity_repository.get_by_permalink(best_match.permalink)
# if we couldn't find anything then return None
# if we couldn't find anything then return None
return None
def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
@@ -87,7 +88,7 @@ class LinkResolver:
return text, alias
def _select_best_match(self, search_text: str, results: List[SearchIndexRow]) -> Entity:
def _select_best_match(self, search_text: str, results: List[SearchIndexRow]) -> SearchIndexRow:
"""Select best match from search results.
Uses multiple criteria:
+4 -2
View File
@@ -51,7 +51,7 @@ class SearchService:
logger.info("Reindex complete")
async def search(self, query: SearchQuery) -> List[SearchIndexRow]:
async def search(self, query: SearchQuery, limit=10, offset=0) -> List[SearchIndexRow]:
"""Search across all indexed content.
Supports three modes:
@@ -75,7 +75,7 @@ class SearchService:
else None
)
# permalink search
# search
results = await self.repository.search(
search_text=query.text,
permalink=query.permalink,
@@ -84,6 +84,8 @@ class SearchService:
types=query.types,
entity_types=query.entity_types,
after_date=after_date,
limit=limit,
offset=offset,
)
return results
+93 -84
View File
@@ -3,6 +3,7 @@
from pathlib import Path
from typing import Dict
import logfire
from loguru import logger
from sqlalchemy.exc import IntegrityError
@@ -61,105 +62,113 @@ class SyncService:
async def sync(self, directory: Path) -> SyncReport:
"""Sync knowledge files with database."""
changes = await self.scanner.find_knowledge_changes(directory)
logger.info(f"Found {changes.total_changes} knowledge changes")
# Handle moves first
for old_path, new_path in changes.moves.items():
logger.debug(f"Moving entity: {old_path} -> {new_path}")
entity = await self.entity_repository.get_by_file_path(old_path)
if entity:
# Update file_path but keep the same permalink for link stability
updated = await self.entity_repository.update(
entity.id, {"file_path": new_path, "checksum": changes.checksums[new_path]}
)
# update search index
if updated:
await self.search_service.index_entity(updated)
with logfire.span("sync", directory=directory): # pyright: ignore [reportGeneralTypeIssues]
changes = await self.scanner.find_knowledge_changes(directory)
logger.info(f"Found {changes.total_changes} knowledge changes")
# Handle deletions next
# remove rows from db for files no longer present
for path in changes.deleted:
await self.handle_entity_deletion(path)
# Handle moves first
for old_path, new_path in changes.moves.items():
logger.debug(f"Moving entity: {old_path} -> {new_path}")
entity = await self.entity_repository.get_by_file_path(old_path)
if entity:
# Update file_path but keep the same permalink for link stability
updated = await self.entity_repository.update(
entity.id, {"file_path": new_path, "checksum": changes.checksums[new_path]}
)
# update search index
if updated:
await self.search_service.index_entity(updated)
# Parse files that need updating
parsed_entities: Dict[str, EntityMarkdown] = {}
# Handle deletions next
# remove rows from db for files no longer present
for path in changes.deleted:
await self.handle_entity_deletion(path)
for path in [*changes.new, *changes.modified]:
entity_markdown = await self.entity_parser.parse_file(directory / path)
parsed_entities[path] = entity_markdown
# Parse files that need updating
parsed_entities: Dict[str, EntityMarkdown] = {}
# First pass: Create/update entities
# entities will have a null checksum to indicate they are not complete
for path, entity_markdown in parsed_entities.items():
# Get unique permalink and update markdown if needed
permalink = await self.entity_service.resolve_permalink(
Path(path), markdown=entity_markdown
)
for path in [*changes.new, *changes.modified]:
entity_markdown = await self.entity_parser.parse_file(directory / path)
parsed_entities[path] = entity_markdown
if permalink != entity_markdown.frontmatter.permalink:
# Add/update permalink in frontmatter
logger.info(f"Adding permalink '{permalink}' to file: {path}")
# update markdown
entity_markdown.frontmatter.metadata["permalink"] = permalink
# update file frontmatter
updated_checksum = await file_utils.update_frontmatter(
directory / path, {"permalink": permalink}
# First pass: Create/update entities
# entities will have a null checksum to indicate they are not complete
for path, entity_markdown in parsed_entities.items():
# Get unique permalink and update markdown if needed
permalink = await self.entity_service.resolve_permalink(
Path(path), markdown=entity_markdown
)
# Update checksum in changes report since file was modified
changes.checksums[path] = updated_checksum
if permalink != entity_markdown.frontmatter.permalink:
# Add/update permalink in frontmatter
logger.info(f"Adding permalink '{permalink}' to file: {path}")
# if the file is new, create an entity
if path in changes.new:
# Create entity with final permalink
logger.debug(f"Creating new entity_markdown: {path}")
await self.entity_service.create_entity_from_markdown(Path(path), entity_markdown)
# otherwise we need to update the entity and observations
else:
logger.debug(f"Updating entity_markdown: {path}")
await self.entity_service.update_entity_and_observations(
# update markdown
entity_markdown.frontmatter.metadata["permalink"] = permalink
# update file frontmatter
updated_checksum = await file_utils.update_frontmatter(
directory / path, {"permalink": permalink}
)
# Update checksum in changes report since file was modified
changes.checksums[path] = updated_checksum
# if the file is new, create an entity
if path in changes.new:
# Create entity with final permalink
logger.debug(f"Creating new entity_markdown: {path}")
await self.entity_service.create_entity_from_markdown(
Path(path), entity_markdown
)
# otherwise we need to update the entity and observations
else:
logger.debug(f"Updating entity_markdown: {path}")
await self.entity_service.update_entity_and_observations(
Path(path), entity_markdown
)
# Second pass
for path, entity_markdown in parsed_entities.items():
logger.debug(f"Updating relations for: {path}")
# Process relations
checksum = changes.checksums[path]
entity = await self.entity_service.update_entity_relations(
Path(path), entity_markdown
)
# Second pass
for path, entity_markdown in parsed_entities.items():
logger.debug(f"Updating relations for: {path}")
# add to search index
await self.search_service.index_entity(entity)
# Process relations
checksum = changes.checksums[path]
entity = await self.entity_service.update_entity_relations(Path(path), entity_markdown)
# Set final checksum to mark sync complete
await self.entity_repository.update(entity.id, {"checksum": checksum})
# add to search index
await self.search_service.index_entity(entity)
# Set final checksum to mark sync complete
await self.entity_repository.update(entity.id, {"checksum": checksum})
# Third pass: Try to resolve any forward references
logger.debug("Attempting to resolve forward references")
for relation in await self.relation_repository.find_unresolved_relations():
target_entity = await self.entity_service.link_resolver.resolve_link(relation.to_name)
# check we found a link that is not the source
if target_entity and target_entity.id != relation.from_id:
logger.debug(
f"Resolved forward reference: {relation.to_name} -> {target_entity.permalink}"
# Third pass: Try to resolve any forward references
logger.debug("Attempting to resolve forward references")
for relation in await self.relation_repository.find_unresolved_relations():
target_entity = await self.entity_service.link_resolver.resolve_link(
relation.to_name
)
try:
await self.relation_repository.update(
relation.id,
{
"to_id": target_entity.id,
"to_name": target_entity.title, # Update to actual title
},
# check we found a link that is not the source
if target_entity and target_entity.id != relation.from_id:
logger.debug(
f"Resolved forward reference: {relation.to_name} -> {target_entity.permalink}"
)
except IntegrityError:
logger.debug(f"Ignoring duplicate relation {relation}")
# update search index
await self.search_service.index_entity(target_entity)
try:
await self.relation_repository.update(
relation.id,
{
"to_id": target_entity.id,
"to_name": target_entity.title, # Update to actual title
},
)
except IntegrityError:
logger.debug(f"Ignoring duplicate relation {relation}")
return changes
# update search index
await self.search_service.index_entity(target_entity)
return changes
+32 -4
View File
@@ -9,8 +9,11 @@ from typing import Optional, Union
from loguru import logger
from unidecode import unidecode
import basic_memory
from basic_memory.config import config
import logfire
def generate_permalink(file_path: Union[Path, str]) -> str:
"""Generate a stable permalink from a file path.
@@ -61,19 +64,42 @@ def generate_permalink(file_path: Union[Path, str]) -> str:
return "/".join(clean_segments)
def setup_logging(home_dir: Path = config.home, log_file: Optional[str] = None) -> None:
def setup_logging(
home_dir: Path = config.home, log_file: Optional[str] = None, console: bool = True
) -> None: # pragma: no cover
"""
Configure logging for the application.
:param home_dir: the root directory for the application
:param log_file: the name of the log file to write to
:param app: the fastapi application instance
:param console: whether to log to the console
"""
# Remove default handler and any existing handlers
logger.remove()
# Add file handler
if log_file:
# Add file handler if we are not running tests
if log_file and config.env != "test":
# enable pydantic logfire
logfire.configure(
code_source=logfire.CodeSource(
repository="https://github.com/basicmachines-co/basic-memory",
revision=basic_memory.__version__,
root_path="/src/basic_memory",
),
environment=config.env,
console=False,
)
logger.configure(handlers=[logfire.loguru_handler()])
# instrument code spans
logfire.instrument_sqlite3()
logfire.instrument_httpx()
# setup logger
log_path = home_dir / log_file
logger.add(
str(log_path), # loguru expects a string path
str(log_path),
level=config.log_level,
rotation="100 MB",
retention="10 days",
@@ -85,3 +111,5 @@ def setup_logging(home_dir: Path = config.home, log_file: Optional[str] = None)
# Add stderr handler
logger.add(sys.stderr, level=config.log_level, backtrace=True, diagnose=True, colorize=True)
logger.info(f"ENV: '{config.env}' Log level: '{config.log_level}' Logging to {log_file}")
+4
View File
@@ -0,0 +1,4 @@
from basic_memory.config import config
# set config.env to "test" for pytest to prevent logging to file in utils.setup_logging()
config.env = "test"
+30
View File
@@ -26,6 +26,25 @@ async def test_get_memory_context(client, test_graph):
assert context.metadata.total_results == 3
@pytest.mark.asyncio
async def test_get_memory_context_pagination(client, test_graph):
"""Test getting context from memory URL."""
response = await client.get("/memory/test/root?page=1&page_size=1")
assert response.status_code == 200
context = GraphContext(**response.json())
assert len(context.primary_results) == 1
assert context.primary_results[0].permalink == "test/root"
assert len(context.related_results) > 0
# Verify metadata
assert context.metadata.uri == "test/root"
assert context.metadata.depth == 1 # default depth
# assert context.metadata["timeframe"] == "7d" # default timeframe
assert isinstance(context.metadata.generated_at, datetime)
assert context.metadata.total_results == 3
@pytest.mark.asyncio
async def test_get_memory_context_pattern(client, test_graph):
"""Test getting context with pattern matching."""
@@ -91,6 +110,17 @@ async def test_recent_activity(client, test_graph):
assert len(context.related_results) > 0
@pytest.mark.asyncio
async def test_recent_activity_pagination(client, test_graph):
"""Test handling of paths."""
response = await client.get("/memory/recent?page=1&page_size=1")
assert response.status_code == 200
context = GraphContext(**response.json())
assert len(context.primary_results) == 1
assert len(context.related_results) > 0
@pytest.mark.asyncio
async def test_recent_activity_by_type(client, test_graph):
"""Test handling of non-existent paths."""
+215 -1
View File
@@ -5,6 +5,8 @@ from datetime import datetime, timezone
import pytest
from pathlib import Path
from basic_memory.schemas import EntityResponse
@pytest.mark.asyncio
async def test_get_resource_content(client, test_config, entity_repository):
@@ -35,6 +37,35 @@ async def test_get_resource_content(client, test_config, entity_repository):
assert response.text == content
@pytest.mark.asyncio
async def test_get_resource_pagination(client, test_config, entity_repository):
"""Test getting content by permalink with pagination."""
# Create a test file
content = "# Test Content\n\nThis is a test file."
test_file = Path(test_config.home) / "test" / "test.md"
test_file.parent.mkdir(parents=True, exist_ok=True)
test_file.write_text(content)
# Create entity referencing the file
entity = await entity_repository.create(
{
"title": "Test Entity",
"entity_type": "test",
"permalink": "test/test",
"file_path": "test/test.md", # Relative to config.home
"content_type": "text/markdown",
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
)
# Test getting the content
response = await client.get(f"/resource/{entity.permalink}", params={"page": 1, "page_size": 1})
assert response.status_code == 200
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
assert response.text == content
@pytest.mark.asyncio
async def test_get_resource_by_title(client, test_config, entity_repository):
"""Test getting content by permalink."""
@@ -67,7 +98,7 @@ async def test_get_resource_missing_entity(client):
"""Test 404 when entity doesn't exist."""
response = await client.get("/resource/does/not/exist")
assert response.status_code == 404
assert "Entity not found" in response.json()["detail"]
assert "Resource not found" in response.json()["detail"]
@pytest.mark.asyncio
@@ -89,3 +120,186 @@ async def test_get_resource_missing_file(client, test_config, entity_repository)
response = await client.get(f"/resource/{entity.permalink}")
assert response.status_code == 404
assert "File not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_get_resource_observation(client, test_config, entity_repository):
"""Test getting content by observation permalink."""
# Create entity
content = "# Test Content\n\n- [note] an observation."
data = {
"title": "Test Entity",
"folder": "test",
"entity_type": "test",
"content": f"{content}",
}
response = await client.post("/knowledge/entities", json=data)
entity_response = response.json()
entity = EntityResponse(**entity_response)
assert len(entity.observations) == 1
observation = entity.observations[0]
# Test getting the content via the observation
response = await client.get(f"/resource/{observation.permalink}")
assert response.status_code == 200
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
assert (
"""
---
title: Test Entity
type: test
permalink: test/test-entity
---
# Test Content
- [note] an observation.
""".strip()
in response.text
)
@pytest.mark.asyncio
async def test_get_resource_entities(client, test_config, entity_repository):
"""Test getting content by permalink match."""
# Create entity
content1 = "# Test Content\n"
data = {
"title": "Test Entity",
"folder": "test",
"entity_type": "test",
"content": f"{content1}",
}
response = await client.post("/knowledge/entities", json=data)
entity_response = response.json()
entity1 = EntityResponse(**entity_response)
content2 = "# Related Content\n- links to [[Test Entity]]"
data = {
"title": "Related Entity",
"folder": "test",
"entity_type": "test",
"content": f"{content2}",
}
response = await client.post("/knowledge/entities", json=data)
entity_response = response.json()
entity2 = EntityResponse(**entity_response)
assert len(entity2.relations) == 1
# Test getting the content via the relation
response = await client.get("/resource/test/*")
assert response.status_code == 200
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
assert (
f"""
--- memory://test/test-entity {entity1.updated_at.isoformat()} {entity1.checksum[:8]}
# Test Content
--- memory://test/related-entity {entity2.updated_at.isoformat()} {entity2.checksum[:8]}
# Related Content
- links to [[Test Entity]]
""".strip()
in response.text
)
@pytest.mark.asyncio
async def test_get_resource_entities_pagination(client, test_config, entity_repository):
"""Test getting content by permalink match."""
# Create entity
content1 = "# Test Content\n"
data = {
"title": "Test Entity",
"folder": "test",
"entity_type": "test",
"content": f"{content1}",
}
response = await client.post("/knowledge/entities", json=data)
entity_response = response.json()
entity1 = EntityResponse(**entity_response)
assert entity1
content2 = "# Related Content\n- links to [[Test Entity]]"
data = {
"title": "Related Entity",
"folder": "test",
"entity_type": "test",
"content": f"{content2}",
}
response = await client.post("/knowledge/entities", json=data)
entity_response = response.json()
entity2 = EntityResponse(**entity_response)
assert len(entity2.relations) == 1
# Test getting second result
response = await client.get("/resource/test/*", params={"page": 2, "page_size": 1})
assert response.status_code == 200
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
assert (
"""
---
title: Related Entity
type: test
permalink: test/related-entity
---
# Related Content
- links to [[Test Entity]]
""".strip()
in response.text
)
@pytest.mark.asyncio
async def test_get_resource_relation(client, test_config, entity_repository):
"""Test getting content by relation permalink."""
# Create entity
content1 = "# Test Content\n"
data = {
"title": "Test Entity",
"folder": "test",
"entity_type": "test",
"content": f"{content1}",
}
response = await client.post("/knowledge/entities", json=data)
entity_response = response.json()
entity1 = EntityResponse(**entity_response)
content2 = "# Related Content\n- links to [[Test Entity]]"
data = {
"title": "Related Entity",
"folder": "test",
"entity_type": "test",
"content": f"{content2}",
}
response = await client.post("/knowledge/entities", json=data)
entity_response = response.json()
entity2 = EntityResponse(**entity_response)
assert len(entity2.relations) == 1
relation = entity2.relations[0]
# Test getting the content via the relation
response = await client.get(f"/resource/{relation.permalink}")
assert response.status_code == 200
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
assert (
f"""
--- memory://test/test-entity {entity1.updated_at.isoformat()} {entity1.checksum[:8]}
# Test Content
--- memory://test/related-entity {entity2.updated_at.isoformat()} {entity2.checksum[:8]}
# Related Content
- links to [[Test Entity]]
""".strip()
in response.text
)
+12 -22
View File
@@ -35,6 +35,18 @@ async def test_search_basic(client, indexed_entity):
assert found, "Expected to find indexed entity in results"
@pytest.mark.asyncio
async def test_search_basic_pagination(client, indexed_entity):
"""Test basic text search."""
response = await client.post("/search/?page=3&page_size=1", json={"text": "search"})
assert response.status_code == 200
search_results = SearchResponse.model_validate(response.json())
assert len(search_results.results) == 1
assert search_results.current_page == 3
assert search_results.page_size == 1
@pytest.mark.asyncio
async def test_search_with_type_filter(client, indexed_entity):
"""Test search with type filter."""
@@ -92,28 +104,6 @@ async def test_search_with_date_filter(client, indexed_entity):
assert len(search_results.results) == 0
@pytest.mark.skip("search scoring is not implemented yet")
@pytest.mark.asyncio
async def test_search_scoring(client, indexed_entity):
"""Test search result scoring."""
# Exact match should score higher
exact_response = await client.post("/search/", json={"text": "TestComponent"})
# Partial match should score lower
partial_response = await client.post("/search/", json={"text": "test"})
assert exact_response.status_code == 200
assert partial_response.status_code == 200
exact_result = SearchResponse.model_validate(exact_response.json())
partial_result = SearchResponse.model_validate(partial_response.json())
exact_score = exact_result.results[0].score
partial_score = partial_result.results[0].score
assert exact_score > partial_score
@pytest.mark.asyncio
async def test_search_empty(search_service, client):
"""Test search with no matches."""
+294
View File
@@ -0,0 +1,294 @@
"""Tests for the Basic Memory CLI tools."""
from datetime import datetime, timezone
import pytest
from typer.testing import CliRunner
from unittest.mock import patch, AsyncMock
from basic_memory.cli.commands.tools import tool_app
from basic_memory.schemas.response import EntityResponse
from basic_memory.schemas.search import SearchResponse
from basic_memory.schemas.memory import GraphContext
runner = CliRunner()
@pytest.fixture
def mock_write_note():
with patch("basic_memory.cli.commands.tools.mcp_write_note", new_callable=AsyncMock) as mock:
mock.return_value = "Created test/note.md (abc123)\npermalink: test/note"
yield mock
@pytest.fixture
def mock_read_note():
with patch("basic_memory.cli.commands.tools.mcp_read_note", new_callable=AsyncMock) as mock:
mock.return_value = "--- memory://test/note 2025-01 abc123\nTest content"
yield mock
@pytest.fixture
def mock_search():
with patch("basic_memory.cli.commands.tools.mcp_search", new_callable=AsyncMock) as mock:
mock.return_value = SearchResponse(results=[], current_page=1, page_size=10)
yield mock
@pytest.fixture
def mock_build_context():
with patch("basic_memory.cli.commands.tools.mcp_build_context", new_callable=AsyncMock) as mock:
now = datetime.now(timezone.utc)
mock.return_value = GraphContext(
primary_results=[],
related_results=[],
metadata={
"uri": "test/*",
"depth": 1,
"timeframe": "7d",
"generated_at": now,
"total_results": 0,
"total_relations": 0,
},
)
yield mock
@pytest.fixture
def mock_recent_activity():
with patch(
"basic_memory.cli.commands.tools.mcp_recent_activity", new_callable=AsyncMock
) as mock:
now = datetime.now(timezone.utc)
mock.return_value = GraphContext(
primary_results=[],
related_results=[],
metadata={
"uri": None,
"types": ["entity", "observation"],
"depth": 1,
"timeframe": "7d",
"generated_at": now,
"total_results": 0,
"total_relations": 0,
},
)
yield mock
@pytest.fixture
def mock_get_entity():
with patch("basic_memory.cli.commands.tools.mcp_get_entity", new_callable=AsyncMock) as mock:
now = datetime.now(timezone.utc)
mock.return_value = EntityResponse(
permalink="test/entity",
title="Test Entity",
file_path="test/entity.md",
entity_type="note",
content_type="text/markdown",
observations=[],
relations=[],
created_at=now,
updated_at=now,
)
yield mock
def test_write_note(mock_write_note):
"""Test write_note command with basic arguments."""
result = runner.invoke(
tool_app,
[
"write-note",
"--title",
"Test Note",
"--content",
"Test content",
"--folder",
"test",
],
)
assert result.exit_code == 0
mock_write_note.assert_awaited_once_with("Test Note", "Test content", "test", None)
def test_write_note_with_tags(mock_write_note):
"""Test write_note command with tags."""
result = runner.invoke(
tool_app,
[
"write-note",
"--title",
"Test Note",
"--content",
"Test content",
"--folder",
"test",
"--tags",
"tag1",
"--tags",
"tag2",
],
)
assert result.exit_code == 0
mock_write_note.assert_awaited_once_with("Test Note", "Test content", "test", ["tag1", "tag2"])
def test_read_note(mock_read_note):
"""Test read_note command."""
result = runner.invoke(
tool_app,
["read-note", "test/note"],
)
assert result.exit_code == 0
mock_read_note.assert_awaited_once_with("test/note", 1, 10)
def test_read_note_with_pagination(mock_read_note):
"""Test read_note command with pagination."""
result = runner.invoke(
tool_app,
["read-note", "test/note", "--page", "2", "--page-size", "5"],
)
assert result.exit_code == 0
mock_read_note.assert_awaited_once_with("test/note", 2, 5)
def test_search_basic(mock_search):
"""Test basic search command."""
result = runner.invoke(
tool_app,
["search", "test query"],
)
assert result.exit_code == 0
mock_search.assert_awaited_once()
args = mock_search.await_args[1]
assert args["query"].text == "test query"
def test_search_permalink(mock_search):
"""Test search with permalink flag."""
result = runner.invoke(
tool_app,
["search", "test/*", "--permalink"],
)
assert result.exit_code == 0
mock_search.assert_awaited_once()
args = mock_search.await_args[1]
assert args["query"].permalink_match == "test/*"
def test_search_title(mock_search):
"""Test search with title flag."""
result = runner.invoke(
tool_app,
["search", "test", "--title"],
)
assert result.exit_code == 0
mock_search.assert_awaited_once()
args = mock_search.await_args[1]
assert args["query"].title == "test"
def test_search_with_pagination(mock_search):
"""Test search with pagination."""
result = runner.invoke(
tool_app,
["search", "test", "--page", "2", "--page-size", "5"],
)
assert result.exit_code == 0
mock_search.assert_awaited_once()
args = mock_search.await_args[1]
assert args["page"] == 2
assert args["page_size"] == 5
def test_build_context(mock_build_context):
"""Test build_context command."""
result = runner.invoke(
tool_app,
["build-context", "memory://test/*"],
)
assert result.exit_code == 0
mock_build_context.assert_awaited_once_with(
url="memory://test/*", depth=1, timeframe="7d", page=1, page_size=10, max_related=10
)
def test_build_context_with_options(mock_build_context):
"""Test build_context command with all options."""
result = runner.invoke(
tool_app,
[
"build-context",
"memory://test/*",
"--depth",
"2",
"--timeframe",
"1d",
"--page",
"2",
"--page-size",
"5",
"--max-related",
"20",
],
)
assert result.exit_code == 0
mock_build_context.assert_awaited_once_with(
url="memory://test/*", depth=2, timeframe="1d", page=2, page_size=5, max_related=20
)
def test_get_entity(mock_get_entity):
"""Test get_entity command."""
result = runner.invoke(
tool_app,
["get-entity", "test/entity"],
)
assert result.exit_code == 0
mock_get_entity.assert_awaited_once_with(identifier="test/entity")
def test_recent_activity(mock_recent_activity):
"""Test recent_activity command with defaults."""
result = runner.invoke(
tool_app,
["recent-activity"],
)
assert result.exit_code == 0
mock_recent_activity.assert_awaited_once_with(
type=["entity", "observation", "relation"],
depth=1,
timeframe="7d",
page=1,
page_size=10,
max_related=10,
)
def test_recent_activity_with_options(mock_recent_activity):
"""Test recent_activity command with options."""
result = runner.invoke(
tool_app,
[
"recent-activity",
"--type",
"entity",
"--type",
"observation",
"--depth",
"2",
"--timeframe",
"1d",
"--page",
"2",
"--page-size",
"5",
"--max-related",
"20",
],
)
assert result.exit_code == 0
mock_recent_activity.assert_awaited_once_with(
type=["entity", "observation"], depth=2, timeframe="1d", page=2, page_size=5, max_related=20
)
+3 -6
View File
@@ -220,7 +220,7 @@ async def test_hidden_messages(tmp_path, sample_conversation_with_hidden):
def test_import_chatgpt_command_file_not_found(tmp_path):
"""Test error handling for nonexistent file."""
nonexistent = tmp_path / "nonexistent.json"
result = runner.invoke(app, ["import", "chatgpt", "--conversations-json", str(nonexistent)])
result = runner.invoke(app, ["import", "chatgpt", str(nonexistent)])
assert result.exit_code == 1
assert "File not found" in result.output
@@ -231,9 +231,7 @@ def test_import_chatgpt_command_success(tmp_path, sample_chatgpt_json, monkeypat
monkeypatch.setenv("HOME", str(tmp_path))
# Run import
result = runner.invoke(
import_app, ["chatgpt", "--conversations-json", str(sample_chatgpt_json)]
)
result = runner.invoke(import_app, ["chatgpt", str(sample_chatgpt_json)])
assert result.exit_code == 0
assert "Import complete" in result.output
assert "Imported 1 conversations" in result.output
@@ -246,7 +244,7 @@ def test_import_chatgpt_command_invalid_json(tmp_path):
invalid_file = tmp_path / "invalid.json"
invalid_file.write_text("not json")
result = runner.invoke(import_app, ["chatgpt", "--conversations-json", str(invalid_file)])
result = runner.invoke(import_app, ["chatgpt", str(invalid_file)])
assert result.exit_code == 1
assert "Error during import" in result.output
@@ -263,7 +261,6 @@ def test_import_chatgpt_with_custom_folder(tmp_path, sample_chatgpt_json, monkey
[
"import",
"chatgpt",
"--conversations-json",
str(sample_chatgpt_json),
"--folder",
conversations_folder,
-21
View File
@@ -308,27 +308,6 @@ async def test_graph(
for entity in entities:
await search_service.index_entity(entity)
# search_content = await entity_repository.execute_query(text("select * from search_index"),
# use_query_options=False)
# for row in search_content:
# print(row)
# print("relation:")
# search_content = await entity_repository.execute_query(text("select * from search_index where type = 'relation'"),
# use_query_options=False)
# for row in search_content:
# print(row)
#
# # In test_graph fixture after creating everything:
# print("Entities:")
# entities = await entity_repository.find_all()
# for e in entities:
# print(f"- {e.title} (id={e.id})")
#
# print("\nRelations:")
# relations = await relation_repository.find_all()
# for r in relations:
# print(f"- {r.from_id} -> {r.to_id} ({r.relation_type})")
return {
"root": root,
"connected1": connected_1,
-45
View File
@@ -1,45 +0,0 @@
"""Tests for get_entity MCP tool."""
import pytest
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.tools import notes
from basic_memory.mcp.tools.knowledge import get_entity
@pytest.mark.asyncio
async def test_get_basic_entity(client):
"""Test retrieving a basic entity."""
# First create an entity
permalink = await notes.write_note(
title="Test Note",
folder="test",
content="""
# Test\nThis is a test note
- [note] First observation
""",
tags=["test", "documentation"],
)
assert permalink # Got a valid permalink
# Get the entity without content
entity = await get_entity(permalink)
# Verify entity details
assert entity.file_path == "test/Test Note.md"
assert entity.entity_type == "note"
assert entity.permalink == "test/test-note"
# Check observations
assert len(entity.observations) == 1
obs = entity.observations[0]
assert obs.content == "First observation"
assert obs.category == "note"
@pytest.mark.asyncio
async def test_get_nonexistent_entity(client):
"""Test attempting to get a non-existent entity."""
with pytest.raises(ToolError):
await get_entity("test/nonexistent")
+85 -10
View File
@@ -13,7 +13,7 @@ from basic_memory.schemas.delete import DeleteEntitiesRequest
async def test_get_single_entity(client):
"""Test retrieving a single entity."""
# First create an entity
permalink = await notes.write_note(
result = await notes.write_note(
title="Test Note",
folder="test",
content="""
@@ -22,9 +22,34 @@ async def test_get_single_entity(client):
""",
tags=["test", "documentation"],
)
assert result
# Get the entity
entity = await get_entity(permalink)
entity = await get_entity("test/test-note")
# Verify entity details
assert entity.title == "Test Note"
assert entity.permalink == "test/test-note"
assert len(entity.observations) == 1
@pytest.mark.asyncio
async def test_get_single_entity_memory_url(client):
"""Test retrieving a single entity."""
# First create an entity
result = await notes.write_note(
title="Test Note",
folder="test",
content="""
# Test\nThis is a test note
- [note] First observation
""",
tags=["test", "documentation"],
)
assert result
# Get the entity
entity = await get_entity("memory://test/test-note")
# Verify entity details
assert entity.title == "Test Note"
@@ -36,40 +61,68 @@ async def test_get_single_entity(client):
async def test_get_multiple_entities(client):
"""Test retrieving multiple entities."""
# Create two test entities
permalink1 = await notes.write_note(
await notes.write_note(
title="Test Note 1",
folder="test",
content="# Test 1",
)
permalink2 = await notes.write_note(
await notes.write_note(
title="Test Note 2",
folder="test",
content="# Test 2",
)
# Get both entities
request = GetEntitiesRequest(permalinks=[permalink1, permalink2])
request = GetEntitiesRequest(permalinks=["test/test-note-1", "test/test-note-2"])
response = await get_entities(request)
# Verify we got both entities
assert len(response.entities) == 2
permalinks = {e.permalink for e in response.entities}
assert permalink1 in permalinks
assert permalink2 in permalinks
assert "test/test-note-1" in permalinks
assert "test/test-note-2" in permalinks
@pytest.mark.asyncio
async def test_get_multiple_entities_memory_ur(client):
"""Test retrieving multiple entities."""
# Create two test entities
await notes.write_note(
title="Test Note 1",
folder="test",
content="# Test 1",
)
await notes.write_note(
title="Test Note 2",
folder="test",
content="# Test 2",
)
# Get both entities
request = GetEntitiesRequest(
permalinks=["memory://test/test-note-1", "memory://test/test-note-2"]
)
response = await get_entities(request)
# Verify we got both entities
assert len(response.entities) == 2
permalinks = {e.permalink for e in response.entities}
assert "test/test-note-1" in permalinks
assert "test/test-note-2" in permalinks
@pytest.mark.asyncio
async def test_delete_entities(client):
"""Test deleting entities."""
# Create a test entity
permalink = await notes.write_note(
await notes.write_note(
title="Test Note",
folder="test",
content="# Test Note to Delete",
)
# Delete the entity
request = DeleteEntitiesRequest(permalinks=[permalink])
request = DeleteEntitiesRequest(permalinks=["test/test-note"])
response = await delete_entities(request)
# Verify deletion
@@ -77,7 +130,29 @@ async def test_delete_entities(client):
# Verify entity no longer exists
with pytest.raises(ToolError):
await get_entity(permalink)
await get_entity("test/test-note")
@pytest.mark.asyncio
async def test_delete_entities_memory_url(client):
"""Test deleting entities."""
# Create a test entity
await notes.write_note(
title="Test Note",
folder="test",
content="# Test Note to Delete",
)
# Delete the entity
request = DeleteEntitiesRequest(permalinks=["memory://test/test-note"])
response = await delete_entities(request)
# Verify deletion
assert response.deleted is True
# Verify entity no longer exists
with pytest.raises(ToolError):
await get_entity("test/test-note")
@pytest.mark.asyncio
+6 -2
View File
@@ -89,7 +89,9 @@ async def test_recent_activity_timeframe_formats(client, test_graph):
# Test each valid timeframe
for timeframe in valid_timeframes:
try:
result = await recent_activity(type=["entity"], timeframe=timeframe, max_results=1)
result = await recent_activity(
type=["entity"], timeframe=timeframe, page=1, page_size=10, max_related=10
)
assert result is not None
except Exception as e:
pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}")
@@ -136,7 +138,9 @@ async def test_build_context_timeframe_formats(client, test_graph):
# Test each valid timeframe
for timeframe in valid_timeframes:
try:
result = await build_context(url=test_url, timeframe=timeframe, max_results=1)
result = await build_context(
url=test_url, timeframe=timeframe, page=1, page_size=10, max_related=10
)
assert result is not None
except Exception as e:
pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}")
+159 -67
View File
@@ -1,10 +1,11 @@
"""Tests for note tools that exercise the full stack with SQLite."""
from textwrap import dedent
import pytest
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.tools import notes
from basic_memory.schemas import EntityResponse
@pytest.mark.asyncio
@@ -17,31 +18,41 @@ async def test_write_note(app):
- Handle tags correctly
- Return valid permalink
"""
permalink = await notes.write_note(
result = await notes.write_note(
title="Test Note",
folder="test",
content="# Test\nThis is a test note",
tags=["test", "documentation"],
)
assert permalink # Got a valid permalink
assert result
assert (
dedent("""
# Created test/Test Note.md (159f2168)
permalink: test/test-note
## Tags
- test, documentation
""").strip()
in result
)
# Try reading it back via permalink
content = await notes.read_note(permalink)
content = await notes.read_note("test/test-note")
assert (
"""
---
title: Test Note
type: note
permalink: test/test-note
tags:
- '#test'
- '#documentation'
---
# Test
This is a test note
""".strip()
dedent("""
---
title: Test Note
type: note
permalink: test/test-note
tags:
- '#test'
- '#documentation'
---
# Test
This is a test note
""").strip()
in content
)
@@ -49,20 +60,28 @@ This is a test note
@pytest.mark.asyncio
async def test_write_note_no_tags(app):
"""Test creating a note without tags."""
permalink = await notes.write_note(title="Simple Note", folder="test", content="Just some text")
result = await notes.write_note(title="Simple Note", folder="test", content="Just some text")
# Should be able to read it back
content = await notes.read_note(permalink)
assert result
assert (
"""
--
title: Simple Note
type: note
permalink: test/simple-note
---
Just some text
""".strip()
dedent("""
# Created test/Simple Note.md (9a1ff079)
permalink: test/simple-note
""").strip()
in result
)
# Should be able to read it back
content = await notes.read_note("test/simple-note")
assert (
dedent("""
--
title: Simple Note
type: note
permalink: test/simple-note
---
Just some text
""").strip()
in content
)
@@ -84,24 +103,44 @@ async def test_write_note_update_existing(app):
- Handle tags correctly
- Return valid permalink
"""
permalink = await notes.write_note(
result = await notes.write_note(
title="Test Note",
folder="test",
content="# Test\nThis is a test note",
tags=["test", "documentation"],
)
assert permalink # Got a valid permalink
assert result # Got a valid permalink
assert (
dedent("""
# Created test/Test Note.md (159f2168)
permalink: test/test-note
## Tags
- test, documentation
""").strip()
in result
)
permalink = await notes.write_note(
result = await notes.write_note(
title="Test Note",
folder="test",
content="# Test\nThis is an updated note",
tags=["test", "documentation"],
)
assert (
dedent("""
# Updated test/Test Note.md (131b5662)
permalink: test/test-note
## Tags
- test, documentation
""").strip()
in result
)
# Try reading it back
content = await notes.read_note(permalink)
content = await notes.read_note("test/test-note")
assert (
"""
---
@@ -135,10 +174,18 @@ async def test_read_note_by_title(app):
async def test_note_unicode_content(app):
"""Test handling of unicode content in notes."""
content = "# Test 🚀\nThis note has emoji 🎉 and unicode ♠♣♥♦"
permalink = await notes.write_note(title="Unicode Test", folder="test", content=content)
result = await notes.write_note(title="Unicode Test", folder="test", content=content)
assert (
dedent("""
# Created test/Unicode Test.md (272389cd)
permalink: test/unicode-test
""").strip()
in result
)
# Read back should preserve unicode
result = await notes.read_note(permalink)
result = await notes.read_note("test/unicode-test")
assert content in result
@@ -147,20 +194,61 @@ async def test_multiple_notes(app):
"""Test creating and managing multiple notes."""
# Create several notes
notes_data = [
("Note 1", "test", "Content 1", ["tag1"]),
("Note 2", "test", "Content 2", ["tag1", "tag2"]),
("Note 3", "test", "Content 3", []),
("test/note-1", "Note 1", "test", "Content 1", ["tag1"]),
("test/note-2", "Note 2", "test", "Content 2", ["tag1", "tag2"]),
("test/note-3", "Note 3", "test", "Content 3", []),
]
permalinks = []
for title, folder, content, tags in notes_data:
permalink = await notes.write_note(title=title, folder=folder, content=content, tags=tags)
permalinks.append(permalink)
for _, title, folder, content, tags in notes_data:
await notes.write_note(title=title, folder=folder, content=content, tags=tags)
# Should be able to read each one
for i, permalink in enumerate(permalinks):
content = await notes.read_note(permalink)
assert f"Content {i + 1}" in content
for permalink, title, folder, content, _ in notes_data:
note = await notes.read_note(permalink)
assert content in note
# read multiple notes at once
result = await notes.read_note("test/*")
# note we can't compare times
assert "--- memory://test/note-1" in result
assert "Content 1" in result
assert "--- memory://test/note-2" in result
assert "Content 2" in result
assert "--- memory://test/note-3" in result
assert "Content 3" in result
@pytest.mark.asyncio
async def test_multiple_notes_pagination(app):
"""Test creating and managing multiple notes."""
# Create several notes
notes_data = [
("test/note-1", "Note 1", "test", "Content 1", ["tag1"]),
("test/note-2", "Note 2", "test", "Content 2", ["tag1", "tag2"]),
("test/note-3", "Note 3", "test", "Content 3", []),
]
for _, title, folder, content, tags in notes_data:
await notes.write_note(title=title, folder=folder, content=content, tags=tags)
# Should be able to read each one
for permalink, title, folder, content, _ in notes_data:
note = await notes.read_note(permalink)
assert content in note
# read multiple notes at once with pagination
result = await notes.read_note("test/*", page=1, page_size=2)
# note we can't compare times
assert "--- memory://test/note-1" in result
assert "Content 1" in result
assert "--- memory://test/note-2" in result
assert "Content 2" in result
@pytest.mark.asyncio
@@ -172,16 +260,16 @@ async def test_delete_note_existing(app):
- Return valid permalink
- Delete the note
"""
permalink = await notes.write_note(
result = await notes.write_note(
title="Test Note",
folder="test",
content="# Test\nThis is a test note",
tags=["test", "documentation"],
)
assert permalink # Got a valid permalink
assert result
deleted = await notes.delete_note(permalink)
deleted = await notes.delete_note("test/test-note")
assert deleted is True
@@ -207,7 +295,7 @@ async def test_write_note_verbose(app):
- Handle tags correctly
- Return valid permalink
"""
entity = await notes.write_note(
result = await notes.write_note(
title="Test Note",
folder="test",
content="""
@@ -218,24 +306,27 @@ async def test_write_note_verbose(app):
""",
tags=["test", "documentation"],
verbose=True,
)
assert isinstance(entity, EntityResponse)
assert entity.title == "Test Note"
assert entity.file_path == "test/Test Note.md"
assert entity.entity_type == "note"
assert entity.permalink == "test/test-note"
assert len(entity.observations) == 1
assert entity.observations[0].content == "First observation"
assert len(entity.relations) == 1
assert entity.relations[0].relation_type == "relates to"
assert entity.relations[0].from_id == "test/test-note"
assert entity.relations[0].to_id is None
assert entity.relations[0].to_name == "Knowledge"
assert (
dedent("""
# Created test/Test Note.md (06873a7a)
permalink: test/test-note
## Observations
- note: 1
## Relations
- Resolved: 0
- Unresolved: 1
Unresolved relations will be retried on next sync.
## Tags
- test, documentation
""").strip()
in result
)
@pytest.mark.asyncio
@@ -248,13 +339,14 @@ async def test_read_note_memory_url(app):
- Return the note content
"""
# First create a note
permalink = await notes.write_note(
result = await notes.write_note(
title="Memory URL Test",
folder="test",
content="Testing memory:// URL handling",
)
assert result
# Should be able to read it with a memory:// URL
memory_url = f"memory://{permalink}"
memory_url = "memory://test/memory-url-test"
content = await notes.read_note(memory_url)
assert "Testing memory:// URL handling" in content
+24 -2
View File
@@ -12,12 +12,13 @@ from basic_memory.schemas.search import SearchQuery, SearchItemType
async def test_search_basic(client):
"""Test basic search functionality."""
# Create a test note
permalink = await notes.write_note(
result = await notes.write_note(
title="Test Search Note",
folder="test",
content="# Test\nThis is a searchable test note",
tags=["test", "search"],
)
assert result
# Search for it
query = SearchQuery(text="searchable")
@@ -25,7 +26,28 @@ async def test_search_basic(client):
# Verify results
assert len(response.results) > 0
assert any(r.permalink == permalink for r in response.results)
assert any(r.permalink == "test/test-search-note" for r in response.results)
@pytest.mark.asyncio
async def test_search_pagination(client):
"""Test basic search functionality."""
# Create a test note
result = await notes.write_note(
title="Test Search Note",
folder="test",
content="# Test\nThis is a searchable test note",
tags=["test", "search"],
)
assert result
# Search for it
query = SearchQuery(text="searchable")
response = await search(query, page=1, page_size=1)
# Verify results
assert len(response.results) == 1
assert any(r.permalink == "test/test-search-note" for r in response.results)
@pytest.mark.asyncio
+19 -7
View File
@@ -51,16 +51,17 @@ def test_relation_in_validation():
def test_relation_response():
"""Test RelationResponse validation."""
data = {
"permalink": "test/123/relates_to/test/456",
"from_id": "test/123",
"to_id": "test/456",
"relation_type": "test",
"relation_type": "relates_to",
"from_entity": {"permalink": "test/123"},
"to_entity": {"permalink": "test/456"},
}
relation = RelationResponse.model_validate(data)
assert relation.from_id == "test/123"
assert relation.to_id == "test/456"
assert relation.relation_type == "test"
assert relation.relation_type == "relates_to"
assert relation.context is None
@@ -73,16 +74,27 @@ def test_entity_out_from_attributes():
"file_path": "test",
"entity_type": "knowledge",
"content_type": "text/markdown",
"observations": [{"id": 1, "category": "note", "content": "test obs", "context": None}],
"relations": [
"observations": [
{
"id": 1,
"from_id": "test/test",
"to_id": "test/test",
"relation_type": "test",
"permalink": "permalink",
"category": "note",
"content": "test obs",
"context": None,
}
],
"relations": [
{
"id": 1,
"permalink": "test/test/relates_to/test/test",
"from_id": "test/test",
"to_id": "test/test",
"relation_type": "relates_to",
"context": None,
}
],
"created_at": "2023-01-01T00:00:00",
"updated_at": "2023-01-01T00:00:00",
}
entity = EntityResponse.model_validate(db_data)
assert entity.permalink == "test/test"
+1 -1
View File
@@ -112,7 +112,7 @@ def test_search_response():
metadata={},
),
]
response = SearchResponse(results=results)
response = SearchResponse(results=results, current_page=1, page_size=1)
assert len(response.results) == 2
assert response.results[0].score > response.results[1].score
-12
View File
@@ -128,18 +128,6 @@ async def test_build_context(context_service, test_graph):
assert total_results == len(primary_results) + len(related_results)
@pytest.mark.skip("search prefix see:'https://sqlite.org/fts5.html#FTS5 Prefix Queries'")
@pytest.mark.asyncio
async def test_build_context_pattern(context_service, test_graph):
"""Test permalink wildcard lookup."""
# url = memory_url.validate_strings("memory://test/connected*")
# results = await context_service.build_context(url)
# matched_results = results["metadata"]["matched_results"]
# primary_results = results["primary_results"]
# related_results = results["related_results"]
# total_results = results["metadata"]["total_results"]
@pytest.mark.asyncio
async def test_build_context_not_found(context_service):
"""Test handling non-existent permalinks."""
+26 -37
View File
@@ -10,7 +10,17 @@ from basic_memory.services.link_resolver import LinkResolver
@pytest_asyncio.fixture
async def test_entities(entity_service, file_service):
"""Create a set of test entities."""
"""Create a set of test entities.
components
Auth Service.md
Core Service.md
config
Service Config.md
specs
Core Features.md
"""
e1, _ = await entity_service.create_or_update_entity(
EntitySchema(
@@ -40,6 +50,20 @@ async def test_entities(entity_service, file_service):
folder="specs",
)
)
e5, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Sub Features 1",
entity_type="specs",
folder="specs/subspec",
)
)
e6, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Sub Features 2",
entity_type="specs",
folder="specs/subspec",
)
)
return [e1, e2, e3, e4]
@@ -68,18 +92,11 @@ async def test_exact_title_match(link_resolver, test_entities):
assert entity.permalink == "components/core-service"
@pytest.mark.skip(reason="Fuzzy misspelling not yet implemented")
@pytest.mark.asyncio
async def test_fuzzy_title_match_misspelling(link_resolver):
# Test slight misspelling
result = await link_resolver.resolve_link("Core Servise")
assert result.permalink == "components/core-service"
@pytest.mark.asyncio
async def test_fuzzy_title_partial_match(link_resolver):
# Test partial match
result = await link_resolver.resolve_link("Auth Serv")
assert result is not None, "Did not find partial match"
assert result.permalink == "components/auth-service"
@@ -114,31 +131,3 @@ async def test_resolve_none(link_resolver):
"""Test resolving non-existent entity."""
# Basic new entity
assert await link_resolver.resolve_link("New Feature") is None
@pytest.mark.skip("Advanced relevance scoring not yet implemented")
@pytest.mark.asyncio
async def test_multiple_matches_resolution(link_resolver):
"""Test resolution when multiple potential matches exist."""
# Add some similar entities
test_cases = [
{
"link": "Service", # Ambiguous
"expected_prefix": "components/", # Should prefer component directory match
},
{
"link": "Core", # Ambiguous
"expected_prefix": "specs/", # Should prefer specs directory match
},
{
"link": "Service",
"expected": "components/core-service", # Should pick shortest/highest scored
},
]
for case in test_cases:
result = await link_resolver.resolve_link(case["link"])
if "expected_prefix" in case:
assert result.startswith(case["expected_prefix"])
else:
assert result == case["expected"]
+48 -11
View File
@@ -19,6 +19,25 @@ async def test_search_permalink(search_service, test_graph):
assert "test/root" in r.permalink
@pytest.mark.asyncio
async def test_search_limit_offset(search_service, test_graph):
"""Exact permalink"""
results = await search_service.search(SearchQuery(permalink_match="test/*"))
assert len(results) > 1
results = await search_service.search(SearchQuery(permalink_match="test/*"), limit=1)
assert len(results) == 1
results = await search_service.search(SearchQuery(permalink_match="test/*"), limit=100)
num_results = len(results)
# assert offset
offset_results = await search_service.search(
SearchQuery(permalink_match="test/*"), limit=100, offset=1
)
assert len(offset_results) == num_results - 1
@pytest.mark.asyncio
async def test_search_permalink_observations_wildcard(search_service, test_graph):
"""Pattern matching"""
@@ -32,21 +51,24 @@ async def test_search_permalink_observations_wildcard(search_service, test_graph
@pytest.mark.asyncio
async def test_search_permalink_relation_wildcard(search_service, test_graph):
"""Pattern matching"""
results = await search_service.search(SearchQuery(permalink_match="test/root/connects_to/*"))
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/connected-entity-1" in permalinks
@pytest.mark.skip("search prefix see:'https://sqlite.org/fts5.html#FTS5 Prefix Queries'")
@pytest.mark.asyncio
async def test_search_permalink_wildcard2(search_service, test_graph):
"""Pattern matching"""
results = await search_service.search(SearchQuery(permalink_match="test/connected*"))
assert len(results) == 2
results = await search_service.search(
SearchQuery(
permalink_match="test/connected*",
)
)
assert len(results) >= 2
permalinks = {r.permalink for r in results}
assert "test/connected1" in permalinks
assert "test/connected2" in permalinks
assert "test/connected-entity-1" in permalinks
assert "test/connected-entity-2" in permalinks
@pytest.mark.asyncio
@@ -68,17 +90,27 @@ async def test_search_title(search_service, test_graph):
@pytest.mark.asyncio
async def test_text_search_features(search_service, test_graph):
async def test_text_search_case_insensitive(search_service, test_graph):
"""Test text search functionality."""
# Case insensitive
results = await search_service.search(SearchQuery(text="ENTITY"))
assert any("test/root" in r.permalink for r in results)
# Partial word match
results = await search_service.search(SearchQuery(text="Connect"))
@pytest.mark.asyncio
async def test_text_search_content_word_match(search_service, test_graph):
"""Test text search functionality."""
# content word match
results = await search_service.search(SearchQuery(text="Connected"))
assert len(results) > 0
assert any(r.file_path == "test/Connected Entity 2.md" for r in results)
@pytest.mark.asyncio
async def test_text_search_multiple_terms(search_service, test_graph):
"""Test text search functionality."""
# Multiple terms
results = await search_service.search(SearchQuery(text="root note"))
assert any("test/root" in r.permalink for r in results)
@@ -88,15 +120,20 @@ async def test_text_search_features(search_service, test_graph):
async def test_pattern_matching(search_service, test_graph):
"""Test pattern matching with various wildcards."""
# Test wildcards
results = await search_service.search(SearchQuery(permalink="test/*"))
results = await search_service.search(SearchQuery(permalink_match="test/*"))
for r in results:
assert "test/" in r.permalink
# Test start wildcards
results = await search_service.search(SearchQuery(permalink="*/observations"))
results = await search_service.search(SearchQuery(permalink_match="*/observations"))
for r in results:
assert "/observations" in r.permalink
# Test permalink partial match
results = await search_service.search(SearchQuery(permalink_match="test"))
for r in results:
assert "test/" in r.permalink
@pytest.mark.asyncio
async def test_filters(search_service, test_graph):
+41 -2
View File
@@ -1,8 +1,47 @@
"""Tests for basic-memory package"""
import sys
import tomllib
import pytest
from frontmatter.default_handlers import toml
from basic_memory import __version__
from basic_memory.config import config
def read_toml_version(file_path):
try:
with open(file_path, "rb") as f:
if sys.version_info >= (3, 11):
data = tomllib.load(f)
else:
data = toml.load(f)
if "project" in data and "version" in data["project"]:
return data["project"]["version"]
else:
return None
except FileNotFoundError:
return None
except (toml.TomlDecodeError, tomllib.TOMLDecodeError):
return None
file_path = "pyproject.toml"
version = read_toml_version(file_path)
def test_version():
"""Test version is set"""
assert __version__ is not None
"""Test version is set in project src code and pyproject.toml"""
assert __version__ == version
def test_config_env():
"""Test the config env is set to test for pytest"""
assert config.env == "test"
@pytest.mark.asyncio
async def test_config_env_async():
"""Test the config env is set to test for async pytest"""
assert config.env == "test"
Generated
+403 -1
View File
@@ -59,6 +59,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 },
]
[[package]]
name = "asgiref"
version = "3.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 },
]
[[package]]
name = "asttokens"
version = "3.0.0"
@@ -70,7 +79,7 @@ wheels = [
[[package]]
name = "basic-memory"
version = "0.3.0"
version = "0.6.0"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -79,6 +88,7 @@ dependencies = [
{ name = "fastapi", extra = ["standard"] },
{ name = "greenlet" },
{ name = "icecream" },
{ name = "logfire", extra = ["fastapi", "httpx", "sqlalchemy", "sqlite3"] },
{ name = "loguru" },
{ name = "markdown-it-py" },
{ name = "mcp" },
@@ -116,6 +126,7 @@ requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.8" },
{ name = "greenlet", specifier = ">=3.1.1" },
{ name = "icecream", specifier = ">=2.1.3" },
{ name = "logfire", extras = ["fastapi", "httpx", "sqlalchemy", "sqlite3"], specifier = ">=3.6.0" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "markdown-it-py", specifier = ">=3.0.0" },
{ name = "mcp", specifier = ">=1.2.0" },
@@ -169,6 +180,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
]
[[package]]
name = "charset-normalizer"
version = "3.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 },
{ url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 },
{ url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 },
{ url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 },
{ url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 },
{ url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 },
{ url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 },
{ url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 },
{ url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 },
{ url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 },
{ url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 },
{ url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 },
{ url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 },
{ url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 },
{ url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 },
{ url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 },
{ url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 },
{ url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 },
{ url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 },
{ url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 },
{ url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 },
{ url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 },
{ url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 },
{ url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 },
{ url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 },
{ url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 },
{ url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 },
]
[[package]]
name = "click"
version = "8.1.8"
@@ -281,6 +327,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/0a/981c438c4cd84147c781e4e96c1d72df03775deb1bc76c5a6ee8afa89c62/dateparser-1.2.1-py3-none-any.whl", hash = "sha256:bdcac262a467e6260030040748ad7c10d6bacd4f3b9cdb4cfd2251939174508c", size = 295658 },
]
[[package]]
name = "deprecated"
version = "1.2.18"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998 },
]
[[package]]
name = "dmgbuild"
version = "1.6.4"
@@ -419,6 +477,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/e6/51824bd1f2c1ce70aa01495aa6ffe04ab789fa819fa7e6f0ad2388fb03c6/gevent-24.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:ec68e270543ecd532c4c1d70fca020f90aa5486ad49c4f3b8b2e64a66f5c9274", size = 1540088 },
]
[[package]]
name = "googleapis-common-protos"
version = "1.67.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/31/e1/fbffb85a624f1404133b5bb624834e77e0f549e2b8548146fe18c56e1411/googleapis_common_protos-1.67.0.tar.gz", hash = "sha256:21398025365f138be356d5923e9168737d94d46a72aefee4a6110a1f23463c86", size = 57344 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/30/2bd0eb03a7dee7727cd2ec643d1e992979e62d5e7443507381cce0455132/googleapis_common_protos-1.67.0-py2.py3-none-any.whl", hash = "sha256:579de760800d13616f51cf8be00c876f00a9f146d3e6510e19d1f4111758b741", size = 164985 },
]
[[package]]
name = "greenlet"
version = "3.1.1"
@@ -544,6 +614,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]]
name = "importlib-metadata"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514 },
]
[[package]]
name = "iniconfig"
version = "2.0.0"
@@ -576,6 +658,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/51/be/34b8864d0976258f29a5e3d7bedb2785fd56409cf866813458bfd32d2a6b/lief-0.16.3-cp313-cp313-win_amd64.whl", hash = "sha256:52dc05445d8019b61a9ab8c6eb9d6238c4346ac692dcecca76d5f329a999216e", size = 3167188 },
]
[[package]]
name = "logfire"
version = "3.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "executing" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-sdk" },
{ name = "protobuf" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2b/03/3ed1bab39509c0d9bd1048387bceee75e6229c520eec9f7200ff85714b91/logfire-3.6.0.tar.gz", hash = "sha256:065a12dc727c92450bb05c0bf17b6ca36d1ee1ba99b6ca53d0f88d877ac863d9", size = 268596 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/7f/f1baa1f8ba5e5a8a1a892b42589a0e51878db0385893b043cf75734c3b7e/logfire-3.6.0-py3-none-any.whl", hash = "sha256:9d215ede261cb579d4bad77ff3419ee17145a2a87a9864e1fda21f3143fa2307", size = 180916 },
]
[package.optional-dependencies]
fastapi = [
{ name = "opentelemetry-instrumentation-fastapi" },
]
httpx = [
{ name = "opentelemetry-instrumentation-httpx" },
]
sqlalchemy = [
{ name = "opentelemetry-instrumentation-sqlalchemy" },
]
sqlite3 = [
{ name = "opentelemetry-instrumentation-sqlite3" },
]
[[package]]
name = "loguru"
version = "0.7.3"
@@ -697,6 +811,205 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 },
]
[[package]]
name = "opentelemetry-api"
version = "1.30.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deprecated" },
{ name = "importlib-metadata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2b/6d/bbbf879826b7f3c89a45252010b5796fb1f1a0d45d9dc4709db0ef9a06c8/opentelemetry_api-1.30.0.tar.gz", hash = "sha256:375893400c1435bf623f7dfb3bcd44825fe6b56c34d0667c542ea8257b1a1240", size = 63703 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/0a/eea862fae6413d8181b23acf8e13489c90a45f17986ee9cf4eab8a0b9ad9/opentelemetry_api-1.30.0-py3-none-any.whl", hash = "sha256:d5f5284890d73fdf47f843dda3210edf37a38d66f44f2b5aedc1e89ed455dc09", size = 64955 },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.30.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-proto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/d7/44098bf1ef89fc5810cdbda05faa2ae9322a0dbda4921cdc965dc68a9856/opentelemetry_exporter_otlp_proto_common-1.30.0.tar.gz", hash = "sha256:ddbfbf797e518411857d0ca062c957080279320d6235a279f7b64ced73c13897", size = 19640 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/54/f4b3de49f8d7d3a78fd6e6e1a6fd27dd342eb4d82c088b9078c6a32c3808/opentelemetry_exporter_otlp_proto_common-1.30.0-py3-none-any.whl", hash = "sha256:5468007c81aa9c44dc961ab2cf368a29d3475977df83b4e30aeed42aa7bc3b38", size = 18747 },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.30.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deprecated" },
{ name = "googleapis-common-protos" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-common" },
{ name = "opentelemetry-proto" },
{ name = "opentelemetry-sdk" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/f9/abb9191d536e6a2e2b7903f8053bf859a76bf784e3ca19a5749550ef19e4/opentelemetry_exporter_otlp_proto_http-1.30.0.tar.gz", hash = "sha256:c3ae75d4181b1e34a60662a6814d0b94dd33b628bee5588a878bed92cee6abdc", size = 15073 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/3c/cdf34bc459613f2275aff9b258f35acdc4c4938dad161d17437de5d4c034/opentelemetry_exporter_otlp_proto_http-1.30.0-py3-none-any.whl", hash = "sha256:9578e790e579931c5ffd50f1e6975cbdefb6a0a0a5dea127a6ae87df10e0a589", size = 17245 },
]
[[package]]
name = "opentelemetry-instrumentation"
version = "0.51b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "packaging" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ec/5a/4c7f02235ac1269b48f3855f6be1afc641f31d4888d28b90b732fbce7141/opentelemetry_instrumentation-0.51b0.tar.gz", hash = "sha256:4ca266875e02f3988536982467f7ef8c32a38b8895490ddce9ad9604649424fa", size = 27760 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/2c/48fa93f1acca9f79a06da0df7bfe916632ecc7fce1971067b3e46bcae55b/opentelemetry_instrumentation-0.51b0-py3-none-any.whl", hash = "sha256:c6de8bd26b75ec8b0e54dff59e198946e29de6a10ec65488c357d4b34aa5bdcf", size = 30923 },
]
[[package]]
name = "opentelemetry-instrumentation-asgi"
version = "0.51b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "opentelemetry-util-http" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/67/8aa6e1129f641f0f3f8786e6c5d18c1f2bbe490bd4b0e91a6879e85154d2/opentelemetry_instrumentation_asgi-0.51b0.tar.gz", hash = "sha256:b3fe97c00f0bfa934371a69674981d76591c68d937b6422a5716ca21081b4148", size = 24201 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/7e/0a95ab37302729543631a789ba8e71dea75c520495739dbbbdfdc580b401/opentelemetry_instrumentation_asgi-0.51b0-py3-none-any.whl", hash = "sha256:e8072993db47303b633c6ec1bc74726ba4d32bd0c46c28dfadf99f79521a324c", size = 16340 },
]
[[package]]
name = "opentelemetry-instrumentation-dbapi"
version = "0.51b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/84/b7/fdc107617b9f626632f5fbe444a6a91efa4a9d1e38447500802b8a12010c/opentelemetry_instrumentation_dbapi-0.51b0.tar.gz", hash = "sha256:740b5e17eef02a91a8d3966f06e5605817a7d875ae4d9dec8318ef652ccfc1fe", size = 13860 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/13/d3cd0292680ebd54ed6d55d7a81434bc2c6f7327d971c6690c98114d6abc/opentelemetry_instrumentation_dbapi-0.51b0-py3-none-any.whl", hash = "sha256:1b4dfb4f25b4ef509b70fb24c637436a40fe5fc8204933b956f1d0ccaa61735f", size = 12373 },
]
[[package]]
name = "opentelemetry-instrumentation-fastapi"
version = "0.51b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-instrumentation-asgi" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "opentelemetry-util-http" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2d/dc/8db4422b5084177d1ef6c7855c69bf2e9e689f595a4a9b59e60588e0d427/opentelemetry_instrumentation_fastapi-0.51b0.tar.gz", hash = "sha256:1624e70f2f4d12ceb792d8a0c331244cd6723190ccee01336273b4559bc13abc", size = 19249 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/1c/ec2d816b78edf2404d7b3df6d09eefb690b70bfd191b7da06f76634f1bdc/opentelemetry_instrumentation_fastapi-0.51b0-py3-none-any.whl", hash = "sha256:10513bbc11a1188adb9c1d2c520695f7a8f2b5f4de14e8162098035901cd6493", size = 12117 },
]
[[package]]
name = "opentelemetry-instrumentation-httpx"
version = "0.51b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "opentelemetry-util-http" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/d5/4a3990c461ae7e55212115e0f8f3aa412b5ce6493579e85c292245ac69ea/opentelemetry_instrumentation_httpx-0.51b0.tar.gz", hash = "sha256:061d426a04bf5215a859fea46662e5074f920e5cbde7e6ad6825a0a1b595802c", size = 17700 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/ba/23d4ab6402408c01f1c3f32e0c04ea6dae575bf19bcb9a0049c9e768c983/opentelemetry_instrumentation_httpx-0.51b0-py3-none-any.whl", hash = "sha256:2e3fdf755ba6ead6ab43031497c3d55d4c796d0368eccc0ce48d304b7ec6486a", size = 14109 },
]
[[package]]
name = "opentelemetry-instrumentation-sqlalchemy"
version = "0.51b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "packaging" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/b2/970b1b46576b663bba64503486afe266c064c2bfd1862876420714ce29d9/opentelemetry_instrumentation_sqlalchemy-0.51b0.tar.gz", hash = "sha256:dbfe95b69006017f903dda194606be458d54789e6b3419d37161fb8861bb98a5", size = 14582 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/d4/b68c3b3388dd5107f3ed532747e112249c152ba44af71a1f96673d66e3ee/opentelemetry_instrumentation_sqlalchemy-0.51b0-py3-none-any.whl", hash = "sha256:5ff4816228b496aef1511149e2b17a25e0faacec4d5eb65bf18a9964af40f1af", size = 14132 },
]
[[package]]
name = "opentelemetry-instrumentation-sqlite3"
version = "0.51b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-instrumentation-dbapi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e8/2a/1755f34fd1d58858272970ce9f8386a488ce2aa16c2673373ed31cc60d33/opentelemetry_instrumentation_sqlite3-0.51b0.tar.gz", hash = "sha256:3bd5dbe2292a68b27b79c44a13a03b1443341404e02351d3886ee6526792ead1", size = 7930 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/d0/6288eb2b6065b7766eee545729e6e68ac241ce82ec60a8452742414536c7/opentelemetry_instrumentation_sqlite3-0.51b0-py3-none-any.whl", hash = "sha256:77418bfec1b45f4d44a9a316c355aab33d36eb7cc1cd5d871f40acae36ae5c96", size = 9339 },
]
[[package]]
name = "opentelemetry-proto"
version = "1.30.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/31/6e/c1ff2e3b0cd3a189a6be03fd4d63441d73d7addd9117ab5454e667b9b6c7/opentelemetry_proto-1.30.0.tar.gz", hash = "sha256:afe5c9c15e8b68d7c469596e5b32e8fc085eb9febdd6fb4e20924a93a0389179", size = 34362 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/d7/85de6501f7216995295f7ec11e470142e6a6e080baacec1753bbf272e007/opentelemetry_proto-1.30.0-py3-none-any.whl", hash = "sha256:c6290958ff3ddacc826ca5abbeb377a31c2334387352a259ba0df37c243adc11", size = 55854 },
]
[[package]]
name = "opentelemetry-sdk"
version = "1.30.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/93/ee/d710062e8a862433d1be0b85920d0c653abe318878fef2d14dfe2c62ff7b/opentelemetry_sdk-1.30.0.tar.gz", hash = "sha256:c9287a9e4a7614b9946e933a67168450b9ab35f08797eb9bc77d998fa480fa18", size = 158633 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/28/64d781d6adc6bda2260067ce2902bd030cf45aec657e02e28c5b4480b976/opentelemetry_sdk-1.30.0-py3-none-any.whl", hash = "sha256:14fe7afc090caad881addb6926cec967129bd9260c4d33ae6a217359f6b61091", size = 118717 },
]
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.51b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deprecated" },
{ name = "opentelemetry-api" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1e/c0/0f9ef4605fea7f2b83d55dd0b0d7aebe8feead247cd6facd232b30907b4f/opentelemetry_semantic_conventions-0.51b0.tar.gz", hash = "sha256:3fabf47f35d1fd9aebcdca7e6802d86bd5ebc3bc3408b7e3248dde6e87a18c47", size = 107191 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/75/d7bdbb6fd8630b4cafb883482b75c4fc276b6426619539d266e32ac53266/opentelemetry_semantic_conventions-0.51b0-py3-none-any.whl", hash = "sha256:fdc777359418e8d06c86012c3dc92c88a6453ba662e941593adb062e48c2eeae", size = 177416 },
]
[[package]]
name = "opentelemetry-util-http"
version = "0.51b0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/64/32510c0a803465eb6ef1f5bd514d0f5627f8abc9444ed94f7240faf6fcaa/opentelemetry_util_http-0.51b0.tar.gz", hash = "sha256:05edd19ca1cc3be3968b1e502fd94816901a365adbeaab6b6ddb974384d3a0b9", size = 8043 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/dd/c371eeb9cc78abbdad231a27ce1a196a37ef96328d876ccbb381dea4c8ee/opentelemetry_util_http-0.51b0-py3-none-any.whl", hash = "sha256:0561d7a6e9c422b9ef9ae6e77eafcfcd32a2ab689f5e801475cbb67f189efa20", size = 7304 },
]
[[package]]
name = "packaging"
version = "24.2"
@@ -729,6 +1042,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
]
[[package]]
name = "protobuf"
version = "5.29.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/d1/e0a911544ca9993e0f17ce6d3cc0932752356c1b0a834397f28e63479344/protobuf-5.29.3.tar.gz", hash = "sha256:5da0f41edaf117bde316404bad1a486cb4ededf8e4a54891296f648e8e076620", size = 424945 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/7a/1e38f3cafa022f477ca0f57a1f49962f21ad25850c3ca0acd3b9d0091518/protobuf-5.29.3-cp310-abi3-win32.whl", hash = "sha256:3ea51771449e1035f26069c4c7fd51fba990d07bc55ba80701c78f886bf9c888", size = 422708 },
{ url = "https://files.pythonhosted.org/packages/61/fa/aae8e10512b83de633f2646506a6d835b151edf4b30d18d73afd01447253/protobuf-5.29.3-cp310-abi3-win_amd64.whl", hash = "sha256:a4fa6f80816a9a0678429e84973f2f98cbc218cca434abe8db2ad0bffc98503a", size = 434508 },
{ url = "https://files.pythonhosted.org/packages/dd/04/3eaedc2ba17a088961d0e3bd396eac764450f431621b58a04ce898acd126/protobuf-5.29.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a8434404bbf139aa9e1300dbf989667a83d42ddda9153d8ab76e0d5dcaca484e", size = 417825 },
{ url = "https://files.pythonhosted.org/packages/4f/06/7c467744d23c3979ce250397e26d8ad8eeb2bea7b18ca12ad58313c1b8d5/protobuf-5.29.3-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:daaf63f70f25e8689c072cfad4334ca0ac1d1e05a92fc15c54eb9cf23c3efd84", size = 319573 },
{ url = "https://files.pythonhosted.org/packages/a8/45/2ebbde52ad2be18d3675b6bee50e68cd73c9e0654de77d595540b5129df8/protobuf-5.29.3-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:c027e08a08be10b67c06bf2370b99c811c466398c357e615ca88c91c07f0910f", size = 319672 },
{ url = "https://files.pythonhosted.org/packages/fd/b2/ab07b09e0f6d143dfb839693aa05765257bceaa13d03bf1a696b78323e7a/protobuf-5.29.3-py3-none-any.whl", hash = "sha256:0a18ed4a24198528f2333802eb075e59dea9d679ab7a6c5efb017a59004d849f", size = 172550 },
]
[[package]]
name = "pycparser"
version = "2.22"
@@ -1058,6 +1385,21 @@ wheels = [
{ 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"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
]
[[package]]
name = "rich"
version = "13.9.4"
@@ -1254,6 +1596,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/84/b7/6ec57841fb67c98f52fc8e4a2d96df60059637cba077edc569a302a8ffc7/Unidecode-1.3.8-py3-none-any.whl", hash = "sha256:d130a61ce6696f8148a3bd8fe779c99adeb4b870584eeb9526584e9aa091fd39", size = 235494 },
]
[[package]]
name = "urllib3"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 },
]
[[package]]
name = "uvicorn"
version = "0.34.0"
@@ -1374,6 +1725,57 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 },
]
[[package]]
name = "wrapt"
version = "1.17.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/bd/ab55f849fd1f9a58ed7ea47f5559ff09741b25f00c191231f9f059c83949/wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925", size = 53799 },
{ url = "https://files.pythonhosted.org/packages/53/18/75ddc64c3f63988f5a1d7e10fb204ffe5762bc663f8023f18ecaf31a332e/wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392", size = 38821 },
{ url = "https://files.pythonhosted.org/packages/48/2a/97928387d6ed1c1ebbfd4efc4133a0633546bec8481a2dd5ec961313a1c7/wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40", size = 38919 },
{ url = "https://files.pythonhosted.org/packages/73/54/3bfe5a1febbbccb7a2f77de47b989c0b85ed3a6a41614b104204a788c20e/wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d", size = 88721 },
{ url = "https://files.pythonhosted.org/packages/25/cb/7262bc1b0300b4b64af50c2720ef958c2c1917525238d661c3e9a2b71b7b/wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b", size = 80899 },
{ url = "https://files.pythonhosted.org/packages/2a/5a/04cde32b07a7431d4ed0553a76fdb7a61270e78c5fd5a603e190ac389f14/wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98", size = 89222 },
{ url = "https://files.pythonhosted.org/packages/09/28/2e45a4f4771fcfb109e244d5dbe54259e970362a311b67a965555ba65026/wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82", size = 86707 },
{ url = "https://files.pythonhosted.org/packages/c6/d2/dcb56bf5f32fcd4bd9aacc77b50a539abdd5b6536872413fd3f428b21bed/wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae", size = 79685 },
{ url = "https://files.pythonhosted.org/packages/80/4e/eb8b353e36711347893f502ce91c770b0b0929f8f0bed2670a6856e667a9/wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9", size = 87567 },
{ url = "https://files.pythonhosted.org/packages/17/27/4fe749a54e7fae6e7146f1c7d914d28ef599dacd4416566c055564080fe2/wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9", size = 36672 },
{ url = "https://files.pythonhosted.org/packages/15/06/1dbf478ea45c03e78a6a8c4be4fdc3c3bddea5c8de8a93bc971415e47f0f/wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991", size = 38865 },
{ url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800 },
{ url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824 },
{ url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920 },
{ url = "https://files.pythonhosted.org/packages/3b/24/11c4510de906d77e0cfb5197f1b1445d4fec42c9a39ea853d482698ac681/wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8", size = 88690 },
{ url = "https://files.pythonhosted.org/packages/71/d7/cfcf842291267bf455b3e266c0c29dcb675b5540ee8b50ba1699abf3af45/wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6", size = 80861 },
{ url = "https://files.pythonhosted.org/packages/d5/66/5d973e9f3e7370fd686fb47a9af3319418ed925c27d72ce16b791231576d/wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc", size = 89174 },
{ url = "https://files.pythonhosted.org/packages/a7/d3/8e17bb70f6ae25dabc1aaf990f86824e4fd98ee9cadf197054e068500d27/wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2", size = 86721 },
{ url = "https://files.pythonhosted.org/packages/6f/54/f170dfb278fe1c30d0ff864513cff526d624ab8de3254b20abb9cffedc24/wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b", size = 79763 },
{ url = "https://files.pythonhosted.org/packages/4a/98/de07243751f1c4a9b15c76019250210dd3486ce098c3d80d5f729cba029c/wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504", size = 87585 },
{ url = "https://files.pythonhosted.org/packages/f9/f0/13925f4bd6548013038cdeb11ee2cbd4e37c30f8bfd5db9e5a2a370d6e20/wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a", size = 36676 },
{ url = "https://files.pythonhosted.org/packages/bf/ae/743f16ef8c2e3628df3ddfd652b7d4c555d12c84b53f3d8218498f4ade9b/wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845", size = 38871 },
{ url = "https://files.pythonhosted.org/packages/3d/bc/30f903f891a82d402ffb5fda27ec1d621cc97cb74c16fea0b6141f1d4e87/wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192", size = 56312 },
{ url = "https://files.pythonhosted.org/packages/8a/04/c97273eb491b5f1c918857cd26f314b74fc9b29224521f5b83f872253725/wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b", size = 40062 },
{ url = "https://files.pythonhosted.org/packages/4e/ca/3b7afa1eae3a9e7fefe499db9b96813f41828b9fdb016ee836c4c379dadb/wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0", size = 40155 },
{ url = "https://files.pythonhosted.org/packages/89/be/7c1baed43290775cb9030c774bc53c860db140397047cc49aedaf0a15477/wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306", size = 113471 },
{ url = "https://files.pythonhosted.org/packages/32/98/4ed894cf012b6d6aae5f5cc974006bdeb92f0241775addad3f8cd6ab71c8/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb", size = 101208 },
{ url = "https://files.pythonhosted.org/packages/ea/fd/0c30f2301ca94e655e5e057012e83284ce8c545df7661a78d8bfca2fac7a/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681", size = 109339 },
{ url = "https://files.pythonhosted.org/packages/75/56/05d000de894c4cfcb84bcd6b1df6214297b8089a7bd324c21a4765e49b14/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6", size = 110232 },
{ url = "https://files.pythonhosted.org/packages/53/f8/c3f6b2cf9b9277fb0813418e1503e68414cd036b3b099c823379c9575e6d/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6", size = 100476 },
{ url = "https://files.pythonhosted.org/packages/a7/b1/0bb11e29aa5139d90b770ebbfa167267b1fc548d2302c30c8f7572851738/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f", size = 106377 },
{ url = "https://files.pythonhosted.org/packages/6a/e1/0122853035b40b3f333bbb25f1939fc1045e21dd518f7f0922b60c156f7c/wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", size = 37986 },
{ url = "https://files.pythonhosted.org/packages/09/5e/1655cf481e079c1f22d0cabdd4e51733679932718dc23bf2db175f329b76/wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", size = 40750 },
{ url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594 },
]
[[package]]
name = "zipp"
version = "3.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/50/bad581df71744867e9468ebd0bcd6505de3b275e06f202c2cb016e3ff56f/zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", size = 24545 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/1a/7e4798e9339adc931158c9d69ecc34f5e6791489d469f5e50ec15e35f458/zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931", size = 9630 },
]
[[package]]
name = "zope-event"
version = "5.0"