Compare commits

..

4 Commits

Author SHA1 Message Date
Drew Cain 9d9ea4d61c chore: update version to 0.17.7 for v0.17.7 release 2026-01-19 12:12:35 -06:00
Drew Cain 7a502e6474 feat: Add MCP registry publication files (#515)
Signed-off-by: Drew Cain <groksrc@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 11:57:50 -06:00
Paul Hernandez c7835a9d5c fix: ensure external_id is set on entity creation (#512) (#513)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 11:14:48 -06:00
Paul Hernandez 85835ae533 chore: Remove OpenPanel telemetry (#514)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 22:24:27 -06:00
35 changed files with 252 additions and 816 deletions
+31 -1
View File
@@ -78,6 +78,34 @@ The GitHub Actions workflow (`.github/workflows/release.yml`) then:
2. Verify formula version matches release
3. Test Homebrew installation: `brew install basicmachines-co/basic-memory/basic-memory`
#### MCP Registry Publication
After PyPI release is published, update the MCP registry:
1. **Verify PyPI Release**
- Confirm package is live: https://pypi.org/project/basic-memory/<version>/
- The `server.json` version was auto-updated by `just release`
2. **Publish to MCP Registry**
```bash
cd /Users/drew/code/basic-memory
mcp-publisher publish
```
If not authenticated:
```bash
mcp-publisher login github
# Follow device authentication flow
mcp-publisher publish
```
3. **Verify Publication**
```bash
curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=basic-memory"
```
**Note:** The `mcp-publisher` CLI can be installed via Homebrew (`brew install mcp-publisher`) or from GitHub releases.
#### Website Updates
**1. basicmachines.co** (`/Users/drew/code/basicmachines.co`)
@@ -145,6 +173,7 @@ Before starting, verify:
📋 GitHub Release: https://github.com/basicmachines-co/basic-memory/releases/tag/v0.13.2
📦 PyPI: https://pypi.org/project/basic-memory/0.13.2/
🍺 Homebrew: https://github.com/basicmachines-co/homebrew-basic-memory
🔌 MCP Registry: https://registry.modelcontextprotocol.io
🚀 GitHub Actions: Completed
Install with pip/uv:
@@ -162,8 +191,9 @@ Users can now upgrade:
- This creates production releases used by end users
- Must pass all quality gates before proceeding
- Uses the automated justfile target for consistency
- Version is automatically updated in `__init__.py`
- Version is automatically updated in `__init__.py` and `server.json`
- Triggers automated GitHub release with changelog
- Package is published to PyPI for `pip` and `uv` users
- Homebrew formula is automatically updated for stable releases
- MCP Registry is updated manually via `mcp-publisher publish`
- Supports multiple installation methods (uv, pip, Homebrew)
+1 -33
View File
@@ -1,3 +1,4 @@
<!-- mcp-name: io.github.basicmachines-co/basic-memory -->
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![PyPI version](https://badge.fury.io/py/basic-memory.svg)](https://badge.fury.io/py/basic-memory)
[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
@@ -466,39 +467,6 @@ tail -f ~/.basic-memory/basic-memory.log
BASIC_MEMORY_CLOUD_MODE=true uvicorn basic_memory.api.app:app
```
## Telemetry
Basic Memory collects anonymous usage statistics to help improve the software. This follows the [Homebrew model](https://docs.brew.sh/Analytics) - telemetry is on by default with easy opt-out.
**What we collect:**
- App version, Python version, OS, architecture
- Feature usage (which MCP tools and CLI commands are used)
- Error types (sanitized - no file paths or personal data)
**What we NEVER collect:**
- Note content, file names, or paths
- Personal information
- IP addresses
**Opting out:**
```bash
# Disable telemetry
basic-memory telemetry disable
# Check status
basic-memory telemetry status
# Re-enable
basic-memory telemetry enable
```
Or set the environment variable:
```bash
export BASIC_MEMORY_TELEMETRY_ENABLED=false
```
For more details, see the [Telemetry documentation](https://basicmemory.com/telemetry).
## Development
### Running Tests
+1 -3
View File
@@ -8,9 +8,8 @@ To keep the default CI signal **stable and meaningful**, the default `pytest` co
- highly environment-dependent (OS/DB tuning)
- inherently interactive (CLI)
- background-task orchestration (watchers/sync runners)
- external analytics
### Whats excluded (and why)
### What's excluded (and why)
Coverage excludes are configured in `pyproject.toml` under `[tool.coverage.report].omit`.
@@ -19,7 +18,6 @@ Current exclusions include:
- `src/basic_memory/db.py`: platform/backend tuning paths (SQLite/Postgres/Windows), covered by integration tests and targeted runs.
- `src/basic_memory/services/initialization.py`: startup orchestration/background tasks; covered indirectly by app/MCP entrypoints.
- `src/basic_memory/sync/sync_service.py`: heavy filesystem↔DB integration; validated in integration suite (not enforced in unit coverage).
- `src/basic_memory/telemetry.py`: external analytics; exercised lightly but excluded from strict coverage gate.
### Recommended additional runs
+16 -5
View File
@@ -204,9 +204,14 @@ release version:
echo "📝 Updating version in __init__.py..."
sed -i.bak "s/__version__ = \".*\"/__version__ = \"$VERSION_NUM\"/" src/basic_memory/__init__.py
rm -f src/basic_memory/__init__.py.bak
# Update version in server.json (MCP registry metadata)
echo "📝 Updating version in server.json..."
sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION_NUM\"/g" server.json
rm -f server.json.bak
# Commit version update
git add src/basic_memory/__init__.py
git add src/basic_memory/__init__.py server.json
git commit -m "chore: update version to $VERSION_NUM for {{version}} release"
# Create and push tag
@@ -221,9 +226,10 @@ release version:
echo "📦 GitHub Actions will build and publish to PyPI"
echo "🔗 Monitor at: https://github.com/basicmachines-co/basic-memory/actions"
echo ""
echo "📝 REMINDER: Update documentation sites after release is published:"
echo "📝 REMINDER: Post-release tasks:"
echo " 1. docs.basicmemory.com - Add release notes to src/pages/latest-releases.mdx"
echo " 2. basicmachines.co - Update version in src/components/sections/hero.tsx"
echo " 3. MCP Registry - Run: mcp-publisher publish"
echo " See: .claude/commands/release/release.md for detailed instructions"
# Create a beta release (e.g., just beta v0.13.2b1)
@@ -269,9 +275,14 @@ beta version:
echo "📝 Updating version in __init__.py..."
sed -i.bak "s/__version__ = \".*\"/__version__ = \"$VERSION_NUM\"/" src/basic_memory/__init__.py
rm -f src/basic_memory/__init__.py.bak
# Update version in server.json (MCP registry metadata)
echo "📝 Updating version in server.json..."
sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION_NUM\"/g" server.json
rm -f server.json.bak
# Commit version update
git add src/basic_memory/__init__.py
git add src/basic_memory/__init__.py server.json
git commit -m "chore: update version to $VERSION_NUM for {{version}} beta release"
# Create and push tag
-2
View File
@@ -41,7 +41,6 @@ dependencies = [
"mdformat>=0.7.22",
"mdformat-gfm>=0.3.7",
"mdformat-frontmatter>=2.0.8",
"openpanel>=0.0.1", # Anonymous usage telemetry (Homebrew-style opt-out)
"sniffio>=1.3.1",
"anyio>=4.10.0",
"httpx>=0.28.0",
@@ -143,6 +142,5 @@ omit = [
"*/db.py", # Backend/runtime-dependent (sqlite/postgres/windows tuning); validated via integration tests
"*/services/initialization.py", # Startup orchestration + background tasks (watchers); exercised indirectly in entrypoints
"*/sync/sync_service.py", # Heavy filesystem/db integration; covered by integration suite, not enforced in unit coverage
"*/telemetry.py", # External analytics; tested lightly, excluded from strict coverage target
"*/services/migration_service.py", # Complex migration scenarios
]
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.basicmachines-co/basic-memory",
"description": "Local-first knowledge management with bi-directional LLM sync via Markdown files.",
"repository": {
"url": "https://github.com/basicmachines-co/basic-memory.git",
"source": "github"
},
"version": "0.17.7",
"packages": [
{
"registryType": "pypi",
"identifier": "basic-memory",
"version": "0.17.7",
"runtimeHint": "uvx",
"runtimeArguments": ["basic-memory", "mcp"],
"transport": {
"type": "stdio"
}
}
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Package version - updated by release automation
__version__ = "0.17.6"
__version__ = "0.17.7"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
@@ -1,6 +1,7 @@
"""Routes for getting entity content."""
import tempfile
import uuid
from pathlib import Path
from typing import Annotated, Union
@@ -218,7 +219,9 @@ async def write_resource(
status_code = 200
else:
# Create a new entity model
# Explicitly set external_id to ensure NOT NULL constraint is satisfied (fixes #512)
entity = EntityModel(
external_id=str(uuid.uuid4()),
title=file_name,
entity_type=entity_type,
content_type=content_type,
@@ -9,6 +9,7 @@ Key differences from v1:
- More RESTful: POST for create, PUT for update, GET for read
"""
import uuid
from pathlib import Path as PathLib
from fastapi import APIRouter, HTTPException, Response, Path
@@ -147,7 +148,9 @@ async def create_resource(
entity_type = "canvas" if data.file_path.endswith(".canvas") else "file"
# Create a new entity model
# Explicitly set external_id to ensure NOT NULL constraint is satisfied (fixes #512)
entity = EntityModel(
external_id=str(uuid.uuid4()),
title=file_name,
entity_type=entity_type,
content_type=content_type,
-8
View File
@@ -10,7 +10,6 @@ import typer # noqa: E402
from basic_memory.cli.container import CliContainer, set_container # noqa: E402
from basic_memory.config import init_cli_logging # noqa: E402
from basic_memory.telemetry import show_notice_if_needed, track_app_started # noqa: E402
def version_callback(value: bool) -> None:
@@ -47,13 +46,6 @@ def app_callback(
container = CliContainer.create()
set_container(container)
# Show telemetry notice and track CLI startup
# Skip for 'mcp' command - it handles its own telemetry in lifespan
# Skip for 'telemetry' command - avoid issues when user is managing telemetry
if ctx.invoked_subcommand not in {"mcp", "telemetry"}:
show_notice_if_needed()
track_app_started("cli")
# Run initialization for commands that don't use the API
# Skip for 'mcp' command - it has its own lifespan that handles initialization
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
+1 -2
View File
@@ -1,7 +1,7 @@
"""CLI commands for basic-memory."""
from . import status, db, import_memory_json, mcp, import_claude_conversations
from . import import_claude_projects, import_chatgpt, tool, project, format, telemetry
from . import import_claude_projects, import_chatgpt, tool, project, format
__all__ = [
"status",
@@ -14,5 +14,4 @@ __all__ = [
"tool",
"project",
"format",
"telemetry",
]
@@ -14,7 +14,6 @@ from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_post, call_get
from basic_memory.mcp.project_context import get_active_project
from basic_memory.schemas import ProjectInfoResponse
from basic_memory.telemetry import shutdown_telemetry
console = Console()
@@ -24,8 +23,8 @@ T = TypeVar("T")
def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
"""Run an async coroutine with proper database cleanup.
This helper ensures database connections and telemetry threads are cleaned up
before the event loop closes, preventing process hangs in CLI commands.
This helper ensures database connections are cleaned up before the
event loop closes, preventing process hangs in CLI commands.
Args:
coro: The coroutine to run
@@ -39,9 +38,6 @@ def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
return await coro
finally:
await db.shutdown_db()
# Shutdown telemetry to stop the OpenPanel background thread
# This prevents hangs on Python 3.14+ during thread shutdown
shutdown_telemetry()
return asyncio.run(_with_cleanup())
@@ -1,81 +0,0 @@
"""Telemetry commands for basic-memory CLI."""
import typer
from rich.console import Console
from rich.panel import Panel
from basic_memory.cli.app import app
from basic_memory.config import ConfigManager
console = Console()
# Create telemetry subcommand group
telemetry_app = typer.Typer(help="Manage anonymous telemetry settings")
app.add_typer(telemetry_app, name="telemetry")
@telemetry_app.command("enable")
def enable() -> None:
"""Enable anonymous telemetry.
Telemetry helps improve Basic Memory by collecting anonymous usage data.
No personal data, note content, or file paths are ever collected.
"""
config_manager = ConfigManager()
config = config_manager.config
config.telemetry_enabled = True
config_manager.save_config(config)
console.print("[green]Telemetry enabled[/green]")
console.print("[dim]Thank you for helping improve Basic Memory![/dim]")
@telemetry_app.command("disable")
def disable() -> None:
"""Disable anonymous telemetry.
You can re-enable telemetry anytime with: bm telemetry enable
"""
config_manager = ConfigManager()
config = config_manager.config
config.telemetry_enabled = False
config_manager.save_config(config)
console.print("[yellow]Telemetry disabled[/yellow]")
@telemetry_app.command("status")
def status() -> None:
"""Show current telemetry status and what's collected."""
from basic_memory.telemetry import get_install_id, TELEMETRY_DOCS_URL
config = ConfigManager().config
status_text = (
"[green]enabled[/green]" if config.telemetry_enabled else "[yellow]disabled[/yellow]"
)
console.print(f"\nTelemetry: {status_text}")
console.print(f"Install ID: [dim]{get_install_id()}[/dim]")
console.print()
what_we_collect = """
[bold]What we collect:[/bold]
- App version, Python version, OS, architecture
- Feature usage (which MCP tools and CLI commands)
- Sync statistics (entity count, duration)
- Error types (sanitized, no file paths)
[bold]What we NEVER collect:[/bold]
- Note content, file names, or paths
- Personal information
- IP addresses
"""
console.print(
Panel(
what_we_collect.strip(),
title="Telemetry Details",
border_style="blue",
expand=False,
)
)
console.print(f"[dim]Details: {TELEMETRY_DOCS_URL}[/dim]")
-1
View File
@@ -13,7 +13,6 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
mcp,
project,
status,
telemetry,
tool,
)
+1 -12
View File
@@ -221,17 +221,6 @@ class BasicMemoryConfig(BaseSettings):
description="Cloud project sync configuration mapping project names to their local paths and sync state",
)
# Telemetry configuration (Homebrew-style opt-out)
telemetry_enabled: bool = Field(
default=True,
description="Send anonymous usage statistics to help improve Basic Memory. Disable with: bm telemetry disable",
)
telemetry_notice_shown: bool = Field(
default=False,
description="Whether the one-time telemetry notice has been shown to the user",
)
@property
def is_test_env(self) -> bool:
"""Check if running in a test environment.
@@ -241,7 +230,7 @@ class BasicMemoryConfig(BaseSettings):
- BASIC_MEMORY_ENV environment variable is "test"
- PYTEST_CURRENT_TEST environment variable is set (pytest is running)
Used to disable features like telemetry and file watchers during tests.
Used to disable features like file watchers during tests.
"""
return (
self.env == "test"
+7
View File
@@ -1,5 +1,6 @@
"""Utilities for converting between markdown and entity models."""
import uuid
from pathlib import Path
from typing import Any, Optional
@@ -40,6 +41,12 @@ def entity_model_from_markdown(
# Create or update entity
model = entity or Entity()
# Ensure external_id is set for new entities
# SQLAlchemy's Python-side default may not always evaluate,
# so we explicitly set it here for reliability (fixes #512)
if not model.external_id:
model.external_id = str(uuid.uuid4())
# Update basic fields
model.title = markdown.frontmatter.title
model.entity_type = markdown.frontmatter.type
-6
View File
@@ -10,7 +10,6 @@ from loguru import logger
from basic_memory import db
from basic_memory.mcp.container import McpContainer, set_container
from basic_memory.services.initialization import initialize_app
from basic_memory.telemetry import show_notice_if_needed, track_app_started
@asynccontextmanager
@@ -19,7 +18,6 @@ async def lifespan(app: FastMCP):
Handles:
- Database initialization and migrations
- Telemetry notice and tracking
- File sync via SyncCoordinator (if enabled and not in cloud mode)
- Proper cleanup on shutdown
"""
@@ -30,10 +28,6 @@ async def lifespan(app: FastMCP):
logger.debug(f"Starting Basic Memory MCP server (mode={container.mode.name})")
# Show telemetry notice (first run only) and track startup
show_notice_if_needed()
track_app_started("mcp")
# Track if we created the engine (vs test fixtures providing it)
# This prevents disposing an engine provided by test fixtures when
# multiple Client connections are made in the same test
@@ -8,7 +8,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.telemetry import track_mcp_tool
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import (
GraphContext,
@@ -87,7 +86,6 @@ async def build_context(
Raises:
ToolError: If project doesn't exist or depth parameter is invalid
"""
track_mcp_tool("build_context")
logger.info(f"Building context from {url} in project {project}")
# Convert string depth to integer if needed
-2
View File
@@ -13,7 +13,6 @@ from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
from basic_memory.telemetry import track_mcp_tool
@mcp.tool(
@@ -95,7 +94,6 @@ async def canvas(
Raises:
ToolError: If project doesn't exist or folder path is invalid
"""
track_mcp_tool("canvas")
async with get_client() as client:
active_project = await get_active_project(client, project, context)
@@ -15,7 +15,6 @@ from basic_memory.mcp.tools.search import search_notes
from basic_memory.mcp.tools.read_note import read_note
from basic_memory.schemas.search import SearchResponse
from basic_memory.config import ConfigManager
from basic_memory.telemetry import track_mcp_tool
def _format_search_results_for_chatgpt(results: SearchResponse) -> List[Dict[str, Any]]:
@@ -89,7 +88,6 @@ async def search(
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
where the JSON body contains `results`, `total_count`, and echo of `query`.
"""
track_mcp_tool("search")
logger.info(f"ChatGPT search request: query='{query}'")
try:
@@ -153,7 +151,6 @@ async def fetch(
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
where the JSON body includes `id`, `title`, `text`, `url`, and metadata.
"""
track_mcp_tool("fetch")
logger.info(f"ChatGPT fetch request: id='{id}'")
try:
@@ -8,7 +8,6 @@ from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import get_client
from basic_memory.telemetry import track_mcp_tool
def _format_delete_error_response(project: str, error_message: str, identifier: str) -> str:
@@ -202,7 +201,6 @@ async def delete_note(
with suggestions for finding the correct identifier, including search
commands and alternative formats to try.
"""
track_mcp_tool("delete_note")
async with get_client() as client:
active_project = await get_active_project(client, project, context)
-2
View File
@@ -8,7 +8,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
from basic_memory.mcp.server import mcp
from basic_memory.telemetry import track_mcp_tool
def _format_error_response(
@@ -213,7 +212,6 @@ async def edit_note(
search_notes() first to find the correct identifier. The tool provides detailed
error messages with suggestions if operations fail.
"""
track_mcp_tool("edit_note")
async with get_client() as client:
active_project = await get_active_project(client, project, context)
@@ -8,7 +8,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.telemetry import track_mcp_tool
@mcp.tool(
@@ -63,7 +62,6 @@ async def list_directory(
Raises:
ToolError: If project doesn't exist or directory path is invalid
"""
track_mcp_tool("list_directory")
async with get_client() as client:
active_project = await get_active_project(client, project, context)
-2
View File
@@ -9,7 +9,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.project_context import get_active_project
from basic_memory.telemetry import track_mcp_tool
from basic_memory.utils import validate_project_path
@@ -400,7 +399,6 @@ async def move_note(
- Re-indexes the entity for search
- Maintains all observations and relations
"""
track_mcp_tool("move_note")
async with get_client() as client:
logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")
@@ -10,7 +10,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.server import mcp
from basic_memory.schemas.project_info import ProjectInfoRequest
from basic_memory.telemetry import track_mcp_tool
from basic_memory.utils import generate_permalink
@@ -36,7 +35,6 @@ async def list_memory_projects(context: Context | None = None) -> str:
Example:
list_memory_projects()
"""
track_mcp_tool("list_memory_projects")
async with get_client() as client:
if context: # pragma: no cover
await context.info("Listing all available projects")
@@ -92,7 +90,6 @@ async def create_memory_project(
create_memory_project("my-research", "~/Documents/research")
create_memory_project("work-notes", "/home/user/work", set_default=True)
"""
track_mcp_tool("create_memory_project")
async with get_client() as client:
# Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
@@ -151,7 +148,6 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
This action cannot be undone. The project will need to be re-added
to access its content through Basic Memory again.
"""
track_mcp_tool("delete_project")
async with get_client() as client:
# Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
@@ -20,7 +20,6 @@ from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_get, resolve_entity_id
from basic_memory.schemas.memory import memory_url_path
from basic_memory.telemetry import track_mcp_tool
from basic_memory.utils import validate_project_path
@@ -201,7 +200,6 @@ async def read_content(
HTTPError: If project doesn't exist or is inaccessible
SecurityError: If path attempts path traversal
"""
track_mcp_tool("read_content")
logger.info("Reading file", path=path, project=project)
async with get_client() as client:
-2
View File
@@ -10,7 +10,6 @@ from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.search import search_notes
from basic_memory.telemetry import track_mcp_tool
from basic_memory.schemas.memory import memory_url_path
from basic_memory.utils import validate_project_path
@@ -77,7 +76,6 @@ async def read_note(
If the exact note isn't found, this tool provides helpful suggestions
including related notes, search commands, and note creation templates.
"""
track_mcp_tool("read_note")
async with get_client() as client:
# Get and validate the project
active_project = await get_active_project(client, project, context)
@@ -10,7 +10,6 @@ from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, resolve_project_parameter
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
from basic_memory.telemetry import track_mcp_tool
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import (
GraphContext,
@@ -100,7 +99,6 @@ async def recent_activity(
- For focused queries, consider using build_context with a specific URI
- Max timeframe is 1 year in the past
"""
track_mcp_tool("recent_activity")
async with get_client() as client:
# Build common parameters for API calls
params = {
-2
View File
@@ -9,7 +9,6 @@ from fastmcp import Context
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.telemetry import track_mcp_tool
from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse
@@ -330,7 +329,6 @@ async def search_notes(
# Explicit project specification
results = await search_notes("project planning", project="my-project")
"""
track_mcp_tool("search_notes")
# Avoid mutable-default-argument footguns. Treat None as "no filter".
types = types or []
entity_types = entity_types or []
-2
View File
@@ -8,7 +8,6 @@ from fastmcp import Context
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.read_note import read_note
from basic_memory.telemetry import track_mcp_tool
@mcp.tool(
@@ -55,7 +54,6 @@ async def view_note(
HTTPError: If project doesn't exist or is inaccessible
SecurityError: If identifier attempts path traversal
"""
track_mcp_tool("view_note")
logger.info(f"Viewing note: {identifier} in project: {project}")
# Call the existing read_note logic
-2
View File
@@ -7,7 +7,6 @@ from loguru import logger
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
from basic_memory.mcp.server import mcp
from basic_memory.telemetry import track_mcp_tool
from fastmcp import Context
from basic_memory.schemas.base import Entity
from basic_memory.utils import parse_tags, validate_project_path
@@ -115,7 +114,6 @@ async def write_note(
HTTPError: If project doesn't exist or is inaccessible
SecurityError: If folder path attempts path traversal
"""
track_mcp_tool("write_note")
async with get_client() as client:
logger.info(
f"MCP tool call tool=write_note project={project} folder={folder}, title={title}, tags={tags}"
-320
View File
@@ -1,320 +0,0 @@
"""Anonymous telemetry for Basic Memory (Homebrew-style opt-out).
This module implements privacy-respecting usage analytics following the Homebrew model:
- Telemetry is ON by default
- Users can easily opt out: `bm telemetry disable`
- First run shows a one-time notice (not a prompt)
- Only anonymous data is collected (random UUID, no personal info)
What we collect:
- App version, Python version, OS, architecture
- Feature usage (which MCP tools and CLI commands are used)
- Error types (sanitized, no file paths or personal data)
What we NEVER collect:
- Note content, file names, or paths
- Personal information
- IP addresses (OpenPanel doesn't store these)
Documentation: https://basicmemory.com/telemetry
"""
from __future__ import annotations
import platform
import re
import uuid
from pathlib import Path
from typing import TYPE_CHECKING, Any
from loguru import logger
from basic_memory import __version__
if TYPE_CHECKING:
from openpanel import OpenPanel
# --- Configuration ---
# OpenPanel credentials (write-only, safe to embed in client code)
# These can only send events to our dashboard, not read any data
OPENPANEL_CLIENT_ID = "2e7b036d-c6e5-40aa-91eb-5c70a8ef21a3"
OPENPANEL_CLIENT_SECRET = "sec_92f7f8328bd0368ff4c2"
TELEMETRY_DOCS_URL = "https://basicmemory.com/telemetry"
TELEMETRY_NOTICE = f"""
Basic Memory collects anonymous usage statistics to help improve the software.
This includes: version, OS, feature usage, and errors. No personal data or note content.
To opt out: bm telemetry disable
Details: {TELEMETRY_DOCS_URL}
"""
# --- Module State ---
_client: OpenPanel | None = None
_initialized: bool = False
_telemetry_enabled: bool | None = None # Cached to avoid repeated config reads
# --- Telemetry State ---
def _is_telemetry_enabled() -> bool:
"""Check if telemetry is enabled (cached).
Returns False if:
- User disabled via `bm telemetry disable`
- DO_NOT_TRACK environment variable is set
- Running in test environment
"""
global _telemetry_enabled
if _telemetry_enabled is None:
from basic_memory.config import ConfigManager
config = ConfigManager().config
_telemetry_enabled = config.telemetry_enabled and not config.is_test_env
return _telemetry_enabled
# --- Installation ID ---
def get_install_id() -> str:
"""Get or create anonymous installation ID.
Creates a random UUID on first run and stores it locally.
User can delete ~/.basic-memory/.install_id to reset.
"""
id_file = Path.home() / ".basic-memory" / ".install_id"
if id_file.exists():
return id_file.read_text().strip()
install_id = str(uuid.uuid4())
id_file.parent.mkdir(parents=True, exist_ok=True)
id_file.write_text(install_id)
return install_id
# --- Client Management ---
def _get_client() -> OpenPanel | None:
"""Get or create the OpenPanel client (singleton).
Lazily initializes the client with global properties.
Returns None if telemetry is disabled (avoids creating background thread).
"""
global _client, _initialized
# Trigger: telemetry disabled via config, env var, or test mode
# Why: OpenPanel creates a background thread even when disabled=True,
# which can cause hangs on Python 3.14 during thread shutdown
# Outcome: return None early, no OpenPanel client or thread created
if not _is_telemetry_enabled():
return None
if _client is None:
# Defer import to avoid creating background thread when telemetry disabled
from openpanel import OpenPanel
_client = OpenPanel(
client_id=OPENPANEL_CLIENT_ID,
client_secret=OPENPANEL_CLIENT_SECRET,
)
if not _initialized:
install_id = get_install_id()
# Set profile ID for OpenPanel (required for API to accept events)
_client.identify(install_id)
# Set global properties that go with every event
_client.set_global_properties(
{
"app_version": __version__,
"python_version": platform.python_version(),
"os": platform.system().lower(),
"arch": platform.machine(),
"install_id": install_id,
"source": "foss",
}
)
_initialized = True
return _client
def reset_client() -> None:
"""Reset the telemetry client (for testing or after config changes)."""
global _client, _initialized, _telemetry_enabled
_client = None
_initialized = False
_telemetry_enabled = None
def shutdown_telemetry() -> None:
"""Shutdown the telemetry client, stopping its background thread.
Call this on application exit to ensure clean shutdown.
The OpenPanel client creates a background thread with an event loop
that needs to be stopped to avoid hangs on Python 3.14+.
"""
import gc
import io
import sys
global _client
if _client is not None:
try:
# Suppress "Task was destroyed but it is pending!" warnings
# These occur when we stop the event loop with pending HTTP requests,
# which is expected during shutdown. The message is printed directly
# to stderr by asyncio.Task.__del__(), so we redirect stderr temporarily.
# We also force garbage collection to ensure the warning happens
# while stderr is still redirected.
stderr_backup = sys.stderr
sys.stderr = io.StringIO()
try:
# OpenPanel._cleanup stops the event loop and joins the thread
_client._cleanup()
_client = None
# Force garbage collection to trigger Task.__del__ while stderr is redirected
gc.collect()
finally:
sys.stderr = stderr_backup
except Exception as e:
logger.opt(exception=False).debug(f"Telemetry shutdown failed: {e}")
finally:
_client = None
# --- Event Tracking ---
def track(event: str, properties: dict[str, Any] | None = None) -> None:
"""Track an event. Fire-and-forget, never raises.
Args:
event: Event name (e.g., "app_started", "mcp_tool_called")
properties: Optional event properties
"""
# Constraint: telemetry must never break the application
# Even if OpenPanel API is down or config is corrupt, user's command must succeed
try:
client = _get_client()
if client is not None:
client.track(event, properties or {})
except Exception as e:
logger.opt(exception=False).debug(f"Telemetry failed: {e}")
# --- First-Run Notice ---
def show_notice_if_needed() -> None:
"""Show one-time telemetry notice (Homebrew style).
Only shows if:
- Telemetry is enabled
- Notice hasn't been shown before
After showing, marks the notice as shown in config.
"""
from basic_memory.config import ConfigManager
config_manager = ConfigManager()
config = config_manager.config
if config.telemetry_enabled and not config.telemetry_notice_shown:
from rich.console import Console
from rich.panel import Panel
# Print to stderr so it doesn't interfere with command output
console = Console(stderr=True)
console.print(
Panel(
TELEMETRY_NOTICE.strip(),
title="[dim]Telemetry Notice[/dim]",
border_style="dim",
expand=False,
)
)
# Mark as shown so we don't show again
config.telemetry_notice_shown = True
config_manager.save_config(config)
# --- Convenience Functions ---
def track_app_started(mode: str) -> None:
"""Track app startup.
Args:
mode: "cli" or "mcp"
"""
track("app_started", {"mode": mode})
def track_mcp_tool(tool_name: str) -> None:
"""Track MCP tool usage.
Args:
tool_name: Name of the tool (e.g., "write_note", "search_notes")
"""
track("mcp_tool_called", {"tool": tool_name})
def track_cli_command(command: str) -> None:
"""Track CLI command usage.
Args:
command: Command name (e.g., "sync", "import claude")
"""
track("cli_command", {"command": command})
def track_sync_completed(entity_count: int, duration_ms: int) -> None:
"""Track sync completion.
Args:
entity_count: Number of entities synced
duration_ms: Duration in milliseconds
"""
track("sync_completed", {"entity_count": entity_count, "duration_ms": duration_ms})
def track_import_completed(source: str, count: int) -> None:
"""Track import completion.
Args:
source: Import source (e.g., "claude", "chatgpt")
count: Number of items imported
"""
track("import_completed", {"source": source, "count": count})
def track_error(error_type: str, message: str) -> None:
"""Track an error (sanitized).
Args:
error_type: Exception class name
message: Error message (will be sanitized to remove file paths)
"""
if not message:
track("error", {"type": error_type, "message": ""})
return
# Sanitize file paths to prevent leaking user directory structure
# Unix paths: /Users/name/file.py, /home/user/notes/doc.md
sanitized = re.sub(r"/[\w/.+-]+\.\w+", "[FILE]", message)
# Windows paths: C:\Users\name\file.py, D:\projects\doc.md
sanitized = re.sub(r"[A-Z]:\\[\w\\.+-]+\.\w+", "[FILE]", sanitized, flags=re.IGNORECASE)
# Truncate to avoid sending too much data
track("error", {"type": error_type, "message": sanitized[:200]})
+163
View File
@@ -0,0 +1,163 @@
"""Tests for markdown/utils.py - entity model conversion utilities."""
from datetime import datetime, timezone
from pathlib import Path
import pytest
from basic_memory.markdown.schemas import EntityMarkdown, EntityFrontmatter, Observation
from basic_memory.markdown.utils import entity_model_from_markdown
from basic_memory.models import Entity
class TestEntityModelFromMarkdown:
"""Tests for entity_model_from_markdown function."""
def _create_markdown(
self,
title: str = "Test Entity",
entity_type: str = "note",
permalink: str = "test/test-entity",
created: datetime | None = None,
modified: datetime | None = None,
observations: list[Observation] | None = None,
) -> EntityMarkdown:
"""Helper to create test EntityMarkdown objects."""
now = datetime.now(timezone.utc)
return EntityMarkdown(
frontmatter=EntityFrontmatter(
title=title,
type=entity_type,
permalink=permalink,
),
content=f"# {title}\n\nTest content.",
observations=observations or [],
relations=[],
created=created or now,
modified=modified or now,
)
def test_new_entity_has_external_id(self):
"""Test that a new entity always gets an external_id set.
This is a regression test for GitHub issue #512 where SQLite failed
with NOT NULL constraint on external_id because SQLAlchemy's Python-side
default wasn't always evaluated.
"""
markdown = self._create_markdown()
file_path = Path("test/test-entity.md")
entity = entity_model_from_markdown(file_path, markdown)
# external_id must be set (non-None, non-empty)
assert entity.external_id is not None
assert entity.external_id != ""
# Should be a valid UUID format (36 chars with hyphens)
assert len(entity.external_id) == 36
assert entity.external_id.count("-") == 4
def test_existing_entity_preserves_external_id(self):
"""Test that an existing entity's external_id is preserved."""
markdown = self._create_markdown()
file_path = Path("test/test-entity.md")
# Create existing entity with known external_id
existing_external_id = "12345678-1234-1234-1234-123456789012"
existing_entity = Entity()
existing_entity.external_id = existing_external_id
entity = entity_model_from_markdown(file_path, markdown, entity=existing_entity)
# Should preserve the existing external_id
assert entity.external_id == existing_external_id
def test_entity_with_empty_external_id_gets_new_one(self):
"""Test that an entity with empty string external_id gets a new UUID."""
markdown = self._create_markdown()
file_path = Path("test/test-entity.md")
# Create existing entity with empty external_id
existing_entity = Entity()
existing_entity.external_id = ""
entity = entity_model_from_markdown(file_path, markdown, entity=existing_entity)
# Should have a new external_id
assert entity.external_id is not None
assert entity.external_id != ""
assert len(entity.external_id) == 36
def test_entity_with_none_external_id_gets_new_one(self):
"""Test that an entity with None external_id gets a new UUID."""
markdown = self._create_markdown()
file_path = Path("test/test-entity.md")
# Create existing entity with None external_id
existing_entity = Entity()
# Explicitly set to None to test this case
object.__setattr__(existing_entity, "external_id", None)
entity = entity_model_from_markdown(file_path, markdown, entity=existing_entity)
# Should have a new external_id
assert entity.external_id is not None
assert entity.external_id != ""
assert len(entity.external_id) == 36
def test_multiple_calls_generate_unique_ids(self):
"""Test that multiple new entities get unique external_ids."""
markdown1 = self._create_markdown(title="Entity 1", permalink="test/entity-1")
markdown2 = self._create_markdown(title="Entity 2", permalink="test/entity-2")
entity1 = entity_model_from_markdown(Path("test/entity-1.md"), markdown1)
entity2 = entity_model_from_markdown(Path("test/entity-2.md"), markdown2)
# Both should have external_ids
assert entity1.external_id is not None
assert entity2.external_id is not None
# They should be unique
assert entity1.external_id != entity2.external_id
def test_entity_model_fields_populated_with_external_id(self):
"""Test that entity fields are populated correctly, including external_id.
This is a basic sanity check that entity_model_from_markdown sets
the key fields we care about for the #512 fix.
"""
markdown = self._create_markdown(
title="My Test Entity",
entity_type="component",
permalink="components/my-test",
)
file_path = Path("components/my-test.md")
entity = entity_model_from_markdown(file_path, markdown, project_id=1)
# The key assertion: external_id must always be set
assert entity.external_id is not None
assert len(entity.external_id) == 36 # UUID format
# Verify file_path is set correctly (uses posix format)
assert entity.file_path == "components/my-test.md"
# Timestamps should be set from markdown
assert entity.created_at is not None
assert entity.updated_at is not None
def test_missing_dates_raises_error(self):
"""Test that missing created/modified dates raise ValueError."""
markdown = EntityMarkdown(
frontmatter=EntityFrontmatter(
title="Test",
type="note",
),
content="# Test",
observations=[],
relations=[],
created=None,
modified=None,
)
with pytest.raises(ValueError, match="Both created and modified dates are required"):
entity_model_from_markdown(Path("test.md"), markdown)
-290
View File
@@ -1,290 +0,0 @@
"""Tests for telemetry module (minimal mocking).
We avoid standard-library mocks and instead use small stub objects + pytest monkeypatch.
"""
from __future__ import annotations
from pathlib import Path
from basic_memory.config import BasicMemoryConfig
class _StubOpenPanel:
def __init__(self, *, client_id: str, client_secret: str, disabled: bool = False):
self.client_id = client_id
self.client_secret = client_secret
self.disabled = disabled
self.global_properties: dict | None = None
self.events: list[tuple[str, dict]] = []
self.raise_on_track: Exception | None = None
self.profile_id: str | None = None
def identify(self, profile_id: str) -> None:
self.profile_id = profile_id
def set_global_properties(self, props: dict) -> None:
self.global_properties = props
def track(self, event: str, properties: dict) -> None:
if self.raise_on_track:
raise self.raise_on_track
self.events.append((event, properties))
class _StubConsole:
def __init__(self, *args, **kwargs):
self.print_calls: list[tuple[tuple, dict]] = []
def print(self, *args, **kwargs):
self.print_calls.append((args, kwargs))
class TestGetInstallId:
def test_creates_install_id_on_first_call(self, tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
from basic_memory.telemetry import get_install_id
install_id = get_install_id()
assert len(install_id) == 36
assert install_id.count("-") == 4
id_file = tmp_path / ".basic-memory" / ".install_id"
assert id_file.exists()
assert id_file.read_text().strip() == install_id
def test_returns_existing_install_id(self, tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
id_file = tmp_path / ".basic-memory" / ".install_id"
id_file.parent.mkdir(parents=True, exist_ok=True)
id_file.write_text("test-uuid-12345")
from basic_memory.telemetry import get_install_id
assert get_install_id() == "test-uuid-12345"
class TestTelemetryConfig:
def test_telemetry_enabled_defaults_to_true(self, config_home, monkeypatch):
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
assert BasicMemoryConfig().telemetry_enabled is True
def test_telemetry_notice_shown_defaults_to_false(self, config_home, monkeypatch):
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
assert BasicMemoryConfig().telemetry_notice_shown is False
def test_telemetry_enabled_via_env_var(self, config_home, monkeypatch):
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false")
assert BasicMemoryConfig().telemetry_enabled is False
class TestTrack:
def test_track_does_not_raise_on_error(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
# Force telemetry to be enabled for this test
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
# Replace OpenPanel with a stub that raises on track
stub_client = _StubOpenPanel(client_id="id", client_secret="sec", disabled=False)
stub_client.raise_on_track = Exception("Network error")
def openpanel_factory(*, client_id, client_secret):
stub_client.client_id = client_id
stub_client.client_secret = client_secret
return stub_client
# Mock the import inside _get_client()
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
# Should not raise
telemetry.track("test_event", {"key": "value"})
def test_track_respects_disabled_config(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false")
created: list[_StubOpenPanel] = []
def openpanel_factory(*, client_id, client_secret):
client = _StubOpenPanel(
client_id=client_id, client_secret=client_secret, disabled=False
)
created.append(client)
return client
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
telemetry.track("test_event")
# With disabled config, OpenPanel client is never created (early return)
assert len(created) == 0
class TestShowNoticeIfNeeded:
def test_shows_notice_when_enabled_and_not_shown(self, config_manager, monkeypatch):
import basic_memory.telemetry as telemetry
telemetry.reset_client()
# Ensure config state: enabled + not yet shown
cfg = config_manager.load_config()
cfg.telemetry_enabled = True
cfg.telemetry_notice_shown = False
config_manager.save_config(cfg)
instances: list[_StubConsole] = []
def console_factory(*_args, **_kwargs):
c = _StubConsole()
instances.append(c)
return c
monkeypatch.setattr("rich.console.Console", console_factory)
telemetry.show_notice_if_needed()
assert len(instances) == 1
assert len(instances[0].print_calls) == 1
cfg2 = config_manager.load_config()
assert cfg2.telemetry_notice_shown is True
def test_does_not_show_notice_when_disabled(self, config_manager, monkeypatch):
import basic_memory.telemetry as telemetry
telemetry.reset_client()
cfg = config_manager.load_config()
cfg.telemetry_enabled = False
cfg.telemetry_notice_shown = False
config_manager.save_config(cfg)
def console_factory(*_args, **_kwargs):
raise AssertionError("Console should not be constructed when telemetry is disabled")
monkeypatch.setattr("rich.console.Console", console_factory)
telemetry.show_notice_if_needed()
class TestConvenienceFunctions:
def test_track_app_started(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
# Force telemetry to be enabled for this test (pytest sets PYTEST_CURRENT_TEST)
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
created: list[_StubOpenPanel] = []
def openpanel_factory(*, client_id, client_secret):
client = _StubOpenPanel(
client_id=client_id, client_secret=client_secret, disabled=False
)
created.append(client)
return client
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
telemetry.track_app_started("cli")
assert created
assert created[0].events[-1] == ("app_started", {"mode": "cli"})
def test_track_mcp_tool(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
# Force telemetry to be enabled for this test
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
created: list[_StubOpenPanel] = []
def openpanel_factory(*, client_id, client_secret):
client = _StubOpenPanel(
client_id=client_id, client_secret=client_secret, disabled=False
)
created.append(client)
return client
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
telemetry.track_mcp_tool("write_note")
assert created
assert created[0].events[-1] == ("mcp_tool_called", {"tool": "write_note"})
def test_track_error_truncates_message(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
# Force telemetry to be enabled for this test
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
created: list[_StubOpenPanel] = []
def openpanel_factory(*, client_id, client_secret):
client = _StubOpenPanel(
client_id=client_id, client_secret=client_secret, disabled=False
)
created.append(client)
return client
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
telemetry.track_error("ValueError", "x" * 500)
_, props = created[0].events[-1]
assert len(props["message"]) == 200
def test_track_error_sanitizes_file_paths(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
# Force telemetry to be enabled for this test
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
created: list[_StubOpenPanel] = []
def openpanel_factory(*, client_id, client_secret):
client = _StubOpenPanel(
client_id=client_id, client_secret=client_secret, disabled=False
)
created.append(client)
return client
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
telemetry.track_error("FileNotFoundError", "No such file: /Users/john/notes/secret.md")
_, props = created[0].events[-1]
assert "/Users/john" not in props["message"]
assert "[FILE]" in props["message"]
telemetry.reset_client()
created.clear()
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
telemetry.track_error("FileNotFoundError", "Cannot open C:\\Users\\john\\docs\\private.txt")
_, props = created[0].events[-1]
assert "C:\\Users\\john" not in props["message"]
assert "[FILE]" in props["message"]
Generated
-14
View File
@@ -156,7 +156,6 @@ dependencies = [
{ name = "mdformat-frontmatter" },
{ name = "mdformat-gfm" },
{ name = "nest-asyncio" },
{ name = "openpanel" },
{ name = "pillow" },
{ name = "psycopg" },
{ name = "pybars3" },
@@ -212,7 +211,6 @@ requires-dist = [
{ name = "mdformat-frontmatter", specifier = ">=2.0.8" },
{ name = "mdformat-gfm", specifier = ">=0.3.7" },
{ name = "nest-asyncio", specifier = ">=1.6.0" },
{ name = "openpanel", specifier = ">=0.0.1" },
{ name = "pillow", specifier = ">=11.1.0" },
{ name = "psycopg", specifier = "==3.3.1" },
{ name = "pybars3", specifier = ">=0.9.7" },
@@ -1359,18 +1357,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/dd/b3fd642260cb17532f66cc1e8250f3507d1e580483e209dc1e9d13bd980d/openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60", size = 39713, upload-time = "2025-06-07T14:48:54.077Z" },
]
[[package]]
name = "openpanel"
version = "0.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/d2/1ca167988225113a2162fcc528ef309715f348e1ee2bcaa8405222fea08c/openpanel-0.0.1.tar.gz", hash = "sha256:96a27848d670218c03a75528b95b8e3efbd4898665d57b3ee9c81c4b3c06f922", size = 3721, upload-time = "2024-10-16T14:19:41.923Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/73/44ad513438c56d3e9c8dbdbea647a2f79e8be03a80967eeaa873f29a0527/openpanel-0.0.1-py3-none-any.whl", hash = "sha256:c4d5a31694d4307975bbf70bbb2fa9fae920df0fb4b8ff97fa7b78ee8fe667b2", size = 15865, upload-time = "2024-10-22T19:27:53.139Z" },
]
[[package]]
name = "packaging"
version = "25.0"