PyBlackboard-LMS 0.1.0__tar.gz
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.
- pyblackboard_lms-0.1.0/.env.example +4 -0
- pyblackboard_lms-0.1.0/CHANGELOG.md +18 -0
- pyblackboard_lms-0.1.0/LICENSE +21 -0
- pyblackboard_lms-0.1.0/MANIFEST.in +18 -0
- pyblackboard_lms-0.1.0/PKG-INFO +175 -0
- pyblackboard_lms-0.1.0/README.md +144 -0
- pyblackboard_lms-0.1.0/pyproject.toml +75 -0
- pyblackboard_lms-0.1.0/setup.cfg +4 -0
- pyblackboard_lms-0.1.0/src/PyBlackboard_LMS.egg-info/SOURCES.txt +50 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/__init__.py +27 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/api_quota.py +59 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/auth.py +77 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/client.py +228 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/config.py +55 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/errors.py +26 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/facades/__init__.py +19 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/facades/api_quota.py +19 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/facades/courses.py +96 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/facades/enrollments.py +247 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/facades/resources.py +56 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/facades/terms.py +42 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/facades/users.py +81 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/identifiers.py +81 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/resources/__init__.py +15 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/resources/courses.py +150 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/resources/enrollment_roles.py +16 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/resources/enrollments.py +151 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/resources/nodes.py +99 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/resources/terms.py +71 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/resources/users.py +145 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/services/__init__.py +5 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/services/courses.py +60 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/services/enrollments.py +239 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/services/users.py +28 -0
- pyblackboard_lms-0.1.0/src/blackboard_api/transport.py +141 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/__init__.py +1 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/__main__.py +5 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/application/__init__.py +1 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/cli.py +596 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/converters/__init__.py +30 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/converters/common.py +25 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/converters/courses.py +15 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/converters/enrollments.py +15 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/converters/generic.py +34 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/converters/nodes.py +25 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/converters/roles.py +15 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/converters/users.py +15 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/encoding.py +14 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/output/__init__.py +8 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/output/csv.py +35 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/output/dataframe.py +12 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/output/excel.py +23 -0
- pyblackboard_lms-0.1.0/src/blackboard_cli/output/table.py +15 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to PyBlackboard-LMS are documented in this file.
|
|
4
|
+
|
|
5
|
+
This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
Version numbers have the form `MAJOR.MINOR.PATCH`:
|
|
7
|
+
|
|
8
|
+
- Increment `MAJOR` for incompatible public API or CLI changes.
|
|
9
|
+
- Increment `MINOR` for backwards-compatible functionality.
|
|
10
|
+
- Increment `PATCH` for backwards-compatible bug fixes.
|
|
11
|
+
|
|
12
|
+
The single authoritative version is `project.version` in `pyproject.toml`.
|
|
13
|
+
|
|
14
|
+
## [0.1.0] - 2026-08-30
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Initial public package structure for the Blackboard API and CLI.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alejandro Amo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
include README.md
|
|
2
|
+
include CHANGELOG.md
|
|
3
|
+
include LICENSE
|
|
4
|
+
include .env.example
|
|
5
|
+
include pyproject.toml
|
|
6
|
+
graft src
|
|
7
|
+
prune src/*.egg-info
|
|
8
|
+
|
|
9
|
+
prune audit
|
|
10
|
+
prune data
|
|
11
|
+
prune devdocs
|
|
12
|
+
prune docs
|
|
13
|
+
prune tests
|
|
14
|
+
global-exclude .env
|
|
15
|
+
global-exclude .env.*
|
|
16
|
+
include .env.example
|
|
17
|
+
global-exclude __pycache__
|
|
18
|
+
global-exclude *.py[cod]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: PyBlackboard-LMS
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python API and command-line interface for Blackboard Learn.
|
|
5
|
+
Author-email: Alex Amo <hello@alejandroamo.eu>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: blackboard,blackboard-learn,cli,education,lms
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Education
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: requests<3,>=2.31
|
|
21
|
+
Requires-Dist: pandas<3,>=2.0
|
|
22
|
+
Requires-Dist: XlsxWriter<4,>=3.1
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: black<27,>=24.0; extra == "dev"
|
|
25
|
+
Requires-Dist: build<2,>=1.2; extra == "dev"
|
|
26
|
+
Requires-Dist: check-wheel-contents<1,>=0.6; extra == "dev"
|
|
27
|
+
Requires-Dist: mypy<2,>=1.10; extra == "dev"
|
|
28
|
+
Requires-Dist: ruff<1,>=0.6; extra == "dev"
|
|
29
|
+
Requires-Dist: twine<7,>=5.1; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# PyBlackboard-LMS
|
|
33
|
+
|
|
34
|
+
Python and command-line interfaces for Blackboard LMS API.
|
|
35
|
+
|
|
36
|
+
The Python API (`blackboard_api`) provides:
|
|
37
|
+
|
|
38
|
+
- Resilient HTTP transport and OAuth authentication.
|
|
39
|
+
- API quota tracking.
|
|
40
|
+
- Transparent pagination for object collections.
|
|
41
|
+
- Iterators for consuming large result sets without accumulating every item in memory.
|
|
42
|
+
- An atomic operations layer for individual requests (`resources`).
|
|
43
|
+
- A service layer for combinations of operations, such as upserts (`services`).
|
|
44
|
+
- Public facades with validated, human-oriented methods.
|
|
45
|
+
|
|
46
|
+
The `blackboard-cli` command exposes every public facade method except iterators,
|
|
47
|
+
and can export data as JSON, CSV, or Microsoft Excel files. It is intended as a
|
|
48
|
+
data exploration and management tool for Blackboard administrators.
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
PyBlackboard-LMS requires Python 3.10 or later.
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
python -m pip install PyBlackboard-LMS
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The package installs the `blackboard-cli` command and the `blackboard_api` module.
|
|
59
|
+
|
|
60
|
+
## Configuration with explicit parameter values (API only)
|
|
61
|
+
|
|
62
|
+
In a Python script:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from blackboard_api import BlackboardAPI
|
|
66
|
+
client = BlackboardAPI(
|
|
67
|
+
url="https://blackboard.example.com",
|
|
68
|
+
client_id="...",
|
|
69
|
+
client_secret="...",
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`url` is the URL of the Blackboard instance. `client_id` is the value called
|
|
74
|
+
`APP_KEY` in the [Blackboard Developer Portal](https://developer.blackboard.com/portal/applications),
|
|
75
|
+
and `client_secret` is its corresponding `APP_SECRET` value.
|
|
76
|
+
|
|
77
|
+
## Configuration with .env files (API and CLI)
|
|
78
|
+
|
|
79
|
+
When creating `BlackboardAPI` without direct credentials, `env_file` is required.
|
|
80
|
+
Both the API and CLI use dotenv-style files as their configuration source.
|
|
81
|
+
|
|
82
|
+
1. Copy the `.env.example` template file to `.env.production.local` and configure it with
|
|
83
|
+
API credentials and Blackboard instance URL of your **PRODUCTION** Blackboard instance.
|
|
84
|
+
|
|
85
|
+
2. Copy the `.env.example` template file to `.env.test.local` and configure it with
|
|
86
|
+
API credentials and Blackboard instance URL of your **TEST** Blackboard instance,
|
|
87
|
+
if you have any.
|
|
88
|
+
|
|
89
|
+
You can then specify the ENV-file path with:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
blackboard-cli --env-file .env.production.local <command>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
or use the API directly:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from blackboard_api import BlackboardAPI
|
|
99
|
+
|
|
100
|
+
client = BlackboardAPI(env_file=".env.production.local")
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The optional `BB_REQUEST_CONNECT_TIMEOUT` and `BB_REQUEST_READ_TIMEOUT` ENV
|
|
104
|
+
settings are positive integer durations in seconds. They default to `10` and
|
|
105
|
+
`60`, respectively. You can add them to env files if you want to tweak them.
|
|
106
|
+
|
|
107
|
+
## Writes are disabled by default
|
|
108
|
+
|
|
109
|
+
To reduce the risk of accidental changes, mutating operations are disabled by
|
|
110
|
+
default. The API blocks `POST`, `PUT`, `PATCH`, and `DELETE` before they reach
|
|
111
|
+
Blackboard unless writes are explicitly enabled.
|
|
112
|
+
|
|
113
|
+
You can enable writing operations by passing
|
|
114
|
+
`enable_write=True` to `BlackboardAPI`, or `--enable-write` to the CLI.
|
|
115
|
+
|
|
116
|
+
## API reference
|
|
117
|
+
|
|
118
|
+
See [the API reference](docs/api_reference.md) for the public interface and
|
|
119
|
+
internal layers.
|
|
120
|
+
|
|
121
|
+
## CLI command reference
|
|
122
|
+
|
|
123
|
+
The CLI exposes one command for every public API facade method except iterators.
|
|
124
|
+
Iterators are intended for progressive data consumption in Python scripts and
|
|
125
|
+
do not provide a useful CLI abstraction.
|
|
126
|
+
|
|
127
|
+
See [the CLI reference](docs/cli_reference.md) for the complete command list.
|
|
128
|
+
|
|
129
|
+
Use `--command-help` to view every available command.
|
|
130
|
+
|
|
131
|
+
## Developers only
|
|
132
|
+
|
|
133
|
+
### Development setup
|
|
134
|
+
|
|
135
|
+
Install the project and development tools in editable mode:
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
python -m pip install -e ".[dev]"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Unit tests
|
|
142
|
+
|
|
143
|
+
Run automated tests with:
|
|
144
|
+
|
|
145
|
+
```text
|
|
146
|
+
python -m unittest discover -s tests/unit -v
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Unit tests are 100% offline and never require Blackboard credentials or an
|
|
150
|
+
environment file.
|
|
151
|
+
|
|
152
|
+
### Live integration tests
|
|
153
|
+
|
|
154
|
+
Run live integration tests with:
|
|
155
|
+
|
|
156
|
+
```text
|
|
157
|
+
python -m tests.integration.run
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
While unit tests are fully offline, live integration tests connect to Blackboard
|
|
161
|
+
and create, modify, and delete test objects. Use an isolated **test tenant** and
|
|
162
|
+
ensure that `.env.test.local` contains only its credentials. The test runner requires
|
|
163
|
+
that file, but cannot verify that its URL is truly a non-production instance.
|
|
164
|
+
If test environment dot-env file is missing or invalid, live integration tests fail.
|
|
165
|
+
|
|
166
|
+
The test runner writes the same messages to standard output and to a timestamped log
|
|
167
|
+
under `data/test-artifacts/logs/`. Use `--log-level DEBUG` for HTTP and
|
|
168
|
+
fixture lifecycle diagnostics. It never logs credentials, OAuth tokens, or
|
|
169
|
+
passwords.
|
|
170
|
+
|
|
171
|
+
The integration test suite uses randomized, easily recognizable identifiers to avoid
|
|
172
|
+
collisions with existing data. Its final test explicitly verifies cleanup,
|
|
173
|
+
and emergency cleanup is attempted when the process exits.
|
|
174
|
+
Failures outside the process or API failures may still require manual review of the
|
|
175
|
+
test tenant.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# PyBlackboard-LMS
|
|
2
|
+
|
|
3
|
+
Python and command-line interfaces for Blackboard LMS API.
|
|
4
|
+
|
|
5
|
+
The Python API (`blackboard_api`) provides:
|
|
6
|
+
|
|
7
|
+
- Resilient HTTP transport and OAuth authentication.
|
|
8
|
+
- API quota tracking.
|
|
9
|
+
- Transparent pagination for object collections.
|
|
10
|
+
- Iterators for consuming large result sets without accumulating every item in memory.
|
|
11
|
+
- An atomic operations layer for individual requests (`resources`).
|
|
12
|
+
- A service layer for combinations of operations, such as upserts (`services`).
|
|
13
|
+
- Public facades with validated, human-oriented methods.
|
|
14
|
+
|
|
15
|
+
The `blackboard-cli` command exposes every public facade method except iterators,
|
|
16
|
+
and can export data as JSON, CSV, or Microsoft Excel files. It is intended as a
|
|
17
|
+
data exploration and management tool for Blackboard administrators.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
PyBlackboard-LMS requires Python 3.10 or later.
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
python -m pip install PyBlackboard-LMS
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The package installs the `blackboard-cli` command and the `blackboard_api` module.
|
|
28
|
+
|
|
29
|
+
## Configuration with explicit parameter values (API only)
|
|
30
|
+
|
|
31
|
+
In a Python script:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from blackboard_api import BlackboardAPI
|
|
35
|
+
client = BlackboardAPI(
|
|
36
|
+
url="https://blackboard.example.com",
|
|
37
|
+
client_id="...",
|
|
38
|
+
client_secret="...",
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`url` is the URL of the Blackboard instance. `client_id` is the value called
|
|
43
|
+
`APP_KEY` in the [Blackboard Developer Portal](https://developer.blackboard.com/portal/applications),
|
|
44
|
+
and `client_secret` is its corresponding `APP_SECRET` value.
|
|
45
|
+
|
|
46
|
+
## Configuration with .env files (API and CLI)
|
|
47
|
+
|
|
48
|
+
When creating `BlackboardAPI` without direct credentials, `env_file` is required.
|
|
49
|
+
Both the API and CLI use dotenv-style files as their configuration source.
|
|
50
|
+
|
|
51
|
+
1. Copy the `.env.example` template file to `.env.production.local` and configure it with
|
|
52
|
+
API credentials and Blackboard instance URL of your **PRODUCTION** Blackboard instance.
|
|
53
|
+
|
|
54
|
+
2. Copy the `.env.example` template file to `.env.test.local` and configure it with
|
|
55
|
+
API credentials and Blackboard instance URL of your **TEST** Blackboard instance,
|
|
56
|
+
if you have any.
|
|
57
|
+
|
|
58
|
+
You can then specify the ENV-file path with:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
blackboard-cli --env-file .env.production.local <command>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
or use the API directly:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from blackboard_api import BlackboardAPI
|
|
68
|
+
|
|
69
|
+
client = BlackboardAPI(env_file=".env.production.local")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The optional `BB_REQUEST_CONNECT_TIMEOUT` and `BB_REQUEST_READ_TIMEOUT` ENV
|
|
73
|
+
settings are positive integer durations in seconds. They default to `10` and
|
|
74
|
+
`60`, respectively. You can add them to env files if you want to tweak them.
|
|
75
|
+
|
|
76
|
+
## Writes are disabled by default
|
|
77
|
+
|
|
78
|
+
To reduce the risk of accidental changes, mutating operations are disabled by
|
|
79
|
+
default. The API blocks `POST`, `PUT`, `PATCH`, and `DELETE` before they reach
|
|
80
|
+
Blackboard unless writes are explicitly enabled.
|
|
81
|
+
|
|
82
|
+
You can enable writing operations by passing
|
|
83
|
+
`enable_write=True` to `BlackboardAPI`, or `--enable-write` to the CLI.
|
|
84
|
+
|
|
85
|
+
## API reference
|
|
86
|
+
|
|
87
|
+
See [the API reference](docs/api_reference.md) for the public interface and
|
|
88
|
+
internal layers.
|
|
89
|
+
|
|
90
|
+
## CLI command reference
|
|
91
|
+
|
|
92
|
+
The CLI exposes one command for every public API facade method except iterators.
|
|
93
|
+
Iterators are intended for progressive data consumption in Python scripts and
|
|
94
|
+
do not provide a useful CLI abstraction.
|
|
95
|
+
|
|
96
|
+
See [the CLI reference](docs/cli_reference.md) for the complete command list.
|
|
97
|
+
|
|
98
|
+
Use `--command-help` to view every available command.
|
|
99
|
+
|
|
100
|
+
## Developers only
|
|
101
|
+
|
|
102
|
+
### Development setup
|
|
103
|
+
|
|
104
|
+
Install the project and development tools in editable mode:
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
python -m pip install -e ".[dev]"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Unit tests
|
|
111
|
+
|
|
112
|
+
Run automated tests with:
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
python -m unittest discover -s tests/unit -v
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Unit tests are 100% offline and never require Blackboard credentials or an
|
|
119
|
+
environment file.
|
|
120
|
+
|
|
121
|
+
### Live integration tests
|
|
122
|
+
|
|
123
|
+
Run live integration tests with:
|
|
124
|
+
|
|
125
|
+
```text
|
|
126
|
+
python -m tests.integration.run
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
While unit tests are fully offline, live integration tests connect to Blackboard
|
|
130
|
+
and create, modify, and delete test objects. Use an isolated **test tenant** and
|
|
131
|
+
ensure that `.env.test.local` contains only its credentials. The test runner requires
|
|
132
|
+
that file, but cannot verify that its URL is truly a non-production instance.
|
|
133
|
+
If test environment dot-env file is missing or invalid, live integration tests fail.
|
|
134
|
+
|
|
135
|
+
The test runner writes the same messages to standard output and to a timestamped log
|
|
136
|
+
under `data/test-artifacts/logs/`. Use `--log-level DEBUG` for HTTP and
|
|
137
|
+
fixture lifecycle diagnostics. It never logs credentials, OAuth tokens, or
|
|
138
|
+
passwords.
|
|
139
|
+
|
|
140
|
+
The integration test suite uses randomized, easily recognizable identifiers to avoid
|
|
141
|
+
collisions with existing data. Its final test explicitly verifies cleanup,
|
|
142
|
+
and emergency cleanup is attempted when the process exits.
|
|
143
|
+
Failures outside the process or API failures may still require manual review of the
|
|
144
|
+
test tenant.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "PyBlackboard-LMS"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python API and command-line interface for Blackboard Learn."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Alex Amo", email = "hello@alejandroamo.eu" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["blackboard", "blackboard-learn", "cli", "education", "lms"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Topic :: Education",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"requests>=2.31,<3",
|
|
30
|
+
"pandas>=2.0,<3",
|
|
31
|
+
"XlsxWriter>=3.1,<4",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"black>=24.0,<27",
|
|
37
|
+
"build>=1.2,<2",
|
|
38
|
+
"check-wheel-contents>=0.6,<1",
|
|
39
|
+
"mypy>=1.10,<2",
|
|
40
|
+
"ruff>=0.6,<1",
|
|
41
|
+
"twine>=5.1,<7",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
blackboard-cli = "blackboard_cli.cli:main"
|
|
46
|
+
|
|
47
|
+
[tool.setuptools]
|
|
48
|
+
package-dir = { "" = "src" }
|
|
49
|
+
include-package-data = false
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.packages.find]
|
|
52
|
+
where = ["src"]
|
|
53
|
+
include = ["blackboard_api*", "blackboard_cli*"]
|
|
54
|
+
namespaces = false
|
|
55
|
+
|
|
56
|
+
[tool.black]
|
|
57
|
+
line-length = 88
|
|
58
|
+
target-version = ["py310"]
|
|
59
|
+
extend-exclude = "^/(build|dist)/"
|
|
60
|
+
|
|
61
|
+
[tool.ruff]
|
|
62
|
+
line-length = 88
|
|
63
|
+
target-version = "py310"
|
|
64
|
+
extend-exclude = ["build", "dist"]
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint]
|
|
67
|
+
select = ["E", "F", "I", "W"]
|
|
68
|
+
|
|
69
|
+
[tool.mypy]
|
|
70
|
+
python_version = "3.10"
|
|
71
|
+
files = ["src"]
|
|
72
|
+
check_untyped_defs = true
|
|
73
|
+
warn_return_any = true
|
|
74
|
+
warn_unused_ignores = true
|
|
75
|
+
exclude = "^(build|dist)/"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
.env.example
|
|
2
|
+
CHANGELOG.md
|
|
3
|
+
LICENSE
|
|
4
|
+
MANIFEST.in
|
|
5
|
+
README.md
|
|
6
|
+
pyproject.toml
|
|
7
|
+
src/blackboard_api/__init__.py
|
|
8
|
+
src/blackboard_api/api_quota.py
|
|
9
|
+
src/blackboard_api/auth.py
|
|
10
|
+
src/blackboard_api/client.py
|
|
11
|
+
src/blackboard_api/config.py
|
|
12
|
+
src/blackboard_api/errors.py
|
|
13
|
+
src/blackboard_api/identifiers.py
|
|
14
|
+
src/blackboard_api/transport.py
|
|
15
|
+
src/blackboard_api/facades/__init__.py
|
|
16
|
+
src/blackboard_api/facades/api_quota.py
|
|
17
|
+
src/blackboard_api/facades/courses.py
|
|
18
|
+
src/blackboard_api/facades/enrollments.py
|
|
19
|
+
src/blackboard_api/facades/resources.py
|
|
20
|
+
src/blackboard_api/facades/terms.py
|
|
21
|
+
src/blackboard_api/facades/users.py
|
|
22
|
+
src/blackboard_api/resources/__init__.py
|
|
23
|
+
src/blackboard_api/resources/courses.py
|
|
24
|
+
src/blackboard_api/resources/enrollment_roles.py
|
|
25
|
+
src/blackboard_api/resources/enrollments.py
|
|
26
|
+
src/blackboard_api/resources/nodes.py
|
|
27
|
+
src/blackboard_api/resources/terms.py
|
|
28
|
+
src/blackboard_api/resources/users.py
|
|
29
|
+
src/blackboard_api/services/__init__.py
|
|
30
|
+
src/blackboard_api/services/courses.py
|
|
31
|
+
src/blackboard_api/services/enrollments.py
|
|
32
|
+
src/blackboard_api/services/users.py
|
|
33
|
+
src/blackboard_cli/__init__.py
|
|
34
|
+
src/blackboard_cli/__main__.py
|
|
35
|
+
src/blackboard_cli/cli.py
|
|
36
|
+
src/blackboard_cli/encoding.py
|
|
37
|
+
src/blackboard_cli/application/__init__.py
|
|
38
|
+
src/blackboard_cli/converters/__init__.py
|
|
39
|
+
src/blackboard_cli/converters/common.py
|
|
40
|
+
src/blackboard_cli/converters/courses.py
|
|
41
|
+
src/blackboard_cli/converters/enrollments.py
|
|
42
|
+
src/blackboard_cli/converters/generic.py
|
|
43
|
+
src/blackboard_cli/converters/nodes.py
|
|
44
|
+
src/blackboard_cli/converters/roles.py
|
|
45
|
+
src/blackboard_cli/converters/users.py
|
|
46
|
+
src/blackboard_cli/output/__init__.py
|
|
47
|
+
src/blackboard_cli/output/csv.py
|
|
48
|
+
src/blackboard_cli/output/dataframe.py
|
|
49
|
+
src/blackboard_cli/output/excel.py
|
|
50
|
+
src/blackboard_cli/output/table.py
|
|
@@ -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
|
|
@@ -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
|