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

Merged
merged 7 commits into from
Jun 30, 2025
Merged
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
1 change: 1 addition & 0 deletions AUTHORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- Adrian Goetz [@a-goetz](https://github.com/a-goetz)
- Aileen Pongnon [@aileenpongnon](https://github.com/aileenpongnon)
- Alyssa Davis [@allygator](https://github.com/allygator)
- Alex Gabriel Nunez-Carrasquillo [@alportoricensis](https://github.com/alportoricensis)
- [@amorqiu](https://github.com/amorqiu)
- Andrew Gardener [@andrew-gardener](https://github.com/andrew-gardener)
- Anthony Rodriguez [@AnthonyRodriguez726](https://github.com/AnthonyRodriguez726)
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### New Endpoint Coverage

- LTI Resource Links (Thanks, [@jsmnhou](https://github.com/jsmnhou))
- Smart Search API [BETA] (Thanks, [@alportoricensis](https://github.com/alportoricensis))

### General

Expand Down
27 changes: 27 additions & 0 deletions canvasapi/course.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from canvasapi.progress import Progress
from canvasapi.quiz import QuizExtension
from canvasapi.rubric import Rubric, RubricAssociation
from canvasapi.searchresult import SearchResult
from canvasapi.submission import GroupedSubmission, Submission
from canvasapi.tab import Tab
from canvasapi.todo import Todo
Expand Down Expand Up @@ -2744,6 +2745,32 @@ def show_front_page(self, **kwargs):

return Page(self._requester, page_json)

def smartsearch(self, q, **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 q: The search query string.
:type q: 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`
"""
kwargs["q"] = q

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

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


# As of June 30th, 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):
# Using Untitled as a fallback in the event the API changes.
return "<SearchResult: {} - {}>".format(
self.content_type, getattr(self, "title", "Untitled")
)

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

: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"
)

# Use course_id set from Course.smartsearch to create a "fake" Course object to work from
from canvasapi.course import Course

course = Course(self._requester, {"id": self.course_id})

types = {
"WikiPage": course.get_page,
"Assignment": course.get_assignment,
"DiscussionTopic": course.get_discussion_topic,
"Announcement": course.get_discussion_topic,
}

resolver = types.get(self.content_type)
if not resolver:
raise ValueError(
"Resolution not supported for content_type: {}".format(
self.content_type
)
)

return resolver(self.content_id)
1 change: 1 addition & 0 deletions docs/class-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Class Reference
rubric-ref
scope-ref
section-ref
searchresult-ref
sis-import-ref
submission-ref
tab-ref
Expand Down
6 changes: 6 additions & 0 deletions docs/searchresult-ref.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
============
SearchResult
============

.. autoclass:: canvasapi.searchresult.SearchResult
:members:
1 change: 1 addition & 0 deletions scripts/find_missing_kwargs.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"Uploader.upload",
"OutcomeGroup.context_ref",
"OutcomeLink.context_ref",
"SearchResult.resolve",
)


Expand Down
103 changes: 103 additions & 0 deletions tests/fixtures/course.json
Original file line number Diff line number Diff line change
Expand Up @@ -2440,5 +2440,108 @@
}
],
"status_code": 200
},
"smartsearch_basic": {
"method": "GET",
"endpoint": "courses/1/smartsearch?q=Copernicus",
"data": {
"results": [
{
"content_id": 2,
"content_type": "WikiPage",
"title": "Nicolaus Copernicus",
"body": "...",
"html_url": "https://canvas.example.com/courses/1/pages/nicolaus-copernicus",
"distance": 0.212
}
]
}
},
"smartsearch_with_filter": {
"method": "GET",
"endpoint": "courses/1/smartsearch?q=derivatives&filter[]=assignments",
"data": {
"results": [
{
"content_id": 5,
"content_type": "Assignment",
"title": "Chain Rule Practice",
"html_url": "https://canvas.example.com/courses/1/assignments/5",
"distance": 0.112
}
]
}
},
"smartssearch_multiple_results": {
"method": "GET",
"endpoint": "courses/1/smartsearch?q=multiple",
"data": {
"results": [
{
"content_id": 2,
"content_type": "WikiPage",
"title": "Nicolaus Copernicus",
"body": "...",
"html_url": "https://canvas.example.com/courses/1/pages/nicolaus-copernicus",
"distance": 0.212
},
{
"content_id": 5,
"content_type": "Assignment",
"title": "Chain Rule Practice",
"html_url": "https://canvas.example.com/courses/1/assignments/5",
"distance": 0.112
},
{
"content_id": 7,
"content_type": "DiscussionTopic",
"title": "Class Cancelled",
"html_url": "https://canvas.example.com/courses/1/discussion_topics/7",
"distance": 0.312
},
{
"content_id": 6,
"content_type": "Announcement",
"title": "Class Cancelled",
"html_url": "https://canvas.example.com/courses/1/discussion_topics/6",
"distance": 0.412
}
]
}
},
"get_page_smartsearch_variant": {
"method": "GET",
"endpoint": "courses/1/pages/2",
"data": {
"page_id": 2,
"url": "nicolaus-copernicus",
"title": "Nicolaus Copernicus",
"body": "...",
"html_url": "https://canvas.example.com/courses/1/pages/nicolaus-copernicus"
}
},
"get_assignment_smartsearch_variant": {
"method": "GET",
"endpoint": "courses/1/assignments/5",
"data": {
"id": 5,
"name": "Derivatives HW"
}
},
"get_disc_topic_smartsearch_variant": {
"method": "GET",
"endpoint": "courses/1/discussion_topics/6",
"data": {
"id": 6,
"title": "Please Discuss"
}
},
"get_announcement_smartsearch_variant": {
"method": "GET",
"endpoint": "courses/1/discussion_topics/7",
"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 @@ -44,6 +44,7 @@
from canvasapi.progress import Progress
from canvasapi.quiz import Quiz, QuizAssignmentOverrideSet, QuizExtension
from canvasapi.rubric import Rubric, RubricAssociation
from canvasapi.searchresult import SearchResult
from canvasapi.section import Section
from canvasapi.submission import GroupedSubmission, Submission
from canvasapi.tab import Tab
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", filter=["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
Loading