bocconi-sdk 0.2.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 youatb contributors
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,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: bocconi-sdk
3
+ Version: 0.2.0
4
+ Summary: Unofficial Python SDK for Bocconi University student services (yoU@B portal, Blackboard, Esse3)
5
+ License-Expression: MIT
6
+ Keywords: bocconi,youatb,blackboard,esse3,university,student
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Education
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: requests>=2.31
22
+ Requires-Dist: websocket-client>=1.7
23
+ Requires-Dist: tzdata>=2024.1; platform_system == "Windows"
24
+ Dynamic: license-file
25
+
26
+ # Bocconi Software Development Kit
27
+
28
+ Unofficial Python SDK for **Bocconi University student services**. One
29
+ username/password login unlocks every subsystem through the shared SAML2 SSO
30
+ (`idp.unibocconi.it`). Reverse-engineered from live traffic.
31
+
32
+ ## Subsystems
33
+
34
+ | Package | System | What you get |
35
+ |---|---|---|
36
+ | `bocconi.youatb` | **yoU@B portal** (`youatb.unibocconi.it`) | Dashboard widgets (SignalR 2.x `widgethub`), lesson calendar JSON feed, profile/careers, language switching |
37
+ | `bocconi.youatb.puntoblu` | **Esse3 / PuntoBlu** (`www.pb.unibocconi.it`) | Student info, exam sessions, grades booklet, fees/certificates/… (HTML scraping) |
38
+ | `bocconi.blackboard` | **Blackboard** (`blackboard.unibocconi.it`) | Courses, deadlines/to-dos, messages, announcements, unread counters, activity stream |
39
+
40
+ ## Install
41
+
42
+ ```powershell
43
+ cd bocconi-sdk
44
+ python -m venv .venv
45
+ .\.venv\Scripts\Activate.ps1
46
+ pip install -e .
47
+ ```
48
+
49
+ Dependencies: `requests`, `websocket-client`. Python ≥ 3.9.
50
+
51
+ ## Quickstart — parent client (recommended)
52
+
53
+ ```python
54
+ from datetime import date
55
+ from bocconi import BocconiClient
56
+
57
+ with BocconiClient("1234567", "your-password") as client:
58
+ client.login() # one SAML login unlocks everything
59
+
60
+ yt = client.youatb # yoU@B portal
61
+ print(yt.profile.name, yt.student_id)
62
+ widgets = yt.dashboard() # SignalR widgethub
63
+ for ev in yt.calendar(date(2026, 8, 31), date(2026, 9, 6)):
64
+ print(ev.start, ev.course_name, ev.room)
65
+
66
+ bb = client.blackboard # Blackboard Ultra
67
+ for course in bb.courses():
68
+ print(course.display_id, course.name)
69
+ for item in bb.todo().future:
70
+ print(item.due_date, item.title)
71
+
72
+ pb = yt.puntoblu # Esse3
73
+ print(pb.student_info().degree_program)
74
+ print(pb.exam_sessions()[:3])
75
+ ```
76
+
77
+ ## Standalone children
78
+
79
+ Each subsystem also works independently (own session, own login):
80
+
81
+ ```python
82
+ from bocconi.youatb import YouAtBClient
83
+ from bocconi.blackboard import BlackboardClient
84
+
85
+ with YouAtBClient("1234567", "pw") as yt:
86
+ yt.login()
87
+ print(yt.calendar())
88
+
89
+ bb = BlackboardClient.authenticate("1234567", "pw") # SAML via Blackboard's SP
90
+ print(bb.me())
91
+ ```
92
+
93
+ Or attach children to an existing authenticated session:
94
+ `YouAtBClient.from_session(session)`, `BlackboardClient.from_session(session)`.
95
+
96
+ ## API overview
97
+
98
+ ### `BocconiClient(username, password, lang="en")`
99
+
100
+ - `login()` / `logout()` / `close()` — context manager supported.
101
+ - `.youatb` → `YouAtBClient`, `.blackboard` → `BlackboardClient` (lazy).
102
+ - `fetch(url)` — GET any Bocconi SP URL with automatic SSO completion.
103
+
104
+ ### `bocconi.youatb.YouAtBClient`
105
+
106
+ - `login()`, `profile` (name, photo, careers), `student_id`,
107
+ `set_career(...)`, `set_language("ita"|"eng")`
108
+ - `dashboard(widget_ids=None)` / `load_widgets(ids)` → `WidgetResult`
109
+ (`WidgetContent`: `.content_type`, `.url`, `.links`, `.text`; widget-ID
110
+ constants exported from `bocconi.youatb`)
111
+ - `calendar(start=None, end=None)` → `list[CalendarEvent]` — typed lessons:
112
+ course code/name, class, professors, room/building/floor, lat/lon, tz-aware
113
+ Europe/Rome datetimes
114
+ - `.puntoblu` → `PuntoBluClient`
115
+
116
+ ### `bocconi.youatb.puntoblu.PuntoBluClient`
117
+
118
+ - `student_info()` → `StudentInfo` (bilingual eng/ita, enrolment history)
119
+ - `exam_sessions()` → `list[ExamSession]` — dates, type (partial/general),
120
+ enrolment windows, booking status/link
121
+ - `booklet()` → `list[BookletEntry]` — credits, year, status, grades
122
+ - `menu()`, `get(path)` + `PB_*` path constants; career interstitial handled
123
+ automatically (`select_career(stu_id)`)
124
+
125
+ ### `bocconi.blackboard.BlackboardClient`
126
+
127
+ - `me()` → `BbUser`; `courses()` / `recent_courses()` → `list[BbCourse]`
128
+ - `todo(since=None, until=None)` → `BbTodo` — actionable deadlines grouped
129
+ `.overdue`/.`due_today`/`.future` (>15-day ranges auto-chunked)
130
+ - `calendar_items` / `deadlines(since, until)` → `list[BbCalendarItem]`
131
+ (calendar view, keeps completed items)
132
+ - `announcements(course_id)`, `conversations(course_id)`,
133
+ `message_summary()`, `announcement_counts` / `conversation_counts` /
134
+ `unread_grades_count` / `discussion_unread_count(course_id)`
135
+ - `calendars()`, `terms()`, `course_membership(course_id)`, `stream()`,
136
+ `system_preference(key)`, `entitlements()`, `api_get(path)`
137
+
138
+ ## Notes & caveats
139
+
140
+ - The portal hub speaks **ASP.NET SignalR 2.x** (`clientProtocol=2.1`) — *not*
141
+ ASP.NET Core SignalR; a minimal 2.x client is bundled
142
+ (`bocconi.youatb.signalr.SignalR2Client`).
143
+ - Blackboard's SP sends the SAMLRequest via **HTTP-POST binding** — handled
144
+ transparently by `bocconi.auth` for all services.
145
+ - Hub/Esse3 HTML fragments are **untrusted server-rendered markup**; the SDK
146
+ never executes embedded scripts, `.text` strips tags.
147
+ - Sessions expire server-side; on `SessionExpiredError` call `login()` again.
148
+ - Unofficial project, not affiliated with Università Bocconi. Use
149
+ responsibly: don't hammer the servers, don't share session cookies.
150
+
151
+ This SDK currently does not cover the full range of features offered by the Bocconi yoU@B/Blackboard ecosystem, so feel free to open a pull request
152
+
153
+ Run the full demo:
154
+
155
+ ```powershell
156
+ $env:YOUATB_USER = "1234567"
157
+ $env:YOUATB_PASS = "your-password"
158
+ python examples/demo.py
159
+ ```
@@ -0,0 +1,134 @@
1
+ # Bocconi Software Development Kit
2
+
3
+ Unofficial Python SDK for **Bocconi University student services**. One
4
+ username/password login unlocks every subsystem through the shared SAML2 SSO
5
+ (`idp.unibocconi.it`). Reverse-engineered from live traffic.
6
+
7
+ ## Subsystems
8
+
9
+ | Package | System | What you get |
10
+ |---|---|---|
11
+ | `bocconi.youatb` | **yoU@B portal** (`youatb.unibocconi.it`) | Dashboard widgets (SignalR 2.x `widgethub`), lesson calendar JSON feed, profile/careers, language switching |
12
+ | `bocconi.youatb.puntoblu` | **Esse3 / PuntoBlu** (`www.pb.unibocconi.it`) | Student info, exam sessions, grades booklet, fees/certificates/… (HTML scraping) |
13
+ | `bocconi.blackboard` | **Blackboard** (`blackboard.unibocconi.it`) | Courses, deadlines/to-dos, messages, announcements, unread counters, activity stream |
14
+
15
+ ## Install
16
+
17
+ ```powershell
18
+ cd bocconi-sdk
19
+ python -m venv .venv
20
+ .\.venv\Scripts\Activate.ps1
21
+ pip install -e .
22
+ ```
23
+
24
+ Dependencies: `requests`, `websocket-client`. Python ≥ 3.9.
25
+
26
+ ## Quickstart — parent client (recommended)
27
+
28
+ ```python
29
+ from datetime import date
30
+ from bocconi import BocconiClient
31
+
32
+ with BocconiClient("1234567", "your-password") as client:
33
+ client.login() # one SAML login unlocks everything
34
+
35
+ yt = client.youatb # yoU@B portal
36
+ print(yt.profile.name, yt.student_id)
37
+ widgets = yt.dashboard() # SignalR widgethub
38
+ for ev in yt.calendar(date(2026, 8, 31), date(2026, 9, 6)):
39
+ print(ev.start, ev.course_name, ev.room)
40
+
41
+ bb = client.blackboard # Blackboard Ultra
42
+ for course in bb.courses():
43
+ print(course.display_id, course.name)
44
+ for item in bb.todo().future:
45
+ print(item.due_date, item.title)
46
+
47
+ pb = yt.puntoblu # Esse3
48
+ print(pb.student_info().degree_program)
49
+ print(pb.exam_sessions()[:3])
50
+ ```
51
+
52
+ ## Standalone children
53
+
54
+ Each subsystem also works independently (own session, own login):
55
+
56
+ ```python
57
+ from bocconi.youatb import YouAtBClient
58
+ from bocconi.blackboard import BlackboardClient
59
+
60
+ with YouAtBClient("1234567", "pw") as yt:
61
+ yt.login()
62
+ print(yt.calendar())
63
+
64
+ bb = BlackboardClient.authenticate("1234567", "pw") # SAML via Blackboard's SP
65
+ print(bb.me())
66
+ ```
67
+
68
+ Or attach children to an existing authenticated session:
69
+ `YouAtBClient.from_session(session)`, `BlackboardClient.from_session(session)`.
70
+
71
+ ## API overview
72
+
73
+ ### `BocconiClient(username, password, lang="en")`
74
+
75
+ - `login()` / `logout()` / `close()` — context manager supported.
76
+ - `.youatb` → `YouAtBClient`, `.blackboard` → `BlackboardClient` (lazy).
77
+ - `fetch(url)` — GET any Bocconi SP URL with automatic SSO completion.
78
+
79
+ ### `bocconi.youatb.YouAtBClient`
80
+
81
+ - `login()`, `profile` (name, photo, careers), `student_id`,
82
+ `set_career(...)`, `set_language("ita"|"eng")`
83
+ - `dashboard(widget_ids=None)` / `load_widgets(ids)` → `WidgetResult`
84
+ (`WidgetContent`: `.content_type`, `.url`, `.links`, `.text`; widget-ID
85
+ constants exported from `bocconi.youatb`)
86
+ - `calendar(start=None, end=None)` → `list[CalendarEvent]` — typed lessons:
87
+ course code/name, class, professors, room/building/floor, lat/lon, tz-aware
88
+ Europe/Rome datetimes
89
+ - `.puntoblu` → `PuntoBluClient`
90
+
91
+ ### `bocconi.youatb.puntoblu.PuntoBluClient`
92
+
93
+ - `student_info()` → `StudentInfo` (bilingual eng/ita, enrolment history)
94
+ - `exam_sessions()` → `list[ExamSession]` — dates, type (partial/general),
95
+ enrolment windows, booking status/link
96
+ - `booklet()` → `list[BookletEntry]` — credits, year, status, grades
97
+ - `menu()`, `get(path)` + `PB_*` path constants; career interstitial handled
98
+ automatically (`select_career(stu_id)`)
99
+
100
+ ### `bocconi.blackboard.BlackboardClient`
101
+
102
+ - `me()` → `BbUser`; `courses()` / `recent_courses()` → `list[BbCourse]`
103
+ - `todo(since=None, until=None)` → `BbTodo` — actionable deadlines grouped
104
+ `.overdue`/.`due_today`/`.future` (>15-day ranges auto-chunked)
105
+ - `calendar_items` / `deadlines(since, until)` → `list[BbCalendarItem]`
106
+ (calendar view, keeps completed items)
107
+ - `announcements(course_id)`, `conversations(course_id)`,
108
+ `message_summary()`, `announcement_counts` / `conversation_counts` /
109
+ `unread_grades_count` / `discussion_unread_count(course_id)`
110
+ - `calendars()`, `terms()`, `course_membership(course_id)`, `stream()`,
111
+ `system_preference(key)`, `entitlements()`, `api_get(path)`
112
+
113
+ ## Notes & caveats
114
+
115
+ - The portal hub speaks **ASP.NET SignalR 2.x** (`clientProtocol=2.1`) — *not*
116
+ ASP.NET Core SignalR; a minimal 2.x client is bundled
117
+ (`bocconi.youatb.signalr.SignalR2Client`).
118
+ - Blackboard's SP sends the SAMLRequest via **HTTP-POST binding** — handled
119
+ transparently by `bocconi.auth` for all services.
120
+ - Hub/Esse3 HTML fragments are **untrusted server-rendered markup**; the SDK
121
+ never executes embedded scripts, `.text` strips tags.
122
+ - Sessions expire server-side; on `SessionExpiredError` call `login()` again.
123
+ - Unofficial project, not affiliated with Università Bocconi. Use
124
+ responsibly: don't hammer the servers, don't share session cookies.
125
+
126
+ This SDK currently does not cover the full range of features offered by the Bocconi yoU@B/Blackboard ecosystem, so feel free to open a pull request
127
+
128
+ Run the full demo:
129
+
130
+ ```powershell
131
+ $env:YOUATB_USER = "1234567"
132
+ $env:YOUATB_PASS = "your-password"
133
+ python examples/demo.py
134
+ ```
@@ -0,0 +1,48 @@
1
+ """Unofficial Python SDK for Bocconi University student services.
2
+
3
+ One SAML2 SSO login (idp.unibocconi.it) unlocks all subsystems:
4
+
5
+ * :mod:`bocconi.youatb` — yoU@B portal (SignalR widgets, calendar,
6
+ profile/careers) and the Esse3 "PuntoBlu" student area
7
+ * :mod:`bocconi.blackboard` — Blackboard Learn Ultra (courses, deadlines,
8
+ messages, announcements, activity stream)
9
+
10
+ Quickstart::
11
+
12
+ from bocconi import BocconiClient
13
+
14
+ with BocconiClient("1234567", "secret") as client:
15
+ client.login()
16
+ client.youatb.dashboard()
17
+ client.blackboard.courses()
18
+
19
+ Reverse-engineered from the live services; see youatb-docs.md in the
20
+ sibling ``youatb_re`` project for the full protocol write-up. Unofficial,
21
+ not affiliated with Università Bocconi.
22
+ """
23
+
24
+ from . import blackboard, youatb
25
+ from .client import BocconiClient
26
+ from .exceptions import (
27
+ AuthenticationError,
28
+ InvalidCredentials,
29
+ SessionExpiredError,
30
+ SignalRError,
31
+ WidgetError,
32
+ YouAtBError,
33
+ )
34
+
35
+ __version__ = "0.2.0"
36
+
37
+ __all__ = [
38
+ "BocconiClient",
39
+ "youatb",
40
+ "blackboard",
41
+ # errors
42
+ "YouAtBError",
43
+ "AuthenticationError",
44
+ "InvalidCredentials",
45
+ "SessionExpiredError",
46
+ "SignalRError",
47
+ "WidgetError",
48
+ ]
@@ -0,0 +1,217 @@
1
+ """SAML2 Web-SSO login against the Bocconi Shibboleth IdP.
2
+
3
+ Implements the flow documented in youatb-docs.md §2/§5:
4
+
5
+ GET <SP protected URL> -> 302 chain to idp.unibocconi.it
6
+ GET IdP login page -> form with `execution` webflow token
7
+ POST j_username/j_password -> 200 auto-submit form (SAMLResponse)
8
+ POST SAMLResponse to SP ACS URL -> 302 back, SP session cookies set
9
+
10
+ No JavaScript is executed; the auto-submit forms are parsed and replayed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import html as _html
16
+ import logging
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from html.parser import HTMLParser
20
+ from typing import Dict, List, Optional
21
+ from urllib.parse import urljoin
22
+
23
+ import requests
24
+
25
+ from .exceptions import (
26
+ AuthenticationError,
27
+ InvalidCredentials,
28
+ SessionExpiredError,
29
+ )
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+ IDP_HOST = "idp.unibocconi.it"
34
+
35
+ DEFAULT_USER_AGENT = (
36
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
37
+ "(KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
38
+ )
39
+
40
+ _CRED_FAIL_RE = re.compile(
41
+ r"loginfailed|credentials not recognized|login failed|password.*incorrect",
42
+ re.IGNORECASE,
43
+ )
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Minimal HTML form extraction (stdlib only)
48
+ # ---------------------------------------------------------------------------
49
+
50
+ @dataclass
51
+ class HtmlForm:
52
+ action: str = ""
53
+ method: str = "get"
54
+ inputs: Dict[str, str] = field(default_factory=dict)
55
+
56
+
57
+ class _FormParser(HTMLParser):
58
+ def __init__(self) -> None:
59
+ super().__init__(convert_charrefs=True)
60
+ self.forms: List[HtmlForm] = []
61
+ self._current: Optional[HtmlForm] = None
62
+
63
+ def handle_starttag(self, tag: str, attrs) -> None:
64
+ attrs = dict(attrs)
65
+ if tag == "form":
66
+ self._current = HtmlForm(
67
+ action=attrs.get("action", ""),
68
+ method=attrs.get("method", "get").lower(),
69
+ )
70
+ elif tag == "input" and self._current is not None:
71
+ name = attrs.get("name")
72
+ if name:
73
+ self._current.inputs[name] = attrs.get("value", "")
74
+
75
+ def handle_endtag(self, tag: str) -> None:
76
+ if tag == "form" and self._current is not None:
77
+ self.forms.append(self._current)
78
+ self._current = None
79
+
80
+
81
+ def extract_forms(html_text: str) -> List[HtmlForm]:
82
+ parser = _FormParser()
83
+ parser.feed(html_text)
84
+ return parser.forms
85
+
86
+
87
+ def _find_saml_form(html_text: str) -> Optional[HtmlForm]:
88
+ """Find an auto-submit SAML form.
89
+
90
+ Two kinds occur in Bocconi SSO flows:
91
+ * ``SAMLResponse`` — IdP -> SP (the common case, POST binding response)
92
+ * ``SAMLRequest`` — SP -> IdP (POST binding *request*; Blackboard's
93
+ ``/auth-saml/saml/login`` uses this instead of a 302 redirect)
94
+ """
95
+ for form in extract_forms(html_text):
96
+ if "SAMLResponse" in form.inputs or "SAMLRequest" in form.inputs:
97
+ return form
98
+ return None
99
+
100
+
101
+ def _find_password_form(html_text: str) -> Optional[HtmlForm]:
102
+ for form in extract_forms(html_text):
103
+ if "j_password" in form.inputs or "j_username" in form.inputs:
104
+ return form
105
+ return None
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # SAML plumbing
110
+ # ---------------------------------------------------------------------------
111
+
112
+ def complete_saml_posts(
113
+ session: requests.Session,
114
+ response: requests.Response,
115
+ max_hops: int = 10,
116
+ ) -> requests.Response:
117
+ """Replay auto-submit SAML forms until a real page is reached.
118
+
119
+ Handles both directions of the POST binding: IdP->SP ``SAMLResponse``
120
+ forms (youatb, www.pb, app, ...) and SP->IdP ``SAMLRequest`` forms
121
+ (Blackboard's ``/auth-saml/saml/login``).
122
+ """
123
+ for _ in range(max_hops):
124
+ form = _find_saml_form(response.text)
125
+ if form is None:
126
+ return response
127
+ action = urljoin(response.url, _html.unescape(form.action))
128
+ log.debug("SAML POST -> %s", action)
129
+ response = session.post(action, data=form.inputs, timeout=30)
130
+ raise AuthenticationError("Too many SAML redirect hops")
131
+
132
+
133
+ def has_session_cookie(session: requests.Session) -> bool:
134
+ """True if the jar holds a session marker of any known Bocconi service."""
135
+ markers = (".ASPXAUTH", "BbRouter", "samlCookie")
136
+ return any(
137
+ c.name in markers or c.name.startswith("_shibsession_")
138
+ for c in session.cookies
139
+ )
140
+
141
+
142
+ def login(
143
+ session: requests.Session,
144
+ entry_url: str,
145
+ username: str,
146
+ password: str,
147
+ ) -> requests.Response:
148
+ """Perform a full username/password login starting from any SP entry URL.
149
+
150
+ Works for the yoU@B portal (``https://youatb.unibocconi.it/``) as well as
151
+ for SPs that emit the SAMLRequest via POST binding, e.g. Blackboard
152
+ (``https://blackboard.unibocconi.it/ultra/institution-page``): the chain
153
+ eventually reaches the IdP password form either way.
154
+
155
+ Returns the final authenticated response.
156
+
157
+ Raises:
158
+ InvalidCredentials: the IdP rejected the credentials.
159
+ AuthenticationError: any other flow failure.
160
+ """
161
+ log.info("Opening %s", entry_url)
162
+ resp = session.get(entry_url, timeout=30)
163
+ resp = complete_saml_posts(session, resp)
164
+
165
+ pw_form = _find_password_form(resp.text)
166
+ if pw_form is None:
167
+ # No login form: either already authenticated (existing IdP session
168
+ # silently SSO'd us in) or something unexpected happened.
169
+ if has_session_cookie(session):
170
+ log.info("Authenticated via existing IdP SSO session")
171
+ return resp
172
+ raise AuthenticationError(
173
+ f"No login form found and no session established (final URL: {resp.url})"
174
+ )
175
+
176
+ action = urljoin(resp.url, _html.unescape(pw_form.action))
177
+ payload = dict(pw_form.inputs) # keep any hidden webflow fields
178
+ payload["j_username"] = username
179
+ payload["j_password"] = password
180
+ payload["_eventId_proceed"] = ""
181
+ log.info("Posting credentials to %s", action)
182
+ resp = session.post(action, data=payload, timeout=30)
183
+
184
+ if "SAMLResponse" not in resp.text:
185
+ if _CRED_FAIL_RE.search(resp.text) or _find_password_form(resp.text):
186
+ raise InvalidCredentials("IdP rejected the username/password")
187
+ raise AuthenticationError(
188
+ f"Unexpected IdP response (no SAMLResponse, URL: {resp.url})"
189
+ )
190
+
191
+ resp = complete_saml_posts(session, resp)
192
+
193
+ if not has_session_cookie(session):
194
+ raise AuthenticationError(
195
+ "SAML flow completed but no session cookie was set by the SP"
196
+ )
197
+ log.info("Login successful")
198
+ return resp
199
+
200
+
201
+ def ensure_service_session(
202
+ session: requests.Session,
203
+ url: str,
204
+ ) -> requests.Response:
205
+ """GET a protected URL on any Bocconi SP, completing SSO if needed.
206
+
207
+ With a live IdP session this is silent: the SP bounces us to the IdP and
208
+ straight back with a fresh SAMLResponse. Raises SessionExpiredError if the
209
+ IdP asks for credentials again.
210
+ """
211
+ resp = session.get(url, timeout=30)
212
+ resp = complete_saml_posts(session, resp)
213
+ if IDP_HOST in resp.url and _find_password_form(resp.text):
214
+ raise SessionExpiredError(
215
+ "IdP SSO session expired; call YouAtBClient.login() again"
216
+ )
217
+ return resp
@@ -0,0 +1,26 @@
1
+ """Blackboard Learn Ultra subsystem: courses, deadlines/to-dos, messages,
2
+ announcements, activity stream."""
3
+
4
+ from .client import (
5
+ BbAnnouncement,
6
+ BbCalendar,
7
+ BbCalendarItem,
8
+ BbConversation,
9
+ BbCourse,
10
+ BbTodo,
11
+ BbTodoItem,
12
+ BbUser,
13
+ BlackboardClient,
14
+ )
15
+
16
+ __all__ = [
17
+ "BlackboardClient",
18
+ "BbUser",
19
+ "BbCourse",
20
+ "BbConversation",
21
+ "BbAnnouncement",
22
+ "BbCalendarItem",
23
+ "BbCalendar",
24
+ "BbTodo",
25
+ "BbTodoItem",
26
+ ]