Skip to content

Added beta support for Canvas SmartSearch API (Issue #659) #690

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions canvasapi/course.py
Original file line number Diff line number Diff line change
Expand Up @@ -2744,6 +2744,33 @@ def show_front_page(self, **kwargs):

return Page(self._requester, page_json)

def smartsearch(self, query, **kwargs):
"""
AI-powered course content search.

:calls: `GET /api/v1/courses/:course_id/smartsearch \
<https://canvas.instructure.com/doc/api/smart_search.html#method.smart_search.search>`_

:param query: The search query string.
:type query: str
:param kwargs: Optional query parameters (e.g., filter, per_page).
:type kwargs: dict
:rtype: :class:`canvasapi.paginated_list.PaginatedList` of
:class:`canvasapi.searchresult.SearchResult`
"""
from canvasapi.searchresult import SearchResult

kwargs["q"] = query

return PaginatedList(
SearchResult,
self._requester,
"GET",
f"courses/{self.id}/smartsearch",
{"course_id": self.id},
_kwargs=combine_kwargs(**kwargs),
)

def submissions_bulk_update(self, **kwargs):
"""
Update the grading and comments on multiple student's assignment
Expand Down
58 changes: 58 additions & 0 deletions canvasapi/searchresult.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from canvasapi.canvas_object import CanvasObject


# NOTE - As of April 25th, 2025, the SmartSearch API is experimental, and may cause breaks
# on code changes. If you've landed here on an error, it could be the API was updated.
class SearchResult(CanvasObject):
"""
Represents a result (which can be of multiple types) return from the SmartSearch API.
<https://canvas.instructure.com/doc/api/smart_search.html#method.smart_search.search>`_
"""

REQUIRED_FIELDS = ["content_id", "content_type", "title", "html_url"]

def __init__(self, requester, attributes):
super(SearchResult, self).__init__(requester, attributes)

missing = [f for f in self.REQUIRED_FIELDS if not hasattr(self, f)]
if missing:
raise ValueError("SearchResult missing required fields: {}".format(missing))

def __str__(self):
# NOTE - Using Untitiled as a fallback in the event the API changes.
return "<SearchResult: {} - {}>".format(
self.content_type, getattr(self, "title", "Untitled")
)

def resolve(self, course):
"""
Resolve this SearchResult into the corresponding Canvas object.

:param course: The Course instance to resolve against.
:type course: :class:`canvasapi.course.Course`
:return: The full object (e.g., Page, Assignment, DiscussionTopic), or None if
resolution fails.
:rtype: :class:`canvasapi.page.Page`, :class:`canvasapi.assignment.Assignment`,
:class:`canvasapi.discussion_topic.DiscussionTopic`
"""
if not hasattr(self, "content_type") or not hasattr(self, "content_id"):
raise ValueError(
"SearchResult is missing 'content_type' or 'content_id' for resolution"
)

content_type = self.content_type.lower()
types = [
("page", course.get_page),
("assignment", course.get_assignment),
("discussion", course.get_discussion_topic),
("announcement", course.get_discussion_topic),
]

for keyword, resolver in types:
if keyword in content_type:
return resolver(self.content_id)

# See NOTE above.
raise ValueError(
"Resolution not supported for content_type: {}".format(self.content_type)
)
52 changes: 52 additions & 0 deletions tests/fixtures/course.json
Original file line number Diff line number Diff line change
Expand Up @@ -2440,5 +2440,57 @@
}
],
"status_code": 200
},
"smartsearch_basic": {
"method": "GET",
"endpoint": "courses/1/smartsearch",
"data": [
{
"content_id": 2,
"content_type": "WikiPage",
"title": "Nicolaus Copernicus",
"body": "...",
"html_url": "https://canvas.example.com/courses/123/pages/nicolaus-copernicus",
"distance": 0.212
}
]
},
"smartsearch_with_filter": {
"method": "GET",
"endpoint": "courses/1/smartsearch",
"data": [
{
"content_id": 5,
"content_type": "Assignment",
"title": "Chain Rule Practice",
"html_url": "https://canvas.example.com/courses/123/assignments/5",
"distance": 0.112
}
]
},
"get_assignment": {
"method": "GET",
"endpoint": "courses/1/assignments/5",
"data": {
"id": 5,
"title": "Derivatives HW"
}
},
"get_announcement": {
"method": "GET",
"endpoint": "courses/1/discussion_topics/7",
"data": {
"id": 7,
"title": "Class Cancelled"
}
},
"get_disc_topic": {
"method": "GET",
"endpoint": "courses/1/discussion_topics/6",
"data": {
"id": 7,
"title": "Class Cancelled"
}
}
}

16 changes: 16 additions & 0 deletions tests/test_course.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
from canvasapi.todo import Todo
from canvasapi.usage_rights import UsageRights
from canvasapi.user import User
from canvasapi.searchresult import SearchResult
from tests import settings
from tests.util import cleanup_file, register_uris

Expand Down Expand Up @@ -1547,6 +1548,21 @@ def test_set_quiz_extensions(self, m):
self.assertTrue(hasattr(extension[1], "extra_attempts"))
self.assertEqual(extension[1].extra_attempts, 3)

def test_smartsearch(self, m):
register_uris({"course": ["smartsearch_basic"]}, m)
results = self.course.smartsearch("Copernicus")
self.assertTrue(results)
self.assertTrue(all(isinstance(r, SearchResult) for r in results))
self.assertEqual(results[0].title, "Nicolaus Copernicus")

def test_smartsearch_with_filter(self, m):
register_uris({"course": ["smartsearch_with_filter"]}, m)
results = self.course.smartsearch("derivatives", kwargs=["assignments"])
results = list(results)
self.assertTrue(results)
self.assertTrue(all(isinstance(r, SearchResult) for r in results))
self.assertEqual(results[0].title, "Chain Rule Practice")

def test_set_extensions_not_list(self, m):
with self.assertRaises(ValueError):
self.course.set_quiz_extensions({"user_id": 1, "extra_time": 60})
Expand Down
120 changes: 120 additions & 0 deletions tests/test_searchresult.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import unittest
import requests_mock
from canvasapi import Canvas
from canvasapi.searchresult import SearchResult
from tests import settings
from tests.util import register_uris


@requests_mock.Mocker()
class TestSearchResult(unittest.TestCase):
def setUp(self):
self.canvas = Canvas(settings.BASE_URL, settings.API_KEY)
with requests_mock.Mocker() as m:
register_uris(
{
"course": [
"get_by_id",
"smartsearch_basic",
"smartsearch_with_filter",
"get_page",
"get_assignment",
"get_discussion_topic",
"get_announcement",
"get_disc_topic",
]
},
m,
)
self.course = self.canvas.get_course(1)
self.basic_result = list(self.course.smartsearch("Copernicus"))[0]
self.assignment_result = list(
self.course.smartsearch("derivatives", filter=["assignments"])
)[0]

def test_str_representation(self, m):
register_uris({"course": ["smartsearch_basic"]}, m)
self.assertEqual(
str(self.basic_result), "<SearchResult: Assignment - Chain Rule Practice>"
)

def test_str_fallback(self, m):
register_uris({"course": ["smartsearch_basic"]}, m)
result = list(self.course.smartsearch("Copernicus"))[0]
delattr(result, "title")
self.assertIn("Untitled", str(result))

def test_missing_fields_raises(self, m):
with self.assertRaises(ValueError):
SearchResult(self.basic_result._requester, {"content_type": "WikiPage"})

def test_resolve_page(self, m):
register_uris({"course": ["get_assignment"]}, m)
resolved = self.basic_result.resolve(self.course)
self.assertEqual(resolved.title, "Derivatives HW")

def test_resolve_assignment(self, m):
register_uris({"course": ["get_assignment"]}, m)
resolved = self.assignment_result.resolve(self.course)
self.assertEqual(resolved.title, "Derivatives HW")

def test_resolve_discussion(self, m):
register_uris({"course": ["get_disc_topic"]}, m)
result = SearchResult(
self.basic_result._requester,
{
"content_id": 6,
"content_type": "DiscussionTopic",
"title": "Intro Discussion",
"html_url": "https://canvas.example.com/discussion",
},
)
resolved = result.resolve(self.course)
self.assertEqual(resolved.title, "Class Cancelled")

def test_resolve_announcement(self, m):
register_uris({"course": ["get_discussion_topic", "get_announcement"]}, m)
result = SearchResult(
self.basic_result._requester,
{
"content_id": 7,
"content_type": "Announcement",
"title": "Class Cancelled",
"html_url": "https://canvas.example.com/announcements",
},
)
resolved = result.resolve(self.course)
self.assertEqual(resolved.title, "Class Cancelled")

def test_resolve_unknown_type_raises(self, m):
result = SearchResult(
self.basic_result._requester,
{
"content_id": 999,
"content_type": "MysteryThing",
"title": "Mystery",
"html_url": "https://canvas.example.com/unknown",
},
)
with self.assertRaises(ValueError):
result.resolve(self.course)

def test_resolve_missing_attrs_raises(self, m):
with self.assertRaises(ValueError):
result = SearchResult(self.basic_result._requester, {"title": "Incomplete"})
result.resolve(self.course)

def test_resolve_raises_if_missing_attrs(self, m):
result = SearchResult(
self.course._requester,
{
"content_id": 42,
"content_type": "Assignment",
"title": "Partial Result",
"html_url": "https://canvas.example.com",
},
)
delattr(result, "content_id")
with self.assertRaises(ValueError) as ctx:
result.resolve(self.course)
self.assertIn("content_type", str(ctx.exception))