lms.backend.canvas.common

  1import datetime
  2import http
  3import re
  4import typing
  5
  6import html2text
  7
  8import edq.net.request
  9import edq.util.json
 10import edq.util.time
 11import requests
 12
 13DEFAULT_PAGE_SIZE: int = 95
 14HEADER_LINK: str = 'Link'
 15
 16def fetch_next_canvas_link(response: requests.Response) -> typing.Union[str, None]:
 17    """
 18    Fetch the Canvas-style next link within the headers.
 19    If there is no next link, return None.
 20    """
 21
 22    headers = response.headers
 23
 24    if (HEADER_LINK not in headers):
 25        return None
 26
 27    links = headers[HEADER_LINK].split(',')
 28    for link in links:
 29        parts = link.split(';')
 30        if (len(parts) != 2):
 31            continue
 32
 33        if (parts[1].strip() != 'rel="next"'):
 34            continue
 35
 36        return str(parts[0].strip().strip('<>'))
 37
 38    return None
 39
 40def make_request(
 41        method: str,
 42        url: str,
 43        raise_on_404: bool = False,
 44        json: bool = True,
 45        **kwargs: typing.Any) -> typing.Union[typing.Any, None]:
 46    """ Make a single Canvas request and return the decoded JSON body. """
 47
 48    try:
 49        _, body_text = edq.net.request.make_request(method, url, **kwargs)
 50    except requests.HTTPError as ex:
 51        if (raise_on_404 or (ex.response is None) or (ex.response.status_code != http.HTTPStatus.NOT_FOUND)):
 52            raise ex
 53
 54        return None
 55
 56    if (not json):
 57        return body_text
 58
 59    return edq.util.json.loads(body_text, strict = True)
 60
 61def make_get_request(url: str, **kwargs: typing.Any) -> typing.Union[typing.Any, None]:
 62    """ Make a single Canvas GET request. """
 63
 64    return make_request('GET', url, **kwargs)
 65
 66def make_post_request(url: str, **kwargs: typing.Any) -> typing.Union[typing.Any, None]:
 67    """ Make a single Canvas POST request. """
 68
 69    return make_request('POST', url, **kwargs)
 70
 71def make_put_request(url: str, **kwargs: typing.Any) -> typing.Union[typing.Any, None]:
 72    """ Make a single Canvas PUT request. """
 73
 74    return make_request('PUT', url, **kwargs)
 75
 76def make_delete_request(url: str, **kwargs: typing.Any) -> typing.Union[typing.Any, None]:
 77    """ Make a single Canvas DELETE request. """
 78
 79    return make_request('DELETE', url, **kwargs)
 80
 81def make_get_request_list(
 82        url: str,
 83        headers: typing.Dict[str, typing.Any],
 84        data: typing.Union[typing.Dict[str, typing.Any], None] = None,
 85        raise_on_404: bool = False,
 86        ) -> typing.Union[typing.List[typing.Dict[str, typing.Any]], None]:
 87    """ Repeatedly call make_get_request() (using a JSON body and next link) until there are no more results. """
 88
 89    output: typing.List[typing.Dict[str, typing.Any]] = []
 90
 91    next_url: typing.Union[str, None] = url
 92
 93    while (next_url is not None):
 94        try:
 95            response, body_text = edq.net.request.make_get(next_url, headers = headers, data = data)
 96        except requests.HTTPError as ex:
 97            if (raise_on_404 or (ex.response is None) or (ex.response.status_code != http.HTTPStatus.NOT_FOUND)):
 98                raise ex
 99
100            return None
101
102        next_url = fetch_next_canvas_link(response)
103
104        new_results = edq.util.json.loads(body_text, strict = True)
105        for new_result in new_results:
106            output.append(new_result)
107
108    return output
109
110def parse_timestamp(value: typing.Union[str, None]) -> typing.Union[edq.util.time.Timestamp, None]:
111    """ Parse a Canvas-style timestamp into a common form. """
112
113    if (value is None):
114        return None
115
116    # Parse out some cases that Python <= 3.10 cannot deal with.
117    value = re.sub(r'Z$', '+00:00', value)
118    value = re.sub(r'(\d\d:\d\d)(\.\d+)', r'\1', value)
119
120    pytime = datetime.datetime.fromisoformat(value)
121    return edq.util.time.Timestamp.from_pytime(pytime)
122
123def html_to_markdown(html: typing.Union[str, None]) -> str:
124    """
125    Parse the text from a Canvas quiz question into markdown.
126    We intend for the resulting markdown to have a little HTML as possible.
127    This is an impossible task, but we want to do our best.
128    """
129
130    if (html is None):
131        return ''
132
133    converter = html2text.HTML2Text()
134
135    converter.body_width = 0
136    converter.mark_code = True
137
138    text = converter.handle(html)
139    text = text.strip()
140
141    # Replace code tags with fences.
142    text = re.sub(r'\[/?code\]', '```', text)
143
144    # Replace placeholders (e.g., for fill in the blank questions).
145    text = re.sub(r'\[(\w+?)\]', r'<placeholder>\1</placeholder>', text)
146
147    return text
DEFAULT_PAGE_SIZE: int = 95
def make_request( method: str, url: str, raise_on_404: bool = False, json: bool = True, **kwargs: Any) -> Optional[Any]:
41def make_request(
42        method: str,
43        url: str,
44        raise_on_404: bool = False,
45        json: bool = True,
46        **kwargs: typing.Any) -> typing.Union[typing.Any, None]:
47    """ Make a single Canvas request and return the decoded JSON body. """
48
49    try:
50        _, body_text = edq.net.request.make_request(method, url, **kwargs)
51    except requests.HTTPError as ex:
52        if (raise_on_404 or (ex.response is None) or (ex.response.status_code != http.HTTPStatus.NOT_FOUND)):
53            raise ex
54
55        return None
56
57    if (not json):
58        return body_text
59
60    return edq.util.json.loads(body_text, strict = True)

