Skip to content

Commit b667bca

Browse files
committed
fix test coverage and type checks
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent e716946 commit b667bca

File tree

12 files changed

+134
-130
lines changed

12 files changed

+134
-130
lines changed

src/basic_memory/cli/commands/project.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,16 @@ def set_default_project(
9898
try:
9999
# Set the default project
100100
config_manager.set_default_project(name)
101-
101+
102102
# Also activate it for the current session by setting the environment variable
103103
os.environ["BASIC_MEMORY_PROJECT"] = name
104-
104+
105105
# Reload configuration to apply the change
106106
from importlib import reload
107107
from basic_memory import config as config_module
108+
108109
reload(config_module)
109-
110+
110111
console.print(f"[green]Project '{name}' set as default and activated[/green]")
111112
except ValueError as e: # pragma: no cover
112113
console.print(f"[red]Error: {e}[/red]")

src/basic_memory/markdown/entity_parser.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,22 @@
44
"""
55

66
from dataclasses import dataclass, field
7-
from pathlib import Path
87
from datetime import datetime
8+
from pathlib import Path
99
from typing import Any, Optional
10-
import dateparser
1110

12-
from markdown_it import MarkdownIt
11+
import dateparser
1312
import frontmatter
13+
from markdown_it import MarkdownIt
1414

1515
from basic_memory.markdown.plugins import observation_plugin, relation_plugin
1616
from basic_memory.markdown.schemas import (
17-
EntityMarkdown,
1817
EntityFrontmatter,
18+
EntityMarkdown,
1919
Observation,
2020
Relation,
2121
)
22+
from basic_memory.utils import parse_tags
2223

2324
md = MarkdownIt().use(observation_plugin).use(relation_plugin)
2425

@@ -56,11 +57,11 @@ def parse(content: str) -> EntityContent:
5657
)
5758

5859

59-
def parse_tags(tags: Any) -> list[str]:
60-
"""Parse tags into list of strings."""
61-
if isinstance(tags, (list, tuple)):
62-
return [str(t).strip() for t in tags if str(t).strip()]
63-
return [t.strip() for t in tags.split(",") if t.strip()]
60+
# def parse_tags(tags: Any) -> list[str]:
61+
# """Parse tags into list of strings."""
62+
# if isinstance(tags, (list, tuple)):
63+
# return [str(t).strip() for t in tags if str(t).strip()]
64+
# return [t.strip() for t in tags.split(",") if t.strip()]
6465

6566

6667
class EntityParser:
@@ -101,7 +102,9 @@ async def parse_file(self, path: Path | str) -> EntityMarkdown:
101102
metadata = post.metadata
102103
metadata["title"] = post.metadata.get("title", absolute_path.name)
103104
metadata["type"] = post.metadata.get("type", "note")
104-
metadata["tags"] = parse_tags(post.metadata.get("tags", []))
105+
tags = parse_tags(post.metadata.get("tags", [])) # pyright: ignore
106+
if tags:
107+
metadata["tags"] = tags
105108

106109
# frontmatter
107110
entity_frontmatter = EntityFrontmatter(

src/basic_memory/markdown/schemas.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ class EntityFrontmatter(BaseModel):
4242

4343
@property
4444
def tags(self) -> List[str]:
45-
return self.metadata.get("tags") if self.metadata else [] # pyright: ignore
45+
return self.metadata.get("tags") if self.metadata else None # pyright: ignore
4646

4747
@property
4848
def title(self) -> str:

src/basic_memory/mcp/tools/utils.py

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
"""
66

77
import typing
8-
from typing import Union, List
98

109
from httpx import Response, URL, AsyncClient, HTTPStatusError
1110
from httpx._client import UseClientDefault, USE_CLIENT_DEFAULT
@@ -24,32 +23,6 @@
2423
from mcp.server.fastmcp.exceptions import ToolError
2524

2625

27-
def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
28-
"""Parse tags from various input formats into a consistent list.
29-
30-
Args:
31-
tags: Can be a list of strings, a comma-separated string, or None
32-
33-
Returns:
34-
A list of tag strings, or an empty list if no tags
35-
"""
36-
if tags is None:
37-
return []
38-
39-
if isinstance(tags, list):
40-
return tags
41-
42-
if isinstance(tags, str):
43-
return [tag.strip() for tag in tags.split(",") if tag.strip()]
44-
45-
# For any other type, try to convert to string and parse
46-
try:
47-
return parse_tags(str(tags))
48-
except (ValueError, TypeError):
49-
logger.warning(f"Couldn't parse tags from input of type {type(tags)}: {tags}")
50-
return []
51-
52-
5326
def get_error_message(status_code: int, url: URL | str, method: str) -> str:
5427
"""Get a friendly error message based on the HTTP status code.
5528

src/basic_memory/mcp/tools/write_note.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66

77
from basic_memory.mcp.async_client import client
88
from basic_memory.mcp.server import mcp
9-
from basic_memory.mcp.tools.utils import call_put, parse_tags
9+
from basic_memory.mcp.tools.utils import call_put
1010
from basic_memory.schemas import EntityResponse
1111
from basic_memory.schemas.base import Entity
12+
from basic_memory.utils import parse_tags
1213

1314
# Define TagType as a Union that can accept either a string or a list of strings or None
1415
TagType = Union[List[str], str, None]
@@ -21,7 +22,7 @@ async def write_note(
2122
title: str,
2223
content: str,
2324
folder: str,
24-
tags = None, # Remove type hint completely to avoid schema issues
25+
tags=None, # Remove type hint completely to avoid schema issues
2526
) -> str:
2627
"""Write a markdown note to the knowledge base.
2728
@@ -64,7 +65,7 @@ async def write_note(
6465

6566
# Process tags using the helper function
6667
tag_list = parse_tags(tags)
67-
68+
6869
# Create the entity request
6970
metadata = {"tags": [f"#{tag}" for tag in tag_list]} if tag_list else None
7071
entity = Entity(

src/basic_memory/services/entity_service.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -146,15 +146,12 @@ async def update_entity(self, entity: EntityModel, schema: EntitySchema) -> Enti
146146

147147
# Create post with new content from schema
148148
post = await schema_to_markdown(schema)
149-
149+
150150
# Merge new metadata with existing metadata
151151
existing_markdown.frontmatter.metadata.update(post.metadata)
152-
152+
153153
# Create a new post with merged metadata
154-
merged_post = frontmatter.Post(
155-
post.content,
156-
**existing_markdown.frontmatter.metadata
157-
)
154+
merged_post = frontmatter.Post(post.content, **existing_markdown.frontmatter.metadata)
158155

159156
# write file
160157
final_content = frontmatter.dumps(merged_post, sort_keys=False)
@@ -322,4 +319,4 @@ async def update_entity_relations(
322319
)
323320
continue
324321

325-
return await self.repository.get_by_file_path(path)
322+
return await self.repository.get_by_file_path(path)

src/basic_memory/sync/watch_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,4 +351,4 @@ async def handle_changes(self, directory: Path, changes: Set[FileChange]):
351351
duration_ms=duration_ms,
352352
)
353353

354-
await self.write_status()
354+
await self.write_status()

src/basic_memory/utils.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import re
77
import sys
88
from pathlib import Path
9-
from typing import Optional, Protocol, Union, runtime_checkable
9+
from typing import Optional, Protocol, Union, runtime_checkable, List
1010

1111
from loguru import logger
1212
from unidecode import unidecode
@@ -128,3 +128,29 @@ def setup_logging(
128128
# Set log levels for noisy loggers
129129
for logger_name, level in noisy_loggers.items():
130130
logging.getLogger(logger_name).setLevel(level)
131+
132+
133+
def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
134+
"""Parse tags from various input formats into a consistent list.
135+
136+
Args:
137+
tags: Can be a list of strings, a comma-separated string, or None
138+
139+
Returns:
140+
A list of tag strings, or an empty list if no tags
141+
"""
142+
if tags is None:
143+
return []
144+
145+
if isinstance(tags, list):
146+
return tags
147+
148+
if isinstance(tags, str):
149+
return [tag.strip() for tag in tags.split(",") if tag.strip()]
150+
151+
# For any other type, try to convert to string and parse
152+
try: # pragma: no cover
153+
return parse_tags(str(tags))
154+
except (ValueError, TypeError): # pragma: no cover
155+
logger.warning(f"Couldn't parse tags from input of type {type(tags)}: {tags}")
156+
return []

tests/cli/test_project_commands.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ def temp_home(monkeypatch):
1717
"""Create a temporary directory for testing."""
1818
# Save the original environment variable if it exists
1919
original_env = os.environ.get("BASIC_MEMORY_PROJECT")
20-
20+
2121
# Clear environment variable for clean test
2222
if "BASIC_MEMORY_PROJECT" in os.environ:
2323
del os.environ["BASIC_MEMORY_PROJECT"]
24-
24+
2525
with TemporaryDirectory() as tempdir:
2626
temp_home = Path(tempdir)
2727
monkeypatch.setattr(Path, "home", lambda: temp_home)
@@ -31,7 +31,7 @@ def temp_home(monkeypatch):
3131
config_dir.mkdir(parents=True, exist_ok=True)
3232

3333
yield temp_home
34-
34+
3535
# Cleanup: restore original environment variable if it existed
3636
if original_env is not None:
3737
os.environ["BASIC_MEMORY_PROJECT"] = original_env
@@ -141,7 +141,7 @@ def test_project_default(cli_runner, temp_home):
141141
# Verify default was set
142142
config_manager = ConfigManager()
143143
assert config_manager.default_project == "test"
144-
144+
145145
# Extra verification: check if the environment variable was set
146146
assert os.environ.get("BASIC_MEMORY_PROJECT") == "test"
147147

@@ -157,7 +157,7 @@ def test_project_current(cli_runner, temp_home):
157157
"default_project": "main",
158158
}
159159
config_file.write_text(json.dumps(config_data))
160-
160+
161161
# Create the main project directory
162162
main_dir = temp_home / "basic-memory"
163163
main_dir.mkdir(parents=True, exist_ok=True)
@@ -192,16 +192,16 @@ def test_project_default_activates_project(cli_runner, temp_home, monkeypatch):
192192
# Create a test environment
193193
env = {}
194194
monkeypatch.setattr(os, "environ", env)
195-
195+
196196
# Create two test projects
197197
config_manager = ConfigManager()
198198
config_manager.add_project("project1", str(temp_home / "project1"))
199-
199+
200200
# Set project1 as default using the CLI command
201201
result = cli_runner.invoke(app, ["project", "default", "project1"])
202202
assert result.exit_code == 0
203203
assert "Project 'project1' set as default and activated" in result.stdout
204-
204+
205205
# Verify the environment variable was set
206206
# This is the core of our fix - the set_default_project command now also sets
207207
# the BASIC_MEMORY_PROJECT environment variable to activate the project

tests/mcp/test_tool_notes.py

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ async def test_delete_note_doesnt_exist(app):
189189
@pytest.mark.asyncio
190190
async def test_write_note_with_tag_array_from_bug_report(app):
191191
"""Test creating a note with a tag array as reported in issue #38.
192-
192+
193193
This reproduces the exact payload from the bug report where Cursor
194194
was passing an array of tags and getting a type mismatch error.
195195
"""
@@ -198,12 +198,12 @@ async def test_write_note_with_tag_array_from_bug_report(app):
198198
"title": "Title",
199199
"folder": "folder",
200200
"content": "CONTENT",
201-
"tags": ["hipporag", "search", "fallback", "symfony", "error-handling"]
201+
"tags": ["hipporag", "search", "fallback", "symfony", "error-handling"],
202202
}
203-
203+
204204
# Try to call the function with this data directly
205205
result = await write_note(**bug_payload)
206-
206+
207207
assert result
208208
assert "permalink: folder/title" in result
209209
assert "Tags" in result
@@ -257,10 +257,10 @@ async def test_write_note_verbose(app):
257257
@pytest.mark.asyncio
258258
async def test_write_note_preserves_custom_metadata(app, test_config):
259259
"""Test that updating a note preserves custom metadata fields.
260-
260+
261261
Reproduces issue #36 where custom frontmatter fields like Status
262262
were being lost when updating notes with the write_note tool.
263-
263+
264264
Should:
265265
- Create a note with custom frontmatter
266266
- Update the note with new content
@@ -273,51 +273,50 @@ async def test_write_note_preserves_custom_metadata(app, test_config):
273273
content="# Initial content",
274274
tags=["test"],
275275
)
276-
276+
277277
# Read the note to get its permalink
278278
content = await read_note("test/custom-metadata-note")
279-
279+
280280
# Now directly update the file with custom frontmatter
281281
# We need to use a direct file update to add custom frontmatter
282-
from pathlib import Path
283282
import frontmatter
284-
283+
285284
file_path = test_config.home / "test" / "Custom Metadata Note.md"
286285
post = frontmatter.load(file_path)
287286

288287
# Add custom frontmatter
289288
post["Status"] = "In Progress"
290289
post["Priority"] = "High"
291290
post["Version"] = "1.0"
292-
291+
293292
# Write the file back
294293
with open(file_path, "w") as f:
295294
f.write(frontmatter.dumps(post))
296-
295+
297296
# Now update the note using write_note
298297
result = await write_note(
299298
title="Custom Metadata Note",
300299
folder="test",
301300
content="# Updated content",
302301
tags=["test", "updated"],
303302
)
304-
303+
305304
# Verify the update was successful
306305
assert "Updated test/Custom Metadata Note.md" in result
307-
306+
308307
# Read the note back and check if custom frontmatter is preserved
309308
content = await read_note("test/custom-metadata-note")
310-
309+
311310
# Custom frontmatter should be preserved
312311
assert "Status: In Progress" in content
313312
assert "Priority: High" in content
314313
# Version might be quoted as '1.0' due to YAML serialization
315314
assert "Version:" in content # Just check that the field exists
316-
assert "1.0" in content # And that the value exists somewhere
317-
315+
assert "1.0" in content # And that the value exists somewhere
316+
318317
# And new content should be there
319318
assert "# Updated content" in content
320-
319+
321320
# And tags should be updated
322321
assert "'#test'" in content
323-
assert "'#updated'" in content
322+
assert "'#updated'" in content

0 commit comments

Comments
 (0)