Compare commits

...

7 Commits

Author SHA1 Message Date
semantic-release c06cf19e62 chore(release): 0.3.0 [skip ci] 2025-02-15 20:43:17 +00:00
phernandez 41f5b1667b format code 2025-02-15 14:38:19 -06:00
phernandez ca632beb6f fix: refactor db schema migrate handling 2025-02-15 14:36:12 -06:00
phernandez a491c2b7d4 Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-02-15 12:36:20 -06:00
phernandez 674dd1fd47 fix: remove create schema from init_db 2025-02-15 12:36:08 -06:00
phernandez a91da13967 feat: set version in var, output version at startup 2025-02-15 12:26:48 -06:00
phernandez e0803734e6 fix: handle memory:// url format in read_note tool 2025-02-15 12:00:03 -06:00
19 changed files with 145 additions and 130 deletions
+1
View File
@@ -42,3 +42,4 @@ ENV/
# macOS
.DS_Store
/.coverage.*
+19
View File
@@ -1,6 +1,14 @@
# CHANGELOG
## v0.3.0 (2025-02-15)
### Bug Fixes
- Refactor db schema migrate handling
([`ca632be`](https://github.com/basicmachines-co/basic-memory/commit/ca632beb6fed5881f4d8ba5ce698bb5bc681e6aa))
## v0.2.21 (2025-02-15)
### Bug Fixes
@@ -8,6 +16,17 @@
- Fix osx installer github action
([`65ebe5d`](https://github.com/basicmachines-co/basic-memory/commit/65ebe5d19491e5ff047c459d799498ad5dd9cd1a))
- Handle memory:// url format in read_note tool
([`e080373`](https://github.com/basicmachines-co/basic-memory/commit/e0803734e69eeb6c6d7432eea323c7a264cb8347))
- Remove create schema from init_db
([`674dd1f`](https://github.com/basicmachines-co/basic-memory/commit/674dd1fd47be9e60ac17508476c62254991df288))
### Features
- Set version in var, output version at startup
([`a91da13`](https://github.com/basicmachines-co/basic-memory/commit/a91da1396710e62587df1284da00137d156fc05e))
## v0.2.20 (2025-02-14)
+1
View File
@@ -8,6 +8,7 @@ if sys.platform == "darwin":
import tkinter as tk
from tkinter import messagebox
def ensure_uv_installed():
"""Check if uv is installed, install if not."""
try:
+8 -15
View File
@@ -4,31 +4,24 @@ import sys
# Build options for all platforms
build_exe_options = {
"packages": ["json", "pathlib"],
"excludes": [
"unittest",
"pydoc",
"test"
],
"excludes": ["unittest", "pydoc", "test"],
}
# Platform-specific options
if sys.platform == "win32":
base = "Win32GUI" # Use GUI base for Windows
build_exe_options.update({
"include_msvcr": True,
})
build_exe_options.update(
{
"include_msvcr": True,
}
)
target_name = "Basic Memory Installer.exe"
else: # darwin
base = None # Don't use GUI base for macOS
target_name = "Basic Memory Installer"
executables = [
Executable(
script="installer.py",
target_name=target_name,
base=base,
icon="Basic.icns"
)
Executable(script="installer.py", target_name=target_name, base=base, icon="Basic.icns")
]
setup(
@@ -41,7 +34,7 @@ setup(
"bundle_name": "Basic Memory Installer",
"iconfile": "Basic.icns",
"codesign_identity": "-", # Force ad-hoc signing
}
},
},
executables=executables,
)
+4 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "basic-memory"
version = "0.2.21"
version = "0.3.0"
description = "Local-first knowledge management combining Zettelkasten with knowledge graphs"
readme = "README.md"
requires-python = ">=3.12.1"
@@ -84,7 +84,9 @@ pythonVersion = "3.12"
[tool.semantic_release]
version_variable = "src/basic_memory/__init__.py:__version__"
version_variables = [
"src/basic_memory/__init__.py:__version__",
]
version_toml = [
"pyproject.toml:project.version",
]
+1 -1
View File
@@ -1,3 +1,3 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
__version__ = "0.0.1"
__version__ = "0.3.0"
+3 -24
View File
@@ -6,38 +6,17 @@ from fastapi import FastAPI, HTTPException
from fastapi.exception_handlers import http_exception_handler
from loguru import logger
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 alembic import command
from alembic.config import Config
from basic_memory.db import DatabaseType
from basic_memory.repository.search_repository import SearchRepository
async def run_migrations(): # pragma: no cover
"""Run any pending alembic migrations."""
logger.info("Running database migrations...")
try:
config = Config("alembic.ini")
command.upgrade(config, "head")
logger.info("Migrations completed successfully")
_, session_maker = await db.get_or_create_db(
app_config.database_path, DatabaseType.FILESYSTEM
)
await SearchRepository(session_maker).init_search_index()
except Exception as e:
logger.error(f"Error running migrations: {e}")
raise
@asynccontextmanager
async def lifespan(app: FastAPI): # pragma: no cover
"""Lifecycle manager for the FastAPI app."""
logger.info("Starting Basic Memory API")
await run_migrations()
logger.info(f"Starting Basic Memory API {basic_memory.__version__}")
await db.run_migrations(app_config)
yield
logger.info("Shutting down Basic Memory API")
await db.shutdown_db()
+10
View File
@@ -1,3 +1,13 @@
import asyncio
import typer
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
asyncio.run(db.run_migrations(config))
app = typer.Typer()
+1 -1
View File
@@ -22,4 +22,4 @@ def reset(
from basic_memory.cli.commands.sync import sync
logger.info("Rebuilding search index from filesystem...")
asyncio.run(sync()) # pyright: ignore
sync(watch=False) # pyright: ignore
+2 -2
View File
@@ -12,9 +12,9 @@ import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
@app.command()
def mcp():
def mcp(): # pragma: no cover
"""Run the MCP server for Claude Desktop integration."""
home_dir = config.home
logger.info("Starting Basic Memory MCP server")
logger.info(f"Starting Basic Memory MCP server {basic_memory.__version__}")
logger.info(f"Home directory: {home_dir}")
mcp_server.run()
+5 -7
View File
@@ -25,13 +25,11 @@ async def get_file_change_scanner(
db_type=DatabaseType.FILESYSTEM,
) -> FileChangeScanner: # pragma: no cover
"""Get sync service instance."""
async with db.engine_session_factory(db_path=config.database_path, db_type=db_type) as (
engine,
session_maker,
):
entity_repository = EntityRepository(session_maker)
file_change_scanner = FileChangeScanner(entity_repository)
return file_change_scanner
_, session_maker = await db.get_or_create_db(db_path=config.database_path, db_type=db_type)
entity_repository = EntityRepository(session_maker)
file_change_scanner = FileChangeScanner(entity_repository)
return file_change_scanner
def add_files_to_tree(
+38 -38
View File
@@ -14,7 +14,6 @@ from rich.tree import Tree
from basic_memory import db
from basic_memory.cli.app import app
from basic_memory.config import config
from basic_memory.db import DatabaseType
from basic_memory.markdown import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.repository import (
@@ -39,50 +38,50 @@ class ValidationIssue:
error: str
async def get_sync_service(db_type=DatabaseType.FILESYSTEM): # pragma: no cover
async def get_sync_service(): # pragma: no cover
"""Get sync service instance with all dependencies."""
async with db.engine_session_factory(db_path=config.database_path, db_type=db_type) as (
engine,
session_maker,
):
entity_parser = EntityParser(config.home)
markdown_processor = MarkdownProcessor(entity_parser)
file_service = FileService(config.home, markdown_processor)
_, session_maker = await db.get_or_create_db(
db_path=config.database_path, db_type=db.DatabaseType.FILESYSTEM
)
# Initialize repositories
entity_repository = EntityRepository(session_maker)
observation_repository = ObservationRepository(session_maker)
relation_repository = RelationRepository(session_maker)
search_repository = SearchRepository(session_maker)
entity_parser = EntityParser(config.home)
markdown_processor = MarkdownProcessor(entity_parser)
file_service = FileService(config.home, markdown_processor)
# Initialize services
search_service = SearchService(search_repository, entity_repository, file_service)
link_resolver = LinkResolver(entity_repository, search_service)
# Initialize repositories
entity_repository = EntityRepository(session_maker)
observation_repository = ObservationRepository(session_maker)
relation_repository = RelationRepository(session_maker)
search_repository = SearchRepository(session_maker)
# Initialize scanner
file_change_scanner = FileChangeScanner(entity_repository)
# Initialize services
search_service = SearchService(search_repository, entity_repository, file_service)
link_resolver = LinkResolver(entity_repository, search_service)
# Initialize services
entity_service = EntityService(
entity_parser,
entity_repository,
observation_repository,
relation_repository,
file_service,
link_resolver,
)
# Initialize scanner
file_change_scanner = FileChangeScanner(entity_repository)
# Create sync service
sync_service = SyncService(
scanner=file_change_scanner,
entity_service=entity_service,
entity_parser=entity_parser,
entity_repository=entity_repository,
relation_repository=relation_repository,
search_service=search_service,
)
# Initialize services
entity_service = EntityService(
entity_parser,
entity_repository,
observation_repository,
relation_repository,
file_service,
link_resolver,
)
return sync_service
# Create sync service
sync_service = SyncService(
scanner=file_change_scanner,
entity_service=entity_service,
entity_parser=entity_parser,
entity_repository=entity_repository,
relation_repository=relation_repository,
search_service=search_service,
)
return sync_service
def group_issues_by_directory(issues: List[ValidationIssue]) -> Dict[str, List[ValidationIssue]]:
@@ -154,6 +153,7 @@ def display_detailed_sync_results(knowledge: SyncReport):
async def run_sync(verbose: bool = False, watch: bool = False):
"""Run sync operation."""
sync_service = await get_sync_service()
# Start watching if requested
+21 -28
View File
@@ -4,6 +4,10 @@ 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
from loguru import logger
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
@@ -14,8 +18,7 @@ from sqlalchemy.ext.asyncio import (
async_scoped_session,
)
from basic_memory.models import Base
from basic_memory.models.search import CREATE_SEARCH_INDEX
from basic_memory.repository.search_repository import SearchRepository
# Module level state
_engine: Optional[AsyncEngine] = None
@@ -35,7 +38,7 @@ class DatabaseType(Enum):
logger.info("Using in-memory SQLite database")
return "sqlite+aiosqlite://"
return f"sqlite+aiosqlite:///{db_path}"
return f"sqlite+aiosqlite:///{db_path}" # pragma: no cover
def get_scoped_session_factory(
@@ -69,24 +72,6 @@ async def scoped_session(
await factory.remove()
async def init_db() -> None:
"""Initialize database with required tables."""
if _session_maker is None: # pragma: no cover
raise RuntimeError("Database session maker not initialized")
logger.info("Initializing database...")
async with scoped_session(_session_maker) as session:
await session.execute(text("PRAGMA foreign_keys=ON"))
conn = await session.connection()
await conn.run_sync(Base.metadata.create_all)
# recreate search index
await session.execute(CREATE_SEARCH_INDEX)
await session.commit()
async def get_or_create_db(
db_path: Path,
db_type: DatabaseType = DatabaseType.FILESYSTEM,
@@ -100,9 +85,6 @@ async def get_or_create_db(
_engine = create_async_engine(db_url, connect_args={"check_same_thread": False})
_session_maker = async_sessionmaker(_engine, expire_on_commit=False)
# Initialize database
await init_db()
assert _engine is not None # for type checker
assert _session_maker is not None # for type checker
return _engine, _session_maker
@@ -122,7 +104,6 @@ async def shutdown_db() -> None: # pragma: no cover
async def engine_session_factory(
db_path: Path,
db_type: DatabaseType = DatabaseType.MEMORY,
init: bool = True,
) -> AsyncGenerator[tuple[AsyncEngine, async_sessionmaker[AsyncSession]], None]:
"""Create engine and session factory.
@@ -139,9 +120,6 @@ async def engine_session_factory(
try:
_session_maker = async_sessionmaker(_engine, expire_on_commit=False)
if init:
await init_db()
assert _engine is not None # for type checker
assert _session_maker is not None # for type checker
yield _engine, _session_maker
@@ -150,3 +128,18 @@ async def engine_session_factory(
await _engine.dispose()
_engine = None
_session_maker = None
async def run_migrations(app_config: ProjectConfig, database_type=DatabaseType.FILESYSTEM):
"""Run any pending alembic migrations."""
logger.info("Running database migrations...")
try:
config = Config("alembic.ini")
command.upgrade(config, "head")
logger.info("Migrations completed successfully")
_, session_maker = await get_or_create_db(app_config.database_path, database_type)
await SearchRepository(session_maker).init_search_index()
except Exception as e: # pragma: no cover
logger.error(f"Error running migrations: {e}")
raise
+1 -1
View File
@@ -2,7 +2,7 @@ from httpx import ASGITransport, AsyncClient
from basic_memory.api.app import app as fastapi_app
BASE_URL = "http://test"
BASE_URL = "memory://"
# Create shared async client
client = AsyncClient(transport=ASGITransport(app=fastapi_app), base_url=BASE_URL)
+4 -1
View File
@@ -13,6 +13,7 @@ from basic_memory.mcp.async_client import client
from basic_memory.schemas import EntityResponse, DeleteEntitiesResponse
from basic_memory.schemas.base import Entity
from basic_memory.mcp.tools.utils import call_get, call_put, call_delete
from basic_memory.schemas.memory import memory_url_path
@mcp.tool(
@@ -96,7 +97,9 @@ async def read_note(identifier: str) -> str:
Raises:
ValueError: If the note cannot be found
"""
response = await call_get(client, f"/resource/{identifier}")
logger.info(f"Reading note {identifier}")
url = memory_url_path(identifier)
response = await call_get(client, f"/resource/{url}")
return response.text
+1 -4
View File
@@ -1,10 +1,7 @@
"""Types and utilities for file sync."""
from dataclasses import dataclass, field
from typing import Set, Dict, Optional
from watchfiles import Change
from typing import Set, Dict
@dataclass
+2 -5
View File
@@ -7,7 +7,6 @@ from datetime import datetime, timezone
import pytest
import pytest_asyncio
from loguru import logger
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine, async_sessionmaker
from basic_memory import db
@@ -59,10 +58,8 @@ async def engine_factory(
async with db.engine_session_factory(
db_path=test_config.database_path, db_type=DatabaseType.MEMORY
) as (engine, session_maker):
# Initialize database
async with db.scoped_session(session_maker) as session:
await session.execute(text("PRAGMA foreign_keys=ON"))
conn = await session.connection()
# Create all tables for the DB the engine is connected to
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine, session_maker
+22
View File
@@ -236,3 +236,25 @@ async def test_write_note_verbose(app):
assert entity.relations[0].from_id == "test/test-note"
assert entity.relations[0].to_id is None
assert entity.relations[0].to_name == "Knowledge"
@pytest.mark.asyncio
async def test_read_note_memory_url(app):
"""Test reading a note using a memory:// URL.
Should:
- Handle memory:// URLs correctly
- Normalize the URL before resolving
- Return the note content
"""
# First create a note
permalink = await notes.write_note(
title="Memory URL Test",
folder="test",
content="Testing memory:// URL handling",
)
# Should be able to read it with a memory:// URL
memory_url = f"memory://{permalink}"
content = await notes.read_note(memory_url)
assert "Testing memory:// URL handling" in content
Generated
+1 -1
View File
@@ -70,7 +70,7 @@ wheels = [
[[package]]
name = "basic-memory"
version = "0.2.19"
version = "0.2.21"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },