basic-memory 0.12.3__py3-none-any.whl → 0.13.0b2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (107) hide show
  1. basic_memory/__init__.py +7 -1
  2. basic_memory/alembic/env.py +1 -1
  3. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  4. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -5
  5. basic_memory/api/app.py +43 -13
  6. basic_memory/api/routers/__init__.py +4 -2
  7. basic_memory/api/routers/directory_router.py +63 -0
  8. basic_memory/api/routers/importer_router.py +152 -0
  9. basic_memory/api/routers/knowledge_router.py +127 -38
  10. basic_memory/api/routers/management_router.py +78 -0
  11. basic_memory/api/routers/memory_router.py +4 -59
  12. basic_memory/api/routers/project_router.py +230 -0
  13. basic_memory/api/routers/prompt_router.py +260 -0
  14. basic_memory/api/routers/search_router.py +3 -21
  15. basic_memory/api/routers/utils.py +130 -0
  16. basic_memory/api/template_loader.py +292 -0
  17. basic_memory/cli/app.py +20 -21
  18. basic_memory/cli/commands/__init__.py +2 -1
  19. basic_memory/cli/commands/auth.py +136 -0
  20. basic_memory/cli/commands/db.py +3 -3
  21. basic_memory/cli/commands/import_chatgpt.py +31 -207
  22. basic_memory/cli/commands/import_claude_conversations.py +16 -142
  23. basic_memory/cli/commands/import_claude_projects.py +33 -143
  24. basic_memory/cli/commands/import_memory_json.py +26 -83
  25. basic_memory/cli/commands/mcp.py +71 -18
  26. basic_memory/cli/commands/project.py +99 -67
  27. basic_memory/cli/commands/status.py +19 -9
  28. basic_memory/cli/commands/sync.py +44 -58
  29. basic_memory/cli/main.py +1 -5
  30. basic_memory/config.py +144 -88
  31. basic_memory/db.py +6 -4
  32. basic_memory/deps.py +227 -30
  33. basic_memory/importers/__init__.py +27 -0
  34. basic_memory/importers/base.py +79 -0
  35. basic_memory/importers/chatgpt_importer.py +222 -0
  36. basic_memory/importers/claude_conversations_importer.py +172 -0
  37. basic_memory/importers/claude_projects_importer.py +148 -0
  38. basic_memory/importers/memory_json_importer.py +93 -0
  39. basic_memory/importers/utils.py +58 -0
  40. basic_memory/markdown/entity_parser.py +5 -2
  41. basic_memory/mcp/auth_provider.py +270 -0
  42. basic_memory/mcp/external_auth_provider.py +321 -0
  43. basic_memory/mcp/project_session.py +103 -0
  44. basic_memory/mcp/prompts/continue_conversation.py +18 -68
  45. basic_memory/mcp/prompts/recent_activity.py +19 -3
  46. basic_memory/mcp/prompts/search.py +14 -140
  47. basic_memory/mcp/prompts/utils.py +3 -3
  48. basic_memory/mcp/{tools → resources}/project_info.py +6 -2
  49. basic_memory/mcp/server.py +82 -8
  50. basic_memory/mcp/supabase_auth_provider.py +463 -0
  51. basic_memory/mcp/tools/__init__.py +20 -0
  52. basic_memory/mcp/tools/build_context.py +11 -1
  53. basic_memory/mcp/tools/canvas.py +15 -2
  54. basic_memory/mcp/tools/delete_note.py +12 -4
  55. basic_memory/mcp/tools/edit_note.py +297 -0
  56. basic_memory/mcp/tools/list_directory.py +154 -0
  57. basic_memory/mcp/tools/move_note.py +87 -0
  58. basic_memory/mcp/tools/project_management.py +300 -0
  59. basic_memory/mcp/tools/read_content.py +15 -6
  60. basic_memory/mcp/tools/read_note.py +17 -5
  61. basic_memory/mcp/tools/recent_activity.py +11 -2
  62. basic_memory/mcp/tools/search.py +10 -1
  63. basic_memory/mcp/tools/utils.py +137 -12
  64. basic_memory/mcp/tools/write_note.py +11 -15
  65. basic_memory/models/__init__.py +3 -2
  66. basic_memory/models/knowledge.py +16 -4
  67. basic_memory/models/project.py +80 -0
  68. basic_memory/models/search.py +8 -5
  69. basic_memory/repository/__init__.py +2 -0
  70. basic_memory/repository/entity_repository.py +8 -3
  71. basic_memory/repository/observation_repository.py +35 -3
  72. basic_memory/repository/project_info_repository.py +3 -2
  73. basic_memory/repository/project_repository.py +85 -0
  74. basic_memory/repository/relation_repository.py +8 -2
  75. basic_memory/repository/repository.py +107 -15
  76. basic_memory/repository/search_repository.py +87 -27
  77. basic_memory/schemas/__init__.py +6 -0
  78. basic_memory/schemas/directory.py +30 -0
  79. basic_memory/schemas/importer.py +34 -0
  80. basic_memory/schemas/memory.py +26 -12
  81. basic_memory/schemas/project_info.py +112 -2
  82. basic_memory/schemas/prompt.py +90 -0
  83. basic_memory/schemas/request.py +56 -2
  84. basic_memory/schemas/search.py +1 -1
  85. basic_memory/services/__init__.py +2 -1
  86. basic_memory/services/context_service.py +208 -95
  87. basic_memory/services/directory_service.py +167 -0
  88. basic_memory/services/entity_service.py +385 -5
  89. basic_memory/services/exceptions.py +6 -0
  90. basic_memory/services/file_service.py +14 -15
  91. basic_memory/services/initialization.py +144 -67
  92. basic_memory/services/link_resolver.py +16 -8
  93. basic_memory/services/project_service.py +548 -0
  94. basic_memory/services/search_service.py +77 -2
  95. basic_memory/sync/background_sync.py +25 -0
  96. basic_memory/sync/sync_service.py +10 -9
  97. basic_memory/sync/watch_service.py +63 -39
  98. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  99. basic_memory/templates/prompts/search.hbs +101 -0
  100. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b2.dist-info}/METADATA +23 -1
  101. basic_memory-0.13.0b2.dist-info/RECORD +132 -0
  102. basic_memory/api/routers/project_info_router.py +0 -274
  103. basic_memory/mcp/main.py +0 -24
  104. basic_memory-0.12.3.dist-info/RECORD +0 -100
  105. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b2.dist-info}/WHEEL +0 -0
  106. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b2.dist-info}/entry_points.txt +0 -0
  107. {basic_memory-0.12.3.dist-info → basic_memory-0.13.0b2.dist-info}/licenses/LICENSE +0 -0
@@ -10,6 +10,10 @@ from basic_memory.deps import (
10
10
  get_search_service,
11
11
  SearchServiceDep,
12
12
  LinkResolverDep,
13
+ ProjectPathDep,
14
+ FileServiceDep,
15
+ ProjectConfigDep,
16
+ AppConfigDep,
13
17
  )
14
18
  from basic_memory.schemas import (
15
19
  EntityListResponse,
@@ -17,8 +21,8 @@ from basic_memory.schemas import (
17
21
  DeleteEntitiesResponse,
18
22
  DeleteEntitiesRequest,
19
23
  )
24
+ from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest
20
25
  from basic_memory.schemas.base import Permalink, Entity
21
- from basic_memory.services.exceptions import EntityNotFoundError
22
26
 
23
27
  router = APIRouter(prefix="/knowledge", tags=["knowledge"])
24
28
 
@@ -44,43 +48,36 @@ async def create_entity(
44
48
  result = EntityResponse.model_validate(entity)
45
49
 
46
50
  logger.info(
47
- "API response",
48
- endpoint="create_entity",
49
- title=result.title,
50
- permalink=result.permalink,
51
- status_code=201,
51
+ f"API response: endpoint='create_entity' title={result.title}, permalink={result.permalink}, status_code=201"
52
52
  )
53
53
  return result
54
54
 
55
55
 
56
56
  @router.put("/entities/{permalink:path}", response_model=EntityResponse)
57
57
  async def create_or_update_entity(
58
+ project: ProjectPathDep,
58
59
  permalink: Permalink,
59
60
  data: Entity,
60
61
  response: Response,
61
62
  background_tasks: BackgroundTasks,
62
63
  entity_service: EntityServiceDep,
63
64
  search_service: SearchServiceDep,
65
+ file_service: FileServiceDep,
64
66
  ) -> EntityResponse:
65
67
  """Create or update an entity. If entity exists, it will be updated, otherwise created."""
66
68
  logger.info(
67
- "API request",
68
- endpoint="create_or_update_entity",
69
- permalink=permalink,
70
- entity_type=data.entity_type,
71
- title=data.title,
69
+ f"API request: create_or_update_entity for {project=}, {permalink=}, {data.entity_type=}, {data.title=}"
72
70
  )
73
71
 
74
72
  # Validate permalink matches
75
73
  if data.permalink != permalink:
76
74
  logger.warning(
77
- "API validation error",
78
- endpoint="create_or_update_entity",
79
- permalink=permalink,
80
- data_permalink=data.permalink,
81
- error="Permalink mismatch",
75
+ f"API validation error: creating/updating entity with permalink mismatch - url={permalink}, data={data.permalink}",
76
+ )
77
+ raise HTTPException(
78
+ status_code=400,
79
+ detail=f"Entity permalink {data.permalink} must match URL path: '{permalink}'",
82
80
  )
83
- raise HTTPException(status_code=400, detail="Entity permalink must match URL path")
84
81
 
85
82
  # Try create_or_update operation
86
83
  entity, created = await entity_service.create_or_update_entity(data)
@@ -91,38 +88,130 @@ async def create_or_update_entity(
91
88
  result = EntityResponse.model_validate(entity)
92
89
 
93
90
  logger.info(
94
- "API response",
95
- endpoint="create_or_update_entity",
96
- title=result.title,
97
- permalink=result.permalink,
98
- created=created,
99
- status_code=response.status_code,
91
+ f"API response: {result.title=}, {result.permalink=}, {created=}, status_code={response.status_code}"
100
92
  )
101
93
  return result
102
94
 
103
95
 
96
+ @router.patch("/entities/{identifier:path}", response_model=EntityResponse)
97
+ async def edit_entity(
98
+ identifier: str,
99
+ data: EditEntityRequest,
100
+ background_tasks: BackgroundTasks,
101
+ entity_service: EntityServiceDep,
102
+ search_service: SearchServiceDep,
103
+ ) -> EntityResponse:
104
+ """Edit an existing entity using various operations like append, prepend, find_replace, or replace_section.
105
+
106
+ This endpoint allows for targeted edits without requiring the full entity content.
107
+ """
108
+ logger.info(
109
+ f"API request: endpoint='edit_entity', identifier='{identifier}', operation='{data.operation}'"
110
+ )
111
+
112
+ try:
113
+ # Edit the entity using the service
114
+ entity = await entity_service.edit_entity(
115
+ identifier=identifier,
116
+ operation=data.operation,
117
+ content=data.content,
118
+ section=data.section,
119
+ find_text=data.find_text,
120
+ expected_replacements=data.expected_replacements,
121
+ )
122
+
123
+ # Reindex the updated entity
124
+ await search_service.index_entity(entity, background_tasks=background_tasks)
125
+
126
+ # Return the updated entity response
127
+ result = EntityResponse.model_validate(entity)
128
+
129
+ logger.info(
130
+ "API response",
131
+ endpoint="edit_entity",
132
+ identifier=identifier,
133
+ operation=data.operation,
134
+ permalink=result.permalink,
135
+ status_code=200,
136
+ )
137
+
138
+ return result
139
+
140
+ except Exception as e:
141
+ logger.error(f"Error editing entity: {e}")
142
+ raise HTTPException(status_code=400, detail=str(e))
143
+
144
+
145
+ @router.post("/move")
146
+ async def move_entity(
147
+ data: MoveEntityRequest,
148
+ background_tasks: BackgroundTasks,
149
+ entity_service: EntityServiceDep,
150
+ project_config: ProjectConfigDep,
151
+ app_config: AppConfigDep,
152
+ search_service: SearchServiceDep,
153
+ ) -> EntityResponse:
154
+ """Move an entity to a new file location with project consistency.
155
+
156
+ This endpoint moves a note to a different path while maintaining project
157
+ consistency and optionally updating permalinks based on configuration.
158
+ """
159
+ logger.info(
160
+ f"API request: endpoint='move_entity', identifier='{data.identifier}', destination='{data.destination_path}'"
161
+ )
162
+
163
+ try:
164
+ # Move the entity using the service
165
+ moved_entity = await entity_service.move_entity(
166
+ identifier=data.identifier,
167
+ destination_path=data.destination_path,
168
+ project_config=project_config,
169
+ app_config=app_config,
170
+ )
171
+
172
+ # Get the moved entity to reindex it
173
+ entity = await entity_service.link_resolver.resolve_link(data.destination_path)
174
+ if entity:
175
+ await search_service.index_entity(entity, background_tasks=background_tasks)
176
+
177
+ logger.info(
178
+ "API response",
179
+ endpoint="move_entity",
180
+ identifier=data.identifier,
181
+ destination=data.destination_path,
182
+ status_code=200,
183
+ )
184
+ result = EntityResponse.model_validate(moved_entity)
185
+ return result
186
+
187
+ except Exception as e:
188
+ logger.error(f"Error moving entity: {e}")
189
+ raise HTTPException(status_code=400, detail=str(e))
190
+
191
+
104
192
  ## Read endpoints
105
193
 
106
194
 
107
- @router.get("/entities/{permalink:path}", response_model=EntityResponse)
195
+ @router.get("/entities/{identifier:path}", response_model=EntityResponse)
108
196
  async def get_entity(
109
197
  entity_service: EntityServiceDep,
110
- permalink: str,
198
+ link_resolver: LinkResolverDep,
199
+ identifier: str,
111
200
  ) -> EntityResponse:
112
- """Get a specific entity by ID.
201
+ """Get a specific entity by file path or permalink..
113
202
 
114
203
  Args:
115
- permalink: Entity path ID
116
- content: If True, include full file content
204
+ identifier: Entity file path or permalink
117
205
  :param entity_service: EntityService
206
+ :param link_resolver: LinkResolver
118
207
  """
119
- logger.info(f"request: get_entity with permalink={permalink}")
120
- try:
121
- entity = await entity_service.get_by_permalink(permalink)
122
- result = EntityResponse.model_validate(entity)
123
- return result
124
- except EntityNotFoundError:
125
- raise HTTPException(status_code=404, detail=f"Entity with {permalink} not found")
208
+ logger.info(f"request: get_entity with identifier={identifier}")
209
+ entity = await link_resolver.resolve_link(identifier)
210
+ if not entity:
211
+ raise HTTPException(status_code=404, detail=f"Entity {identifier} not found")
212
+
213
+ result = EntityResponse.model_validate(entity)
214
+ return result
126
215
 
127
216
 
128
217
  @router.get("/entities", response_model=EntityListResponse)
@@ -161,8 +250,8 @@ async def delete_entity(
161
250
  # Delete the entity
162
251
  deleted = await entity_service.delete_entity(entity.permalink or entity.id)
163
252
 
164
- # Remove from search index
165
- background_tasks.add_task(search_service.delete_by_permalink, entity.permalink)
253
+ # Remove from search index (entity, observations, and relations)
254
+ background_tasks.add_task(search_service.handle_delete, entity)
166
255
 
167
256
  result = DeleteEntitiesResponse(deleted=deleted)
168
257
  return result
@@ -185,4 +274,4 @@ async def delete_entities(
185
274
  background_tasks.add_task(search_service.delete_by_permalink, permalink)
186
275
 
187
276
  result = DeleteEntitiesResponse(deleted=deleted)
188
- return result
277
+ return result
@@ -0,0 +1,78 @@
1
+ """Management router for basic-memory API."""
2
+
3
+ import asyncio
4
+
5
+ from fastapi import APIRouter, Request
6
+ from loguru import logger
7
+ from pydantic import BaseModel
8
+
9
+ from basic_memory.config import app_config
10
+ from basic_memory.deps import SyncServiceDep, ProjectRepositoryDep
11
+
12
+ router = APIRouter(prefix="/management", tags=["management"])
13
+
14
+
15
+ class WatchStatusResponse(BaseModel):
16
+ """Response model for watch status."""
17
+
18
+ running: bool
19
+ """Whether the watch service is currently running."""
20
+
21
+
22
+ @router.get("/watch/status", response_model=WatchStatusResponse)
23
+ async def get_watch_status(request: Request) -> WatchStatusResponse:
24
+ """Get the current status of the watch service."""
25
+ return WatchStatusResponse(
26
+ running=request.app.state.watch_task is not None and not request.app.state.watch_task.done()
27
+ )
28
+
29
+
30
+ @router.post("/watch/start", response_model=WatchStatusResponse)
31
+ async def start_watch_service(
32
+ request: Request, project_repository: ProjectRepositoryDep, sync_service: SyncServiceDep
33
+ ) -> WatchStatusResponse:
34
+ """Start the watch service if it's not already running."""
35
+
36
+ # needed because of circular imports from sync -> app
37
+ from basic_memory.sync import WatchService
38
+ from basic_memory.sync.background_sync import create_background_sync_task
39
+
40
+ if request.app.state.watch_task is not None and not request.app.state.watch_task.done():
41
+ # Watch service is already running
42
+ return WatchStatusResponse(running=True)
43
+
44
+ # Create and start a new watch service
45
+ logger.info("Starting watch service via management API")
46
+
47
+ # Get services needed for the watch task
48
+ watch_service = WatchService(
49
+ app_config=app_config,
50
+ project_repository=project_repository,
51
+ )
52
+
53
+ # Create and store the task
54
+ watch_task = create_background_sync_task(sync_service, watch_service)
55
+ request.app.state.watch_task = watch_task
56
+
57
+ return WatchStatusResponse(running=True)
58
+
59
+
60
+ @router.post("/watch/stop", response_model=WatchStatusResponse)
61
+ async def stop_watch_service(request: Request) -> WatchStatusResponse: # pragma: no cover
62
+ """Stop the watch service if it's running."""
63
+ if request.app.state.watch_task is None or request.app.state.watch_task.done():
64
+ # Watch service is not running
65
+ return WatchStatusResponse(running=False)
66
+
67
+ # Cancel the running task
68
+ logger.info("Stopping watch service via management API")
69
+ request.app.state.watch_task.cancel()
70
+
71
+ # Wait for it to be properly cancelled
72
+ try:
73
+ await request.app.state.watch_task
74
+ except asyncio.CancelledError:
75
+ pass
76
+
77
+ request.app.state.watch_task = None
78
+ return WatchStatusResponse(running=False)
@@ -1,78 +1,23 @@
1
1
  """Routes for memory:// URI operations."""
2
2
 
3
- from typing import Annotated
3
+ from typing import Annotated, Optional
4
4
 
5
5
  from dateparser import parse
6
6
  from fastapi import APIRouter, Query
7
7
  from loguru import logger
8
8
 
9
9
  from basic_memory.deps import ContextServiceDep, EntityRepositoryDep
10
- from basic_memory.repository import EntityRepository
11
- from basic_memory.repository.search_repository import SearchIndexRow
12
10
  from basic_memory.schemas.base import TimeFrame
13
11
  from basic_memory.schemas.memory import (
14
12
  GraphContext,
15
- RelationSummary,
16
- EntitySummary,
17
- ObservationSummary,
18
- MemoryMetadata,
19
13
  normalize_memory_url,
20
14
  )
21
15
  from basic_memory.schemas.search import SearchItemType
22
- from basic_memory.services.context_service import ContextResultRow
16
+ from basic_memory.api.routers.utils import to_graph_context
23
17
 
24
18
  router = APIRouter(prefix="/memory", tags=["memory"])
25
19
 
26
20
 
27
- async def to_graph_context(context, entity_repository: EntityRepository, page: int, page_size: int):
28
- # return results
29
- async def to_summary(item: SearchIndexRow | ContextResultRow):
30
- match item.type:
31
- case SearchItemType.ENTITY:
32
- return EntitySummary(
33
- title=item.title, # pyright: ignore
34
- permalink=item.permalink,
35
- content=item.content,
36
- file_path=item.file_path,
37
- created_at=item.created_at,
38
- )
39
- case SearchItemType.OBSERVATION:
40
- return ObservationSummary(
41
- title=item.title, # pyright: ignore
42
- file_path=item.file_path,
43
- category=item.category, # pyright: ignore
44
- content=item.content, # pyright: ignore
45
- permalink=item.permalink, # pyright: ignore
46
- created_at=item.created_at,
47
- )
48
- case SearchItemType.RELATION:
49
- from_entity = await entity_repository.find_by_id(item.from_id) # pyright: ignore
50
- to_entity = await entity_repository.find_by_id(item.to_id) if item.to_id else None
51
- return RelationSummary(
52
- title=item.title, # pyright: ignore
53
- file_path=item.file_path,
54
- permalink=item.permalink, # pyright: ignore
55
- relation_type=item.type,
56
- from_entity=from_entity.permalink, # pyright: ignore
57
- to_entity=to_entity.permalink if to_entity else None,
58
- created_at=item.created_at,
59
- )
60
- case _: # pragma: no cover
61
- raise ValueError(f"Unexpected type: {item.type}")
62
-
63
- primary_results = [await to_summary(r) for r in context["primary_results"]]
64
- related_results = [await to_summary(r) for r in context["related_results"]]
65
- metadata = MemoryMetadata.model_validate(context["metadata"])
66
- # Transform to GraphContext
67
- return GraphContext(
68
- primary_results=primary_results,
69
- related_results=related_results,
70
- metadata=metadata,
71
- page=page,
72
- page_size=page_size,
73
- )
74
-
75
-
76
21
  @router.get("/recent", response_model=GraphContext)
77
22
  async def recent(
78
23
  context_service: ContextServiceDep,
@@ -119,7 +64,7 @@ async def get_memory_context(
119
64
  entity_repository: EntityRepositoryDep,
120
65
  uri: str,
121
66
  depth: int = 1,
122
- timeframe: TimeFrame = "7d",
67
+ timeframe: Optional[TimeFrame] = None,
123
68
  page: int = 1,
124
69
  page_size: int = 10,
125
70
  max_related: int = 10,
@@ -133,7 +78,7 @@ async def get_memory_context(
133
78
  memory_url = normalize_memory_url(uri)
134
79
 
135
80
  # Parse timeframe
136
- since = parse(timeframe)
81
+ since = parse(timeframe) if timeframe else None
137
82
  limit = page_size
138
83
  offset = (page - 1) * page_size
139
84
 
@@ -0,0 +1,230 @@
1
+ """Router for project management."""
2
+
3
+ from fastapi import APIRouter, HTTPException, Path, Body
4
+ from typing import Optional
5
+
6
+ from basic_memory.deps import ProjectServiceDep
7
+ from basic_memory.schemas import ProjectInfoResponse
8
+ from basic_memory.schemas.project_info import (
9
+ ProjectList,
10
+ ProjectItem,
11
+ ProjectInfoRequest,
12
+ ProjectStatusResponse,
13
+ )
14
+
15
+ # Router for resources in a specific project
16
+ project_router = APIRouter(prefix="/project", tags=["project"])
17
+
18
+ # Router for managing project resources
19
+ project_resource_router = APIRouter(prefix="/projects", tags=["project_management"])
20
+
21
+
22
+ @project_router.get("/info", response_model=ProjectInfoResponse)
23
+ async def get_project_info(
24
+ project_service: ProjectServiceDep,
25
+ ) -> ProjectInfoResponse:
26
+ """Get comprehensive information about the current Basic Memory project."""
27
+ return await project_service.get_project_info()
28
+
29
+
30
+ # Update a project
31
+ @project_router.patch("/{name}", response_model=ProjectStatusResponse)
32
+ async def update_project(
33
+ project_service: ProjectServiceDep,
34
+ project_name: str = Path(..., description="Name of the project to update"),
35
+ path: Optional[str] = Body(None, description="New path for the project"),
36
+ is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
37
+ ) -> ProjectStatusResponse:
38
+ """Update a project's information in configuration and database.
39
+
40
+ Args:
41
+ project_name: The name of the project to update
42
+ path: Optional new path for the project
43
+ is_active: Optional status update for the project
44
+
45
+ Returns:
46
+ Response confirming the project was updated
47
+ """
48
+ try: # pragma: no cover
49
+ # Get original project info for the response
50
+ old_project = ProjectItem(
51
+ name=project_name,
52
+ path=project_service.projects.get(project_name, ""),
53
+ )
54
+
55
+ await project_service.update_project(project_name, updated_path=path, is_active=is_active)
56
+
57
+ # Get updated project info
58
+ updated_path = path if path else project_service.projects.get(project_name, "")
59
+
60
+ return ProjectStatusResponse(
61
+ message=f"Project '{project_name}' updated successfully",
62
+ status="success",
63
+ default=(project_name == project_service.default_project),
64
+ old_project=old_project,
65
+ new_project=ProjectItem(name=project_name, path=updated_path),
66
+ )
67
+ except ValueError as e: # pragma: no cover
68
+ raise HTTPException(status_code=400, detail=str(e))
69
+
70
+
71
+ # List all available projects
72
+ @project_resource_router.get("/projects", response_model=ProjectList)
73
+ async def list_projects(
74
+ project_service: ProjectServiceDep,
75
+ ) -> ProjectList:
76
+ """List all configured projects.
77
+
78
+ Returns:
79
+ A list of all projects with metadata
80
+ """
81
+ projects = await project_service.list_projects()
82
+ default_project = project_service.default_project
83
+
84
+ project_items = [
85
+ ProjectItem(
86
+ name=project.name,
87
+ path=project.path,
88
+ is_default=project.is_default or False,
89
+ )
90
+ for project in projects
91
+ ]
92
+
93
+ return ProjectList(
94
+ projects=project_items,
95
+ default_project=default_project,
96
+ )
97
+
98
+
99
+ # Add a new project
100
+ @project_resource_router.post("/projects", response_model=ProjectStatusResponse)
101
+ async def add_project(
102
+ project_data: ProjectInfoRequest,
103
+ project_service: ProjectServiceDep,
104
+ ) -> ProjectStatusResponse:
105
+ """Add a new project to configuration and database.
106
+
107
+ Args:
108
+ project_data: The project name and path, with option to set as default
109
+
110
+ Returns:
111
+ Response confirming the project was added
112
+ """
113
+ try: # pragma: no cover
114
+ await project_service.add_project(project_data.name, project_data.path)
115
+
116
+ if project_data.set_default: # pragma: no cover
117
+ await project_service.set_default_project(project_data.name)
118
+
119
+ return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
120
+ message=f"Project '{project_data.name}' added successfully",
121
+ status="success",
122
+ default=project_data.set_default,
123
+ new_project=ProjectItem(
124
+ name=project_data.name, path=project_data.path, is_default=project_data.set_default
125
+ ),
126
+ )
127
+ except ValueError as e: # pragma: no cover
128
+ raise HTTPException(status_code=400, detail=str(e))
129
+
130
+
131
+ # Remove a project
132
+ @project_resource_router.delete("/{name}", response_model=ProjectStatusResponse)
133
+ async def remove_project(
134
+ project_service: ProjectServiceDep,
135
+ name: str = Path(..., description="Name of the project to remove"),
136
+ ) -> ProjectStatusResponse:
137
+ """Remove a project from configuration and database.
138
+
139
+ Args:
140
+ name: The name of the project to remove
141
+
142
+ Returns:
143
+ Response confirming the project was removed
144
+ """
145
+ try:
146
+ old_project = await project_service.get_project(name)
147
+ if not old_project: # pragma: no cover
148
+ raise HTTPException(status_code=404, detail=f"Project: '{name}' does not exist") # pragma: no cover
149
+
150
+ await project_service.remove_project(name)
151
+
152
+ return ProjectStatusResponse(
153
+ message=f"Project '{name}' removed successfully",
154
+ status="success",
155
+ default=False,
156
+ old_project=ProjectItem(name=old_project.name, path=old_project.path),
157
+ new_project=None,
158
+ )
159
+ except ValueError as e: # pragma: no cover
160
+ raise HTTPException(status_code=400, detail=str(e))
161
+
162
+
163
+ # Set a project as default
164
+ @project_resource_router.put("/{name}/default", response_model=ProjectStatusResponse)
165
+ async def set_default_project(
166
+ project_service: ProjectServiceDep,
167
+ name: str = Path(..., description="Name of the project to set as default"),
168
+ ) -> ProjectStatusResponse:
169
+ """Set a project as the default project.
170
+
171
+ Args:
172
+ name: The name of the project to set as default
173
+
174
+ Returns:
175
+ Response confirming the project was set as default
176
+ """
177
+ try:
178
+ # Get the old default project
179
+ default_name = project_service.default_project
180
+ default_project = await project_service.get_project(default_name)
181
+ if not default_project: # pragma: no cover
182
+ raise HTTPException( # pragma: no cover
183
+ status_code=404, detail=f"Default Project: '{default_name}' does not exist"
184
+ )
185
+
186
+ # get the new project
187
+ new_default_project = await project_service.get_project(name)
188
+ if not new_default_project: # pragma: no cover
189
+ raise HTTPException(status_code=404, detail=f"Project: '{name}' does not exist") # pragma: no cover
190
+
191
+ await project_service.set_default_project(name)
192
+
193
+ return ProjectStatusResponse(
194
+ message=f"Project '{name}' set as default successfully",
195
+ status="success",
196
+ default=True,
197
+ old_project=ProjectItem(name=default_name, path=default_project.path),
198
+ new_project=ProjectItem(
199
+ name=name,
200
+ path=new_default_project.path,
201
+ is_default=True,
202
+ ),
203
+ )
204
+ except ValueError as e: # pragma: no cover
205
+ raise HTTPException(status_code=400, detail=str(e))
206
+
207
+
208
+ # Synchronize projects between config and database
209
+ @project_resource_router.post("/sync", response_model=ProjectStatusResponse)
210
+ async def synchronize_projects(
211
+ project_service: ProjectServiceDep,
212
+ ) -> ProjectStatusResponse:
213
+ """Synchronize projects between configuration file and database.
214
+
215
+ Ensures that all projects in the configuration file exist in the database
216
+ and vice versa.
217
+
218
+ Returns:
219
+ Response confirming synchronization was completed
220
+ """
221
+ try: # pragma: no cover
222
+ await project_service.synchronize_projects()
223
+
224
+ return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
225
+ message="Projects synchronized successfully between configuration and database",
226
+ status="success",
227
+ default=False,
228
+ )
229
+ except ValueError as e: # pragma: no cover
230
+ raise HTTPException(status_code=400, detail=str(e))