PyBlackboard-LMS 0.1.0__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.
- blackboard_api/__init__.py +27 -0
- blackboard_api/api_quota.py +59 -0
- blackboard_api/auth.py +77 -0
- blackboard_api/client.py +228 -0
- blackboard_api/config.py +55 -0
- blackboard_api/errors.py +26 -0
- blackboard_api/facades/__init__.py +19 -0
- blackboard_api/facades/api_quota.py +19 -0
- blackboard_api/facades/courses.py +96 -0
- blackboard_api/facades/enrollments.py +247 -0
- blackboard_api/facades/resources.py +56 -0
- blackboard_api/facades/terms.py +42 -0
- blackboard_api/facades/users.py +81 -0
- blackboard_api/identifiers.py +81 -0
- blackboard_api/resources/__init__.py +15 -0
- blackboard_api/resources/courses.py +150 -0
- blackboard_api/resources/enrollment_roles.py +16 -0
- blackboard_api/resources/enrollments.py +151 -0
- blackboard_api/resources/nodes.py +99 -0
- blackboard_api/resources/terms.py +71 -0
- blackboard_api/resources/users.py +145 -0
- blackboard_api/services/__init__.py +5 -0
- blackboard_api/services/courses.py +60 -0
- blackboard_api/services/enrollments.py +239 -0
- blackboard_api/services/users.py +28 -0
- blackboard_api/transport.py +141 -0
- blackboard_cli/__init__.py +1 -0
- blackboard_cli/__main__.py +5 -0
- blackboard_cli/application/__init__.py +1 -0
- blackboard_cli/cli.py +596 -0
- blackboard_cli/converters/__init__.py +30 -0
- blackboard_cli/converters/common.py +25 -0
- blackboard_cli/converters/courses.py +15 -0
- blackboard_cli/converters/enrollments.py +15 -0
- blackboard_cli/converters/generic.py +34 -0
- blackboard_cli/converters/nodes.py +25 -0
- blackboard_cli/converters/roles.py +15 -0
- blackboard_cli/converters/users.py +15 -0
- blackboard_cli/encoding.py +14 -0
- blackboard_cli/output/__init__.py +8 -0
- blackboard_cli/output/csv.py +35 -0
- blackboard_cli/output/dataframe.py +12 -0
- blackboard_cli/output/excel.py +23 -0
- blackboard_cli/output/table.py +15 -0
- pyblackboard_lms-0.1.0.dist-info/METADATA +175 -0
- pyblackboard_lms-0.1.0.dist-info/RECORD +50 -0
- pyblackboard_lms-0.1.0.dist-info/WHEEL +5 -0
- pyblackboard_lms-0.1.0.dist-info/entry_points.txt +2 -0
- pyblackboard_lms-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyblackboard_lms-0.1.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
4
|
+
|
|
5
|
+
from .client import BlackboardAPI
|
|
6
|
+
from .identifiers import InvalidIdentifierError
|
|
7
|
+
from .errors import (
|
|
8
|
+
BlackboardAPIError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
QuotaExhaustedError,
|
|
12
|
+
WriteNotEnabledError,
|
|
13
|
+
ResponseFormatError,
|
|
14
|
+
TransportError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BlackboardAPI",
|
|
19
|
+
"BlackboardAPIError",
|
|
20
|
+
"AuthenticationError",
|
|
21
|
+
"NotFoundError",
|
|
22
|
+
"QuotaExhaustedError",
|
|
23
|
+
"WriteNotEnabledError",
|
|
24
|
+
"ResponseFormatError",
|
|
25
|
+
"TransportError",
|
|
26
|
+
"InvalidIdentifierError",
|
|
27
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from .errors import QuotaExhaustedError
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ApiQuotaState:
|
|
13
|
+
max_requests_per_day: Optional[int] = None
|
|
14
|
+
remaining: Optional[int] = None
|
|
15
|
+
retry_after: Optional[int] = None
|
|
16
|
+
|
|
17
|
+
def update_from_response(self, response: requests.Response) -> None:
|
|
18
|
+
quota_limit = response.headers.get("X-Rate-Limit-Limit")
|
|
19
|
+
remaining = response.headers.get("X-Rate-Limit-Remaining")
|
|
20
|
+
retry_after = response.headers.get("Retry-After")
|
|
21
|
+
if remaining is None and (
|
|
22
|
+
self.max_requests_per_day is not None or self.remaining is not None
|
|
23
|
+
):
|
|
24
|
+
logger.warning(
|
|
25
|
+
"Blackboard did not send the remaining-requests header; "
|
|
26
|
+
"preserving the last known value."
|
|
27
|
+
)
|
|
28
|
+
parsed_limit = _parse_int(quota_limit)
|
|
29
|
+
parsed_remaining = _parse_int(remaining)
|
|
30
|
+
parsed_retry_after = _parse_int(retry_after)
|
|
31
|
+
if quota_limit is not None and parsed_limit is None:
|
|
32
|
+
logger.warning(
|
|
33
|
+
"Invalid X-Rate-Limit-Limit header; preserving the prior value."
|
|
34
|
+
)
|
|
35
|
+
if remaining is not None and parsed_remaining is None:
|
|
36
|
+
logger.warning(
|
|
37
|
+
"Invalid X-Rate-Limit-Remaining header; preserving the prior "
|
|
38
|
+
"value."
|
|
39
|
+
)
|
|
40
|
+
if retry_after is not None and parsed_retry_after is None:
|
|
41
|
+
logger.warning(
|
|
42
|
+
"Invalid Retry-After header; preserving the prior value."
|
|
43
|
+
)
|
|
44
|
+
if parsed_limit is not None:
|
|
45
|
+
self.max_requests_per_day = parsed_limit
|
|
46
|
+
if parsed_remaining is not None:
|
|
47
|
+
self.remaining = parsed_remaining
|
|
48
|
+
if parsed_retry_after is not None:
|
|
49
|
+
self.retry_after = parsed_retry_after
|
|
50
|
+
if self.remaining == 0:
|
|
51
|
+
logger.error("Blackboard reported zero remaining API requests")
|
|
52
|
+
raise QuotaExhaustedError("Blackboard API quota is exhausted")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_int(value: str | None) -> int | None:
|
|
56
|
+
try:
|
|
57
|
+
return int(value)
|
|
58
|
+
except (TypeError, ValueError):
|
|
59
|
+
return None
|
blackboard_api/auth.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .errors import AuthenticationError, TransportError
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthService:
|
|
14
|
+
def __init__(
|
|
15
|
+
self, base_url: str, client_id: str, client_secret: str, transport: Any
|
|
16
|
+
) -> None:
|
|
17
|
+
self.base_url = base_url.rstrip("/")
|
|
18
|
+
self.client_id = client_id
|
|
19
|
+
self.client_secret = client_secret
|
|
20
|
+
self._transport = transport
|
|
21
|
+
self.token = None
|
|
22
|
+
self.token_expires_at = 0
|
|
23
|
+
|
|
24
|
+
def is_token_expired(self, now: float) -> bool:
|
|
25
|
+
return self.token is None or now >= self.token_expires_at
|
|
26
|
+
|
|
27
|
+
def request_access_token(self) -> dict[str, Any]:
|
|
28
|
+
logger.debug("Requesting OAuth token from Blackboard")
|
|
29
|
+
try:
|
|
30
|
+
response = self._transport.request(
|
|
31
|
+
"POST",
|
|
32
|
+
f"{self.base_url}/learn/api/public/v1/oauth2/token",
|
|
33
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
34
|
+
data={"grant_type": "client_credentials"},
|
|
35
|
+
auth=(self.client_id, self.client_secret),
|
|
36
|
+
track_api_quota=False,
|
|
37
|
+
)
|
|
38
|
+
except TransportError as exc:
|
|
39
|
+
logger.warning("Could not reach the authentication endpoint")
|
|
40
|
+
raise AuthenticationError(
|
|
41
|
+
"Could not reach the authentication endpoint"
|
|
42
|
+
) from exc
|
|
43
|
+
try:
|
|
44
|
+
response.raise_for_status()
|
|
45
|
+
data = response.json()
|
|
46
|
+
except requests.exceptions.HTTPError as exc:
|
|
47
|
+
logger.warning("Blackboard rejected OAuth authentication")
|
|
48
|
+
raise AuthenticationError(
|
|
49
|
+
"Blackboard rejected authentication credentials"
|
|
50
|
+
) from exc
|
|
51
|
+
except ValueError as exc:
|
|
52
|
+
logger.warning("Blackboard returned invalid JSON during authentication")
|
|
53
|
+
raise AuthenticationError("Authentication JSON response is invalid") from exc
|
|
54
|
+
if not isinstance(data, dict) or not data.get("access_token"):
|
|
55
|
+
logger.warning("OAuth response does not contain a valid access_token")
|
|
56
|
+
raise AuthenticationError(
|
|
57
|
+
"Authentication response does not contain access_token"
|
|
58
|
+
)
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
def authenticate(self, now: float) -> str:
|
|
62
|
+
if not self.is_token_expired(now):
|
|
63
|
+
logger.debug("Reusing valid OAuth token")
|
|
64
|
+
return self.token
|
|
65
|
+
data = self.request_access_token()
|
|
66
|
+
self.token = data["access_token"]
|
|
67
|
+
try:
|
|
68
|
+
expires_in = max(1, int(data.get("expires_in", 3600)))
|
|
69
|
+
except (TypeError, ValueError) as exc:
|
|
70
|
+
raise AuthenticationError(
|
|
71
|
+
"Authentication response contains invalid expires_in"
|
|
72
|
+
) from exc
|
|
73
|
+
# Prevent very short-lived tokens from expiring immediately.
|
|
74
|
+
skew = min(60, max(0, expires_in // 10))
|
|
75
|
+
self.token_expires_at = now + expires_in - skew
|
|
76
|
+
logger.debug("OAuth token obtained; expiry uses a safety margin")
|
|
77
|
+
return self.token
|
blackboard_api/client.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import logging
|
|
3
|
+
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .auth import AuthService
|
|
9
|
+
from .config import api_config_from_environment
|
|
10
|
+
from .errors import (
|
|
11
|
+
NotFoundError,
|
|
12
|
+
QuotaExhaustedError,
|
|
13
|
+
WriteNotEnabledError,
|
|
14
|
+
ResponseFormatError,
|
|
15
|
+
)
|
|
16
|
+
from .api_quota import ApiQuotaState
|
|
17
|
+
from .transport import Transport
|
|
18
|
+
from .resources.courses import CourseResource
|
|
19
|
+
from .resources.users import UserResource
|
|
20
|
+
from .resources.enrollments import EnrollmentResource
|
|
21
|
+
from .resources.nodes import NodeResource
|
|
22
|
+
from .resources.enrollment_roles import EnrollmentRoleResource
|
|
23
|
+
from .resources.terms import TermResource
|
|
24
|
+
from .services.enrollments import EnrollmentService
|
|
25
|
+
from .services.users import UserService
|
|
26
|
+
from .services.courses import CourseService, TermService
|
|
27
|
+
from .facades.resources import NodeFacade, EnrollmentRoleFacade
|
|
28
|
+
from .facades.enrollments import EnrollmentFacade
|
|
29
|
+
from .facades.users import UserFacade
|
|
30
|
+
from .facades.courses import CourseFacade
|
|
31
|
+
from .facades.terms import TermFacade
|
|
32
|
+
from .facades.api_quota import ApiQuotaFacade
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BlackboardAPI:
|
|
38
|
+
def __init__(
|
|
39
|
+
self, url: str | None = None, client_id: str | None = None,
|
|
40
|
+
client_secret: str | None = None,
|
|
41
|
+
env_file: str | None = None,
|
|
42
|
+
enable_write: bool = False,
|
|
43
|
+
max_retries: int | None = None,
|
|
44
|
+
results_per_page: int = 100,
|
|
45
|
+
) -> None:
|
|
46
|
+
if url is None and client_id is None and client_secret is None:
|
|
47
|
+
if env_file is None:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"env_file is required when credentials are not provided"
|
|
50
|
+
)
|
|
51
|
+
config = api_config_from_environment(env_file)
|
|
52
|
+
url = config["url"]
|
|
53
|
+
client_id = config["client_id"]
|
|
54
|
+
client_secret = config["client_secret"]
|
|
55
|
+
timeout = (config["connect_timeout"], config["read_timeout"])
|
|
56
|
+
elif not all((url, client_id, client_secret)):
|
|
57
|
+
raise ValueError("Provide all three credentials or none")
|
|
58
|
+
else:
|
|
59
|
+
timeout = (10, 60)
|
|
60
|
+
parsed = urlparse(url)
|
|
61
|
+
if parsed.scheme != "https" or not parsed.netloc:
|
|
62
|
+
raise ValueError("BB_INSTANCE_URL must be a valid HTTPS URL")
|
|
63
|
+
self._url = url.rstrip("/")
|
|
64
|
+
self.enable_write = enable_write
|
|
65
|
+
if not isinstance(self.enable_write, bool):
|
|
66
|
+
raise TypeError("enable_write must be a boolean")
|
|
67
|
+
self.results_per_page = results_per_page
|
|
68
|
+
self._api_quota = ApiQuotaState()
|
|
69
|
+
self._transport = Transport(
|
|
70
|
+
self._api_quota,
|
|
71
|
+
timeout=timeout,
|
|
72
|
+
max_retries=(3 if max_retries is None else max_retries),
|
|
73
|
+
)
|
|
74
|
+
self._auth = AuthService(
|
|
75
|
+
self._url, client_id, client_secret, self._transport
|
|
76
|
+
)
|
|
77
|
+
self._courses_resource = CourseResource(self)
|
|
78
|
+
self._users_resource = UserResource(self)
|
|
79
|
+
self._enrollments_resource = EnrollmentResource(self)
|
|
80
|
+
self._nodes_resource = NodeResource(self)
|
|
81
|
+
self._enrollment_roles_resource = EnrollmentRoleResource(self)
|
|
82
|
+
self._terms_resource = TermResource(self)
|
|
83
|
+
self._enrollment_service = EnrollmentService(
|
|
84
|
+
self._enrollments_resource, self._enrollment_roles_resource
|
|
85
|
+
)
|
|
86
|
+
self._user_service = UserService(self._users_resource)
|
|
87
|
+
self._course_service = CourseService(
|
|
88
|
+
self._courses_resource, self._terms_resource
|
|
89
|
+
)
|
|
90
|
+
self._term_service = TermService(
|
|
91
|
+
self._courses_resource, self._terms_resource
|
|
92
|
+
)
|
|
93
|
+
self.courses = CourseFacade(self._courses_resource, self._course_service)
|
|
94
|
+
self.users = UserFacade(self._users_resource, self._user_service)
|
|
95
|
+
self.enrollments = EnrollmentFacade(
|
|
96
|
+
self._enrollments_resource, self._enrollment_service
|
|
97
|
+
)
|
|
98
|
+
self.nodes = NodeFacade(self._nodes_resource)
|
|
99
|
+
self.enrollment_roles = EnrollmentRoleFacade(self._enrollment_roles_resource)
|
|
100
|
+
self.terms = TermFacade(self._terms_resource, self._term_service)
|
|
101
|
+
self.api_quota = ApiQuotaFacade(
|
|
102
|
+
self._get_api_quota,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def token(self) -> str | None:
|
|
107
|
+
return self._auth.token
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def api_quota_remaining(self) -> int | None:
|
|
111
|
+
return self._api_quota.remaining
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def max_requests_per_day(self) -> int | None:
|
|
115
|
+
return self._api_quota.max_requests_per_day
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def results_per_page(self) -> int:
|
|
119
|
+
"""Return the default number of results requested per collection page."""
|
|
120
|
+
return self._results_per_page
|
|
121
|
+
|
|
122
|
+
@results_per_page.setter
|
|
123
|
+
def results_per_page(self, value: int) -> None:
|
|
124
|
+
"""Set the default collection result count for future requests."""
|
|
125
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
126
|
+
raise ValueError("results_per_page must be a positive integer")
|
|
127
|
+
self._results_per_page = value
|
|
128
|
+
|
|
129
|
+
def _ensure_api_quota(self) -> None:
|
|
130
|
+
"""Probe Blackboard once when no quota headers are known yet."""
|
|
131
|
+
if (
|
|
132
|
+
self._api_quota.max_requests_per_day is None
|
|
133
|
+
and self._api_quota.remaining is None
|
|
134
|
+
):
|
|
135
|
+
self._update_api_quota()
|
|
136
|
+
|
|
137
|
+
def _get_api_quota(self) -> dict[str, int | None]:
|
|
138
|
+
self._ensure_api_quota()
|
|
139
|
+
return {
|
|
140
|
+
"remaining": self.api_quota_remaining,
|
|
141
|
+
"max_requests_per_day": self.max_requests_per_day,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
def _authenticate(self) -> None:
|
|
145
|
+
previous_token = self._auth.token
|
|
146
|
+
token = self._auth.authenticate(time.time())
|
|
147
|
+
if token != previous_token:
|
|
148
|
+
self._update_api_quota()
|
|
149
|
+
|
|
150
|
+
def _get_headers(self) -> dict[str, str]:
|
|
151
|
+
self._authenticate()
|
|
152
|
+
return {
|
|
153
|
+
"Authorization": f"Bearer {self.token}",
|
|
154
|
+
"Content-Type": "application/json",
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
def _request(self, method: str, path_or_url: str, **kwargs: Any) -> requests.Response:
|
|
158
|
+
if not self.enable_write and method.upper() not in {"GET", "HEAD", "OPTIONS"}:
|
|
159
|
+
logger.warning("Writes are disabled; blocked %s", method.upper())
|
|
160
|
+
raise WriteNotEnabledError(
|
|
161
|
+
f"{method.upper()} requires enable_write=True"
|
|
162
|
+
)
|
|
163
|
+
url = (
|
|
164
|
+
path_or_url
|
|
165
|
+
if path_or_url.startswith("http")
|
|
166
|
+
else urljoin(self._url + "/", path_or_url.lstrip("/"))
|
|
167
|
+
)
|
|
168
|
+
headers = kwargs.pop("headers", None)
|
|
169
|
+
if headers is None:
|
|
170
|
+
headers = self._get_headers()
|
|
171
|
+
response = self._transport.request(method, url, headers=headers, **kwargs)
|
|
172
|
+
logger.debug("HTTP request completed: %s", method.upper())
|
|
173
|
+
try:
|
|
174
|
+
response.raise_for_status()
|
|
175
|
+
except requests.exceptions.HTTPError as exc:
|
|
176
|
+
if response.status_code == 404:
|
|
177
|
+
raise NotFoundError(f"Resource not found: {url}") from exc
|
|
178
|
+
raise
|
|
179
|
+
return response
|
|
180
|
+
|
|
181
|
+
def _request_json(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
182
|
+
try:
|
|
183
|
+
return self._request(method, path, **kwargs).json()
|
|
184
|
+
except ValueError as exc:
|
|
185
|
+
raise ResponseFormatError(
|
|
186
|
+
f"Invalid JSON response for {method.upper()} {path}"
|
|
187
|
+
) from exc
|
|
188
|
+
|
|
189
|
+
def _iter_paginated(self, path: str):
|
|
190
|
+
"""Yield collection items one page at a time without accumulating them."""
|
|
191
|
+
url = BlackboardAPI._with_results_per_page(self, path)
|
|
192
|
+
while url:
|
|
193
|
+
data = self._request_json("GET", url)
|
|
194
|
+
if not isinstance(data, dict):
|
|
195
|
+
raise ResponseFormatError(
|
|
196
|
+
"Blackboard collection is not a JSON object"
|
|
197
|
+
)
|
|
198
|
+
if "results" not in data:
|
|
199
|
+
raise ResponseFormatError("Collection does not contain results")
|
|
200
|
+
results = data["results"]
|
|
201
|
+
paging = data.get("paging", {})
|
|
202
|
+
if not isinstance(results, list):
|
|
203
|
+
raise ResponseFormatError("Collection results is not a list")
|
|
204
|
+
if not isinstance(paging, dict):
|
|
205
|
+
raise ResponseFormatError("Collection paging is not an object")
|
|
206
|
+
next_page = paging.get("nextPage")
|
|
207
|
+
if next_page is not None and not isinstance(next_page, str):
|
|
208
|
+
raise ResponseFormatError("Collection paging.nextPage is not text")
|
|
209
|
+
yield from results
|
|
210
|
+
url = next_page
|
|
211
|
+
|
|
212
|
+
def _with_results_per_page(self, path: str) -> str:
|
|
213
|
+
"""Add the configured pagination limit without changing an explicit one."""
|
|
214
|
+
parsed = urlparse(path)
|
|
215
|
+
query = parse_qsl(parsed.query, keep_blank_values=True)
|
|
216
|
+
if not any(key == "limit" for key, _ in query):
|
|
217
|
+
query.append((
|
|
218
|
+
"limit",
|
|
219
|
+
str(getattr(self, "_results_per_page", 100)),
|
|
220
|
+
))
|
|
221
|
+
return urlunparse(parsed._replace(query=urlencode(query)))
|
|
222
|
+
|
|
223
|
+
def _update_api_quota(self) -> dict[str, int | None]:
|
|
224
|
+
self._request("GET", "/learn/api/public/v1/users/me")
|
|
225
|
+
return {
|
|
226
|
+
"max_requests_per_day": self._api_quota.max_requests_per_day,
|
|
227
|
+
"remaining": self._api_quota.remaining,
|
|
228
|
+
}
|
blackboard_api/config.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
DEFAULT_CONNECT_TIMEOUT_SECONDS = 10
|
|
6
|
+
DEFAULT_READ_TIMEOUT_SECONDS = 60
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_environment(path: str | Path) -> dict[str, str]:
|
|
10
|
+
env_path = Path(path)
|
|
11
|
+
if not env_path.is_file():
|
|
12
|
+
raise FileNotFoundError(f"Environment file does not exist: {env_path}")
|
|
13
|
+
values = {}
|
|
14
|
+
for line_number, raw_line in enumerate(env_path.read_text(encoding="utf-8").splitlines(), 1):
|
|
15
|
+
line = raw_line.strip()
|
|
16
|
+
if not line or line.startswith("#"):
|
|
17
|
+
continue
|
|
18
|
+
if "=" not in line:
|
|
19
|
+
raise ValueError(f"Invalid format in {env_path}, line {line_number}")
|
|
20
|
+
key, value = line.split("=", 1)
|
|
21
|
+
value = value.strip()
|
|
22
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
23
|
+
value = value[1:-1]
|
|
24
|
+
values[key.strip()] = value
|
|
25
|
+
return values
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _positive_int(value: Any, name: str, default: int) -> int:
|
|
29
|
+
value = default if value in (None, "") else value
|
|
30
|
+
try:
|
|
31
|
+
value = int(value)
|
|
32
|
+
except (TypeError, ValueError) as exc:
|
|
33
|
+
raise ValueError(f"{name} must be a positive integer") from exc
|
|
34
|
+
if value <= 0:
|
|
35
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def api_config_from_environment(path: str | Path) -> dict[str, Any]:
|
|
40
|
+
values = load_environment(path)
|
|
41
|
+
required = {
|
|
42
|
+
"BB_INSTANCE_URL": values.get("BB_INSTANCE_URL"),
|
|
43
|
+
"APP_KEY": values.get("APP_KEY"),
|
|
44
|
+
"APP_SECRET": values.get("APP_SECRET"),
|
|
45
|
+
}
|
|
46
|
+
missing = [name for name, value in required.items() if not value]
|
|
47
|
+
if missing:
|
|
48
|
+
raise ValueError("Missing required environment variables: " + ", ".join(missing))
|
|
49
|
+
return {
|
|
50
|
+
"url": required["BB_INSTANCE_URL"],
|
|
51
|
+
"client_id": required["APP_KEY"],
|
|
52
|
+
"client_secret": required["APP_SECRET"],
|
|
53
|
+
"connect_timeout": _positive_int(values.get("BB_REQUEST_CONNECT_TIMEOUT") or os.environ.get("BB_REQUEST_CONNECT_TIMEOUT"), "connect timeout", DEFAULT_CONNECT_TIMEOUT_SECONDS),
|
|
54
|
+
"read_timeout": _positive_int(values.get("BB_REQUEST_READ_TIMEOUT") or os.environ.get("BB_REQUEST_READ_TIMEOUT"), "read timeout", DEFAULT_READ_TIMEOUT_SECONDS),
|
|
55
|
+
}
|
blackboard_api/errors.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class BlackboardAPIError(RuntimeError):
|
|
2
|
+
"""Base exception for the Blackboard client."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AuthenticationError(BlackboardAPIError):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class QuotaExhaustedError(BlackboardAPIError):
|
|
10
|
+
"""The quota reported by Blackboard is exactly zero."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TransportError(BlackboardAPIError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ResponseFormatError(BlackboardAPIError):
|
|
18
|
+
"""The Blackboard response has an unexpected format."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotFoundError(BlackboardAPIError):
|
|
22
|
+
"""Blackboard could not find the requested resource."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WriteNotEnabledError(BlackboardAPIError):
|
|
26
|
+
"""The operation is blocked because writes are not enabled."""
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Public facades grouped by resource."""
|
|
2
|
+
|
|
3
|
+
from .resources import ResourceFacade, NodeFacade, EnrollmentRoleFacade
|
|
4
|
+
from .enrollments import EnrollmentFacade
|
|
5
|
+
from .users import UserFacade
|
|
6
|
+
from .courses import CourseFacade
|
|
7
|
+
from .terms import TermFacade
|
|
8
|
+
from .api_quota import ApiQuotaFacade
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ResourceFacade",
|
|
12
|
+
"NodeFacade",
|
|
13
|
+
"EnrollmentRoleFacade",
|
|
14
|
+
"EnrollmentFacade",
|
|
15
|
+
"UserFacade",
|
|
16
|
+
"CourseFacade",
|
|
17
|
+
"TermFacade",
|
|
18
|
+
"ApiQuotaFacade",
|
|
19
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Public facade for the API usage quota."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ApiQuotaFacade:
|
|
9
|
+
"""Expose API quota state separately from pagination settings."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
get_state: Callable[[], dict[str, int | None]],
|
|
14
|
+
) -> None:
|
|
15
|
+
self._get_state = get_state
|
|
16
|
+
|
|
17
|
+
def get(self) -> dict[str, int | None]:
|
|
18
|
+
"""Return remaining requests and the daily request maximum."""
|
|
19
|
+
return self._get_state()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Public facade for atomic and composite course operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .resources import ResourceFacade
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CourseFacade(ResourceFacade):
|
|
12
|
+
"""Expose course resource operations and convenience services."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, resource: Any, service: Any) -> None:
|
|
15
|
+
super().__init__(resource)
|
|
16
|
+
self._service = service
|
|
17
|
+
|
|
18
|
+
def list(self) -> list[dict]:
|
|
19
|
+
return self._resource.list()
|
|
20
|
+
|
|
21
|
+
def iter(self) -> Iterator[dict]:
|
|
22
|
+
return self._resource.iter()
|
|
23
|
+
|
|
24
|
+
def get(self, *, course_identifier: str) -> dict:
|
|
25
|
+
return self._resource.get(course_identifier=course_identifier)
|
|
26
|
+
|
|
27
|
+
def create(self, data: dict) -> dict:
|
|
28
|
+
"""Create a course without Blackboard-generated ``id`` or UUID."""
|
|
29
|
+
return self._resource.create(data)
|
|
30
|
+
|
|
31
|
+
def update(self, *, course_identifier: str, data: dict) -> dict:
|
|
32
|
+
return self._resource.update(
|
|
33
|
+
course_identifier=course_identifier, data=data
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def delete(self, *, course_identifier: str) -> None:
|
|
37
|
+
return self._resource.delete(course_identifier=course_identifier)
|
|
38
|
+
|
|
39
|
+
def set_available(self, *, course_identifier: str) -> dict:
|
|
40
|
+
return self._resource.set_available(course_identifier=course_identifier)
|
|
41
|
+
|
|
42
|
+
def set_unavailable(self, *, course_identifier: str) -> dict:
|
|
43
|
+
return self._resource.set_unavailable(course_identifier=course_identifier)
|
|
44
|
+
|
|
45
|
+
def set_disabled(self, *, course_identifier: str) -> dict:
|
|
46
|
+
return self._resource.set_disabled(course_identifier=course_identifier)
|
|
47
|
+
|
|
48
|
+
def assign_node(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
course_identifier: str,
|
|
52
|
+
node_identifier: str,
|
|
53
|
+
primary: bool | None = None,
|
|
54
|
+
) -> dict:
|
|
55
|
+
return self._resource.assign_node(
|
|
56
|
+
course_identifier=course_identifier,
|
|
57
|
+
node_identifier=node_identifier,
|
|
58
|
+
primary=primary,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def unassign_node(
|
|
62
|
+
self, *, course_identifier: str, node_identifier: str
|
|
63
|
+
) -> None:
|
|
64
|
+
return self._resource.unassign_node(
|
|
65
|
+
course_identifier=course_identifier,
|
|
66
|
+
node_identifier=node_identifier,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def list_by_node(self, *, node_identifier: str) -> list[dict]:
|
|
70
|
+
return self._resource.list_by_node(node_identifier=node_identifier)
|
|
71
|
+
|
|
72
|
+
def iter_by_node(self, *, node_identifier: str) -> Iterator[dict]:
|
|
73
|
+
return self._resource.iter_by_node(node_identifier=node_identifier)
|
|
74
|
+
|
|
75
|
+
def assign_term(
|
|
76
|
+
self, *, course_identifier: str, term_identifier: str
|
|
77
|
+
) -> dict:
|
|
78
|
+
return self._service.assign_term(
|
|
79
|
+
course_identifier=course_identifier,
|
|
80
|
+
term_identifier=term_identifier,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def unassign_term(self, *, course_identifier: str) -> dict:
|
|
84
|
+
return self._service.unassign_term(
|
|
85
|
+
course_identifier=course_identifier
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def list_by_term(self, *, term_identifier: str) -> list[dict]:
|
|
89
|
+
"""List courses assigned to a term by primary ID or ``externalId``."""
|
|
90
|
+
return self._service.list_by_term(term_identifier=term_identifier)
|
|
91
|
+
|
|
92
|
+
def get_copy_history(
|
|
93
|
+
self, *, course_identifier: str
|
|
94
|
+
) -> list[dict] | None:
|
|
95
|
+
"""Return the copy history of a given course."""
|
|
96
|
+
return self._service.get_copy_history(course_identifier=course_identifier)
|