Make a single Canvas request and return the decoded JSON body.

def make_get_request(url: str, **kwargs: Any) -> Optional[Any]:
62def make_get_request(url: str, **kwargs: typing.Any) -> typing.Union[typing.Any, None]:
63    """ Make a single Canvas GET request. """
64
65    return make_request('GET', url, **kwargs)

Make a single Canvas GET request.

def make_post_request(url: str, **kwargs: Any) -> Optional[Any]:
67def make_post_request(url: str, **kwargs: typing.Any) -> typing.Union[typing.Any, None]:
68    """ Make a single Canvas POST request. """
69
70    return make_request('POST', url, **kwargs)

Make a single Canvas POST request.

def make_put_request(url: str, **kwargs: Any) -> Optional[Any]:
72def make_put_request(url: str, **kwargs: typing.Any) -> typing.Union[typing.Any, None]:
73    """ Make a single Canvas PUT request. """
74
75    return make_request('PUT', url, **kwargs)

Make a single Canvas PUT request.

def make_delete_request(url: str, **kwargs: Any) -> Optional[Any]:
77def make_delete_request(url: str, **kwargs: typing.Any) -> typing.Union[typing.Any, None]:
78    """ Make a single Canvas DELETE request. """
79
80    return make_request('DELETE', url, **kwargs)

Make a single Canvas DELETE request.

def make_get_request_list( url: str, headers: Dict[str, Any], data: Optional[Dict[str, Any]] = None, raise_on_404: bool = False) -> Optional[List[Dict[str, Any]]]:
 82def make_get_request_list(
 83        url: str,
 84        headers: typing.Dict[str, typing.Any],
 85        data: typing.Union[typing.Dict[str, typing.Any], None] = None,
 86        raise_on_404: bool = False,
 87        ) -> typing.Union[typing.List[typing.Dict[str, typing.Any]], None]:
 88    """ Repeatedly call make_get_request() (using a JSON body and next link) until there are no more results. """
 89
 90    output: typing.List[typing.Dict[str, typing.Any]] = []
 91
 92    next_url: typing.Union[str, None] = url
 93
 94    while (next_url is not None):
 95        try:
 96            response, body_text = edq.net.request.make_get(next_url, headers = headers, data = data)
 97        except requests.HTTPError as ex:
 98            if (raise_on_404 or (ex.response is None) or (ex.response.status_code != http.HTTPStatus.NOT_FOUND)):
 99                raise ex
100
101            return None
102
103        next_url = fetch_next_canvas_link(response)
104
105        new_results = edq.util.json.loads(body_text, strict = True)
106        for new_result in new_results:
107            output.append(new_result)
108
109    return output

Repeatedly call make_get_request() (using a JSON body and next link) until there are no more results.

def parse_timestamp(value: Optional[str]) -> Optional[edq.util.time.Timestamp]:
111def parse_timestamp(value: typing.Union[str, None]) -> typing.Union[edq.util.time.Timestamp, None]:
112    """ Parse a Canvas-style timestamp into a common form. """
113
114    if (value is None):
115        return None
116
117    # Parse out some cases that Python <= 3.10 cannot deal with.
118    value = re.sub(r'Z$', '+00:00', value)
119    value = re.sub(r'(\d\d:\d\d)(\.\d+)', r'\1', value)
120
121    pytime = datetime.datetime.fromisoformat(value)
122    return edq.util.time.Timestamp.from_pytime(pytime)

Parse a Canvas-style timestamp into a common form.

def html_to_markdown(html: Optional[str]) -> str:
124def html_to_markdown(html: typing.Union[str, None]) -> str:
125    """
126    Parse the text from a Canvas quiz question into markdown.
127    We intend for the resulting markdown to have a little HTML as possible.
128    This is an impossible task, but we want to do our best.
129    """
130
131    if (html is None):
132        return ''
133
134    converter = html2text.HTML2Text()
135
136    converter.body_width = 0
137    converter.mark_code = True
138
139    text = converter.handle(html)
140    text = text.strip()
141
142    # Replace code tags with fences.
143    text = re.sub(r'\[/?code\]', '```', text)
144
145    # Replace placeholders (e.g., for fill in the blank questions).
146    text = re.sub(r'\[(\w+?)\]', r'<placeholder>\1</placeholder>', text)
147
148    return text

Parse the text from a Canvas quiz question into markdown. We intend for the resulting markdown to have a little HTML as possible. This is an impossible task, but we want to do our best.