pymabibli 0.0.1__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 bestycame
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,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: pymabibli
3
+ Version: 0.0.1
4
+ Summary: An async Python client for mabibli.be, the libraries of the Province of Liège
5
+ Home-page: https://github.com/bestycame/pymabibli
6
+ Author: Bestycame
7
+ Author-email: bestycame@gmail.com
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Framework :: AsyncIO
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Requires-Python: >=3.12
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: aiohttp>=3.9
21
+ Requires-Dist: beautifulsoup4>=4.12
22
+ Dynamic: author
23
+ Dynamic: author-email
24
+ Dynamic: classifier
25
+ Dynamic: description
26
+ Dynamic: description-content-type
27
+ Dynamic: home-page
28
+ Dynamic: license-file
29
+ Dynamic: requires-dist
30
+ Dynamic: requires-python
31
+ Dynamic: summary
32
+
33
+ # Mabibli Python Client
34
+ ## NOT OFFICIALLY SUPPORTED BY THE PROVINCE DE LIÈGE --
35
+
36
+ An async Python client library for [mabibli.be](https://mabibli.be/), the library network of the Province of Liège.
37
+ This client logs in with your library card and reads your account, loans and borrowing history, and can renew loans.
38
+ Developped for a Home Assistant integration. At least for now.
39
+
40
+ ## Installation
41
+ You can install the package using pip:
42
+
43
+ ```bash
44
+ pip install pymabibli
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ Here's a basic example of how to use the client:
50
+
51
+ ```python
52
+ import asyncio
53
+
54
+ from pymabibli import MabibliClient
55
+
56
+
57
+ async def main():
58
+ async with MabibliClient("your-card-number", "your-password") as client:
59
+ # Log in (also done automatically by the other methods)
60
+ await client.login()
61
+
62
+ # Get account information
63
+ account = await client.get_account()
64
+ print(f"Membership ends: {account.subscription_end}")
65
+
66
+ # Get current loans
67
+ loans = await client.get_loans()
68
+ for loan in loans:
69
+ print(f"{loan.title} [{loan.library}] due {loan.due_date}")
70
+
71
+ # Get borrowing history
72
+ history = await client.get_history()
73
+ print(f"{len(history)} items borrowed")
74
+
75
+ # Renew one loan
76
+ messages = await client.renew_loan(loans[0])
77
+
78
+ # Renew all loans ("Tout prolonger")
79
+ messages = await client.renew_all()
80
+
81
+
82
+ asyncio.run(main())
83
+ ```
84
+
85
+ You can pass your own `aiohttp.ClientSession` with `session=`; the client then leaves it open when closing.
86
+ The login is kept in the session's cookie jar, so each client needs a session of its own:
87
+ two cards sharing one session would see each other's account.
88
+ In Home Assistant, use `async_create_clientsession(hass)` per config entry, not the shared `async_get_clientsession(hass)`.
89
+
90
+ ## Error Handling
91
+
92
+ The client provides specific exception classes:
93
+
94
+ ### Exception Classes
95
+
96
+ - `MabibliError`: Base exception for all errors
97
+ - `MabibliAuthError`: Raised when the card number or password is rejected
98
+ - `MabibliConnectionError`: Raised when the site can't be reached or returns an unexpected page
99
+ - `MabibliRenewError`: Raised when a loan can't be renewed or the site refuses the renewal
100
+
101
+ ### Example Error Handling
102
+
103
+ ```python
104
+ from pymabibli import MabibliAuthError, MabibliClient, MabibliRenewError
105
+
106
+ async def renew_everything():
107
+ async with MabibliClient("your-card-number", "your-password") as client:
108
+ try:
109
+ loans = await client.get_loans()
110
+ except MabibliAuthError as e:
111
+ print(f"Login failed: {e}")
112
+ return
113
+
114
+ for loan in loans:
115
+ if not loan.renewable:
116
+ continue
117
+ try:
118
+ await client.renew_loan(loan)
119
+ except MabibliRenewError as e:
120
+ print(f"Could not renew {loan.title}: {e}")
121
+ ```
122
+
123
+ ### Authentication
124
+
125
+ mabibli.be authenticates through a CAS server (`centralcas.gminvent.fr`).
126
+ The client logs in on first use and logs in again when the site's session expires.
127
+ Renewals are never retried automatically: if the session is lost during a renewal, `MabibliAuthError` is raised
128
+ and you should check the loan before trying again.
129
+
130
+ ### API Methods
131
+
132
+ - `login() -> None`: Log in with the card number and password
133
+ - `get_account() -> Account`: Get name, card number and membership end date
134
+ - `get_loans() -> list[Loan]`: Get current loans, across all pages
135
+ - `get_history() -> list[Loan]`: Get previously borrowed items, newest first, across all pages
136
+ - `renew_loan(loan: Loan) -> list[Message]`: Renew one loan and return the site's messages
137
+ - `renew_all() -> list[Message]`: Renew all renewable loans and return the site's messages
138
+
139
+ ## Development
140
+
141
+ ```bash
142
+ pipenv install --dev
143
+ pipenv run pytest
144
+ pipenv run flake8
145
+ ```
146
+
147
+ The scripts in `examples/` run against the real site with your own card.
148
+ `examples/renew.py` really renews a loan, after asking which one.
149
+
150
+ ## License
151
+ This project is licensed under the MIT License - see the LICENSE file for details.
@@ -0,0 +1,119 @@
1
+ # Mabibli Python Client
2
+ ## NOT OFFICIALLY SUPPORTED BY THE PROVINCE DE LIÈGE --
3
+
4
+ An async Python client library for [mabibli.be](https://mabibli.be/), the library network of the Province of Liège.
5
+ This client logs in with your library card and reads your account, loans and borrowing history, and can renew loans.
6
+ Developped for a Home Assistant integration. At least for now.
7
+
8
+ ## Installation
9
+ You can install the package using pip:
10
+
11
+ ```bash
12
+ pip install pymabibli
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ Here's a basic example of how to use the client:
18
+
19
+ ```python
20
+ import asyncio
21
+
22
+ from pymabibli import MabibliClient
23
+
24
+
25
+ async def main():
26
+ async with MabibliClient("your-card-number", "your-password") as client:
27
+ # Log in (also done automatically by the other methods)
28
+ await client.login()
29
+
30
+ # Get account information
31
+ account = await client.get_account()
32
+ print(f"Membership ends: {account.subscription_end}")
33
+
34
+ # Get current loans
35
+ loans = await client.get_loans()
36
+ for loan in loans:
37
+ print(f"{loan.title} [{loan.library}] due {loan.due_date}")
38
+
39
+ # Get borrowing history
40
+ history = await client.get_history()
41
+ print(f"{len(history)} items borrowed")
42
+
43
+ # Renew one loan
44
+ messages = await client.renew_loan(loans[0])
45
+
46
+ # Renew all loans ("Tout prolonger")
47
+ messages = await client.renew_all()
48
+
49
+
50
+ asyncio.run(main())
51
+ ```
52
+
53
+ You can pass your own `aiohttp.ClientSession` with `session=`; the client then leaves it open when closing.
54
+ The login is kept in the session's cookie jar, so each client needs a session of its own:
55
+ two cards sharing one session would see each other's account.
56
+ In Home Assistant, use `async_create_clientsession(hass)` per config entry, not the shared `async_get_clientsession(hass)`.
57
+
58
+ ## Error Handling
59
+
60
+ The client provides specific exception classes:
61
+
62
+ ### Exception Classes
63
+
64
+ - `MabibliError`: Base exception for all errors
65
+ - `MabibliAuthError`: Raised when the card number or password is rejected
66
+ - `MabibliConnectionError`: Raised when the site can't be reached or returns an unexpected page
67
+ - `MabibliRenewError`: Raised when a loan can't be renewed or the site refuses the renewal
68
+
69
+ ### Example Error Handling
70
+
71
+ ```python
72
+ from pymabibli import MabibliAuthError, MabibliClient, MabibliRenewError
73
+
74
+ async def renew_everything():
75
+ async with MabibliClient("your-card-number", "your-password") as client:
76
+ try:
77
+ loans = await client.get_loans()
78
+ except MabibliAuthError as e:
79
+ print(f"Login failed: {e}")
80
+ return
81
+
82
+ for loan in loans:
83
+ if not loan.renewable:
84
+ continue
85
+ try:
86
+ await client.renew_loan(loan)
87
+ except MabibliRenewError as e:
88
+ print(f"Could not renew {loan.title}: {e}")
89
+ ```
90
+
91
+ ### Authentication
92
+
93
+ mabibli.be authenticates through a CAS server (`centralcas.gminvent.fr`).
94
+ The client logs in on first use and logs in again when the site's session expires.
95
+ Renewals are never retried automatically: if the session is lost during a renewal, `MabibliAuthError` is raised
96
+ and you should check the loan before trying again.
97
+
98
+ ### API Methods
99
+
100
+ - `login() -> None`: Log in with the card number and password
101
+ - `get_account() -> Account`: Get name, card number and membership end date
102
+ - `get_loans() -> list[Loan]`: Get current loans, across all pages
103
+ - `get_history() -> list[Loan]`: Get previously borrowed items, newest first, across all pages
104
+ - `renew_loan(loan: Loan) -> list[Message]`: Renew one loan and return the site's messages
105
+ - `renew_all() -> list[Message]`: Renew all renewable loans and return the site's messages
106
+
107
+ ## Development
108
+
109
+ ```bash
110
+ pipenv install --dev
111
+ pipenv run pytest
112
+ pipenv run flake8
113
+ ```
114
+
115
+ The scripts in `examples/` run against the real site with your own card.
116
+ `examples/renew.py` really renews a loan, after asking which one.
117
+
118
+ ## License
119
+ This project is licensed under the MIT License - see the LICENSE file for details.
@@ -0,0 +1,21 @@
1
+ """Async client for mabibli.be."""
2
+
3
+ from .client import MabibliClient
4
+ from .exceptions import (
5
+ MabibliAuthError,
6
+ MabibliConnectionError,
7
+ MabibliError,
8
+ MabibliRenewError,
9
+ )
10
+ from .models import Account, Loan, Message
11
+
12
+ __all__ = [
13
+ "Account",
14
+ "Loan",
15
+ "MabibliAuthError",
16
+ "MabibliClient",
17
+ "MabibliConnectionError",
18
+ "MabibliError",
19
+ "MabibliRenewError",
20
+ "Message",
21
+ ]
@@ -0,0 +1,214 @@
1
+ """Client for mabibli.be, which authenticates through the GMInvent CAS server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from html.parser import HTMLParser
6
+ import logging
7
+ from typing import Self
8
+ from urllib.parse import urljoin, urlsplit
9
+
10
+ import aiohttp
11
+
12
+ from .exceptions import MabibliAuthError, MabibliConnectionError, MabibliRenewError
13
+ from .models import Account, Loan, Message
14
+ from .parsers import (
15
+ BASE_URL,
16
+ is_logged_in,
17
+ parse_account,
18
+ parse_loans_page,
19
+ parse_messages,
20
+ parse_renew_all_url,
21
+ )
22
+
23
+ _LOGGER = logging.getLogger(__name__)
24
+
25
+ ACCOUNT_PATH = "/user"
26
+ LOANS_PATH = "/page/user/prets-en-cours"
27
+ HISTORY_PATH = "/page/user/mon-historique"
28
+ MAX_PAGES = 100
29
+ CAS_HOST = "centralcas.gminvent.fr"
30
+ CAS_LOGIN_URL = f"https://{CAS_HOST}/cas/login"
31
+ SERVICE_URL = f"{BASE_URL}/casservice"
32
+ DEFAULT_SILO = "Chirx"
33
+
34
+
35
+ class _FormParser(HTMLParser):
36
+ """Collect the input fields of the CAS login form."""
37
+
38
+ def __init__(self) -> None:
39
+ super().__init__()
40
+ self.fields: dict[str, str] = {}
41
+ self.has_error = False
42
+
43
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
44
+ attributes = dict(attrs)
45
+ if tag == "input" and attributes.get("name"):
46
+ self.fields[attributes["name"]] = attributes.get("value") or ""
47
+ # Apereo CAS renders failed logins in an element with id "loginErrorsPanel"
48
+ if attributes.get("id") == "loginErrorsPanel":
49
+ self.has_error = True
50
+
51
+
52
+ class MabibliClient:
53
+ """Log in to mabibli.be and keep the authenticated session.
54
+
55
+ The login lives in the session's cookie jar, so a session passed in must be
56
+ dedicated to this client: sharing one between two cards mixes their accounts.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ username: str,
62
+ password: str,
63
+ session: aiohttp.ClientSession | None = None,
64
+ silo: str = DEFAULT_SILO,
65
+ ) -> None:
66
+ self._username = username
67
+ self._password = password
68
+ self._silo = silo
69
+ self._session = session
70
+ self._close_session = session is None
71
+ self.logged_in = False
72
+
73
+ async def login(self) -> None:
74
+ """Authenticate against CAS and land on mabibli.be with a session cookie."""
75
+ params = {
76
+ "fromDrupalBGM": "Non",
77
+ "fromDrupalSilo": self._silo,
78
+ "service": SERVICE_URL,
79
+ }
80
+ page, page_url = await self._fetch(CAS_LOGIN_URL, params=params)
81
+ # A still valid CAS cookie skips the form and redirects straight to the service
82
+ if urlsplit(page_url).hostname != CAS_HOST:
83
+ self.logged_in = True
84
+ return
85
+ form = _FormParser()
86
+ form.feed(page)
87
+ if "execution" not in form.fields:
88
+ raise MabibliConnectionError("CAS login form not found")
89
+
90
+ data = {
91
+ "username": self._username,
92
+ "password": self._password,
93
+ "customFields[silo]": self._silo,
94
+ "execution": form.fields["execution"],
95
+ "_eventId": "submit",
96
+ "geolocation": "",
97
+ }
98
+ try:
99
+ async with self._get_session().post(
100
+ CAS_LOGIN_URL, params=params, data=data
101
+ ) as response:
102
+ body = await response.text()
103
+ final_url = response.url
104
+ status = response.status
105
+ except aiohttp.ClientError as err:
106
+ raise MabibliConnectionError(err) from err
107
+
108
+ # On success CAS redirects to the service with a ticket, which mabibli.be
109
+ # exchanges for its own session cookie.
110
+ if final_url.host == CAS_HOST:
111
+ result = _FormParser()
112
+ result.feed(body)
113
+ if result.has_error or status == 401:
114
+ raise MabibliAuthError("Invalid card number or password")
115
+ raise MabibliConnectionError(f"Login did not redirect (HTTP {status})")
116
+ self.logged_in = True
117
+
118
+ async def get_account(self) -> Account:
119
+ """Return the reader's name, card number and membership end date."""
120
+ html, _ = await self._get_authenticated(ACCOUNT_PATH)
121
+ return parse_account(html)
122
+
123
+ async def get_loans(self) -> list[Loan]:
124
+ """Return the items currently on loan, across all pages."""
125
+ return await self._get_paged_loans(LOANS_PATH)
126
+
127
+ async def get_history(self) -> list[Loan]:
128
+ """Return previously borrowed items, newest first, across all pages."""
129
+ return await self._get_paged_loans(HISTORY_PATH)
130
+
131
+ async def renew_loan(self, loan: Loan) -> list[Message]:
132
+ """Renew one loan and return the messages the site showed.
133
+
134
+ Raises MabibliRenewError if the site reports an error.
135
+ """
136
+ if loan.renew_url is None:
137
+ raise MabibliRenewError(f"{loan.title} cannot be renewed")
138
+ # Make sure the session is valid first, so the renewal is sent exactly once
139
+ await self._get_authenticated(LOANS_PATH)
140
+ return await self._send_renewal(loan.renew_url)
141
+
142
+ async def renew_all(self) -> list[Message]:
143
+ """Renew every renewable loan, like the "Tout prolonger" button."""
144
+ html, page_url = await self._get_authenticated(LOANS_PATH)
145
+ url = parse_renew_all_url(html, page_url)
146
+ if url is None:
147
+ raise MabibliRenewError("No loans can be renewed")
148
+ return await self._send_renewal(url)
149
+
150
+ async def _send_renewal(self, url: str) -> list[Message]:
151
+ # Renewal links are state-changing GETs, so they are never retried
152
+ html, _ = await self._fetch(url)
153
+ if not is_logged_in(html):
154
+ self.logged_in = False
155
+ raise MabibliAuthError("Session lost while renewing; check the loan before retrying")
156
+ messages = parse_messages(html)
157
+ if errors := [m.text for m in messages if m.level == "error"]:
158
+ raise MabibliRenewError("; ".join(errors))
159
+ return messages
160
+
161
+ async def _get_paged_loans(self, path: str) -> list[Loan]:
162
+ loans: list[Loan] = []
163
+ url = path
164
+ visited: set[str] = set()
165
+ for _ in range(MAX_PAGES):
166
+ html, page_url = await self._get_authenticated(url)
167
+ visited.add(page_url)
168
+ page_loans, next_url = parse_loans_page(html, page_url)
169
+ loans.extend(page_loans)
170
+ if next_url is None or next_url in visited:
171
+ return loans
172
+ url = next_url
173
+ _LOGGER.warning("Stopped after %s pages of %s; the list is incomplete", MAX_PAGES, path)
174
+ return loans
175
+
176
+ async def _get_authenticated(self, url: str) -> tuple[str, str]:
177
+ """Fetch a page, logging in first or again if the Drupal session expired."""
178
+ url = urljoin(BASE_URL, url)
179
+ if not self.logged_in:
180
+ await self.login()
181
+ html, final_url = await self._fetch(url)
182
+ if is_logged_in(html):
183
+ return html, final_url
184
+ self.logged_in = False
185
+ await self.login()
186
+ html, final_url = await self._fetch(url)
187
+ if not is_logged_in(html):
188
+ raise MabibliAuthError("Session not accepted after login")
189
+ return html, final_url
190
+
191
+ async def _fetch(self, url: str, **kwargs) -> tuple[str, str]:
192
+ """Return the body and the final URL after redirects."""
193
+ try:
194
+ async with self._get_session().get(urljoin(BASE_URL, url), **kwargs) as response:
195
+ response.raise_for_status()
196
+ return await response.text(), str(response.url)
197
+ except aiohttp.ClientError as err:
198
+ raise MabibliConnectionError(err) from err
199
+
200
+ def _get_session(self) -> aiohttp.ClientSession:
201
+ if self._session is None:
202
+ self._session = aiohttp.ClientSession()
203
+ return self._session
204
+
205
+ async def close(self) -> None:
206
+ if self._close_session and self._session is not None:
207
+ await self._session.close()
208
+ self._session = None
209
+
210
+ async def __aenter__(self) -> Self:
211
+ return self
212
+
213
+ async def __aexit__(self, *exc_info: object) -> None:
214
+ await self.close()
@@ -0,0 +1,17 @@
1
+ """Exceptions for pymabibli."""
2
+
3
+
4
+ class MabibliError(Exception):
5
+ """Base error."""
6
+
7
+
8
+ class MabibliConnectionError(MabibliError):
9
+ """The site could not be reached or returned an unexpected response."""
10
+
11
+
12
+ class MabibliAuthError(MabibliError):
13
+ """The card number or password was rejected."""
14
+
15
+
16
+ class MabibliRenewError(MabibliError):
17
+ """The library refused to renew a loan."""
@@ -0,0 +1,46 @@
1
+ """Data models for pymabibli."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Account:
11
+ """The reader's account summary."""
12
+
13
+ first_name: str
14
+ last_name: str
15
+ card_number: str
16
+ subscription_end: date | None
17
+ subscription_type: str | None
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Message:
22
+ """A status message the site shows after an action, e.g. a renewal."""
23
+
24
+ level: str
25
+ text: str
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Loan:
30
+ """A borrowed item."""
31
+
32
+ title: str
33
+ notice_id: str | None
34
+ document_type: str | None
35
+ authors: tuple[str, ...]
36
+ publisher: str | None
37
+ library: str | None
38
+ loan_date: date | None
39
+ due_date: date | None
40
+ renew_url: str | None
41
+ # Public; redirects to the cover image, or a generic placeholder when there is none
42
+ cover_url: str | None = None
43
+
44
+ @property
45
+ def renewable(self) -> bool:
46
+ return self.renew_url is not None
@@ -0,0 +1,176 @@
1
+ """Parse the HTML pages of mabibli.be (Drupal + BGM modules)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from datetime import date, datetime
7
+ import json
8
+ import re
9
+ from urllib.parse import urljoin
10
+
11
+ from bs4 import BeautifulSoup, Tag
12
+
13
+ from .exceptions import MabibliConnectionError
14
+ from .models import Account, Loan, Message
15
+
16
+ BASE_URL = "https://mabibli.be"
17
+ _DATE_RE = re.compile(r"(\d{2})[-/](\d{2})[-/](\d{4})")
18
+ # The theme uses Bootstrap alerts; core Drupal markup is kept as a fallback
19
+ _MESSAGE_LEVELS = {
20
+ "alert-danger": "error",
21
+ "messages--error": "error",
22
+ "alert-warning": "warning",
23
+ "messages--warning": "warning",
24
+ "alert-success": "status",
25
+ "alert-info": "status",
26
+ "messages--status": "status",
27
+ }
28
+ _MESSAGE_SELECTOR = ", ".join(f".{cls}" for cls in _MESSAGE_LEVELS)
29
+
30
+
31
+ def is_logged_in(html: str) -> bool:
32
+ return "/user/logout" in html
33
+
34
+
35
+ def parse_account(html: str) -> Account:
36
+ user = _soup(html).select_one(".user--type-user")
37
+ if user is None:
38
+ raise MabibliConnectionError("Account block not found")
39
+ end = _text(user.select_one(".field--name-field-date-fin-d-abonnement .field__item"))
40
+ return Account(
41
+ first_name=_text(user.select_one(".field--name-field-prenom")) or "",
42
+ last_name=_text(user.select_one(".field--name-field-nom")) or "",
43
+ card_number=_text(user.select_one(".field--name-username")) or "",
44
+ subscription_end=_parse_date(end),
45
+ subscription_type=_text(user.select_one(".field--name-field-type-abonnement")),
46
+ )
47
+
48
+
49
+ def parse_loans_page(html: str, page_url: str = BASE_URL) -> tuple[list[Loan], str | None]:
50
+ """Return the loans on a page and the URL of the next page, if any."""
51
+ soup = _soup(html)
52
+ return _loans(soup, page_url), _next_page_url(soup, page_url)
53
+
54
+
55
+ def parse_loans(html: str, page_url: str = BASE_URL) -> list[Loan]:
56
+ return _loans(_soup(html), page_url)
57
+
58
+
59
+ def parse_next_page_url(html: str, page_url: str = BASE_URL) -> str | None:
60
+ """Return the "Suivant" link of a paged list, or None on the last page."""
61
+ return _next_page_url(_soup(html), page_url)
62
+
63
+
64
+ def parse_renew_all_url(html: str, page_url: str = BASE_URL) -> str | None:
65
+ link = _soup(html).select_one(".toutprolonger a.button__prolongation[href]")
66
+ return _absolute(link.get("href") if link else None, page_url)
67
+
68
+
69
+ def parse_messages(html: str) -> list[Message]:
70
+ soup = _soup(html)
71
+ containers = soup.select("[data-drupal-messages], .messages__wrapper") or [soup]
72
+ messages: list[Message] = []
73
+ seen: set[int] = set()
74
+ for container in containers:
75
+ for element in container.select(_MESSAGE_SELECTOR):
76
+ # A wrapper and its inner alert can both carry a message class
77
+ if id(element) in seen or _has_message_ancestor(element, container):
78
+ continue
79
+ seen.add(id(element))
80
+ level = next(_MESSAGE_LEVELS[c] for c in element.get("class") or [] if c in _MESSAGE_LEVELS)
81
+ if text := _text(element):
82
+ messages.append(Message(level, text))
83
+ return messages
84
+
85
+
86
+ def _soup(html: str) -> BeautifulSoup:
87
+ """Parse a page, including the blocks Drupal BigPipe streams as JSON scripts."""
88
+ soup = BeautifulSoup(html, "html.parser")
89
+ for script in soup.select("script[data-big-pipe-replacement-for-placeholder-with-id]"):
90
+ try:
91
+ commands = json.loads(script.string or "")
92
+ except ValueError:
93
+ continue
94
+ for command in commands:
95
+ if command.get("command") == "insert" and command.get("data"):
96
+ script.insert_before(BeautifulSoup(command["data"], "html.parser"))
97
+ script.decompose()
98
+ return soup
99
+
100
+
101
+ def _loans(soup: BeautifulSoup, page_url: str) -> list[Loan]:
102
+ loans: list[Loan] = []
103
+ for pret in soup.select("div.pret"):
104
+ item = pret.parent
105
+ if not isinstance(item, Tag):
106
+ continue
107
+ title_link = item.select_one(".field--name-node-title a")
108
+ renew = pret.select_one("a.button__prolongation")
109
+ cover = item.select_one(".field--name-field-couverture img[src]")
110
+ loans.append(
111
+ Loan(
112
+ title=_clean_title(_text(title_link) or ""),
113
+ notice_id=_notice_id(title_link.get("href") if title_link else None),
114
+ document_type=_text(item.select_one(".typedoc")),
115
+ authors=_unique(
116
+ _text(a)
117
+ for a in item.select(".field--name-field-auteurs .field__item")
118
+ ),
119
+ publisher=_text(item.select_one(".field--name-field-editeur .field__item")),
120
+ library=_text(pret.select_one(".mediatheque div")),
121
+ loan_date=_parse_date(_text(pret.select_one(".field--name-field-date-emprunt"))),
122
+ due_date=_parse_date(_text(pret.select_one(".field--name-field-date-de-retour"))),
123
+ renew_url=_absolute(renew.get("href") if renew else None, page_url),
124
+ cover_url=_absolute(cover.get("src") if cover else None, page_url),
125
+ )
126
+ )
127
+ return loans
128
+
129
+
130
+ def _next_page_url(soup: BeautifulSoup, page_url: str) -> str | None:
131
+ for link in soup.select(".pager a[href]"):
132
+ if "suivant" in link.get_text().lower():
133
+ return _absolute(link.get("href"), page_url)
134
+ return None
135
+
136
+
137
+ def _has_message_ancestor(element: Tag, container: Tag) -> bool:
138
+ for parent in element.parents:
139
+ if parent is container:
140
+ return False
141
+ if any(c in _MESSAGE_LEVELS for c in parent.get("class") or []):
142
+ return True
143
+ return False
144
+
145
+
146
+ def _absolute(href: object, page_url: str) -> str | None:
147
+ return urljoin(page_url, href) if isinstance(href, str) else None
148
+
149
+
150
+ def _text(tag: Tag | None) -> str | None:
151
+ if tag is None:
152
+ return None
153
+ return " ".join(tag.get_text(" ").split()) or None
154
+
155
+
156
+ def _parse_date(value: str | None) -> date | None:
157
+ if not value or not (match := _DATE_RE.search(value)):
158
+ return None
159
+ day, month, year = match.groups()
160
+ return datetime(int(year), int(month), int(day)).date()
161
+
162
+
163
+ def _clean_title(title: str) -> str:
164
+ # The catalogue marks the leading article with "|", e.g. "L'| Atelier"
165
+ title = re.sub(r"(['’])\|\s*", r"\1", title)
166
+ return re.sub(r"\s*\|\s*", " ", title).strip()
167
+
168
+
169
+ def _notice_id(href: object) -> str | None:
170
+ if isinstance(href, str) and (match := re.search(r"/notice/(\d+)", href)):
171
+ return match.group(1)
172
+ return None
173
+
174
+
175
+ def _unique(values: Iterable[str | None]) -> tuple[str, ...]:
176
+ return tuple(dict.fromkeys(v for v in values if v))
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: pymabibli
3
+ Version: 0.0.1
4
+ Summary: An async Python client for mabibli.be, the libraries of the Province of Liège
5
+ Home-page: https://github.com/bestycame/pymabibli
6
+ Author: Bestycame
7
+ Author-email: bestycame@gmail.com
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Framework :: AsyncIO
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Requires-Python: >=3.12
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: aiohttp>=3.9
21
+ Requires-Dist: beautifulsoup4>=4.12
22
+ Dynamic: author
23
+ Dynamic: author-email
24
+ Dynamic: classifier
25
+ Dynamic: description
26
+ Dynamic: description-content-type
27
+ Dynamic: home-page
28
+ Dynamic: license-file
29
+ Dynamic: requires-dist
30
+ Dynamic: requires-python
31
+ Dynamic: summary
32
+
33
+ # Mabibli Python Client
34
+ ## NOT OFFICIALLY SUPPORTED BY THE PROVINCE DE LIÈGE --
35
+
36
+ An async Python client library for [mabibli.be](https://mabibli.be/), the library network of the Province of Liège.
37
+ This client logs in with your library card and reads your account, loans and borrowing history, and can renew loans.
38
+ Developped for a Home Assistant integration. At least for now.
39
+
40
+ ## Installation
41
+ You can install the package using pip:
42
+
43
+ ```bash
44
+ pip install pymabibli
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ Here's a basic example of how to use the client:
50
+
51
+ ```python
52
+ import asyncio
53
+
54
+ from pymabibli import MabibliClient
55
+
56
+
57
+ async def main():
58
+ async with MabibliClient("your-card-number", "your-password") as client:
59
+ # Log in (also done automatically by the other methods)
60
+ await client.login()
61
+
62
+ # Get account information
63
+ account = await client.get_account()
64
+ print(f"Membership ends: {account.subscription_end}")
65
+
66
+ # Get current loans
67
+ loans = await client.get_loans()
68
+ for loan in loans:
69
+ print(f"{loan.title} [{loan.library}] due {loan.due_date}")
70
+
71
+ # Get borrowing history
72
+ history = await client.get_history()
73
+ print(f"{len(history)} items borrowed")
74
+
75
+ # Renew one loan
76
+ messages = await client.renew_loan(loans[0])
77
+
78
+ # Renew all loans ("Tout prolonger")
79
+ messages = await client.renew_all()
80
+
81
+
82
+ asyncio.run(main())
83
+ ```
84
+
85
+ You can pass your own `aiohttp.ClientSession` with `session=`; the client then leaves it open when closing.
86
+ The login is kept in the session's cookie jar, so each client needs a session of its own:
87
+ two cards sharing one session would see each other's account.
88
+ In Home Assistant, use `async_create_clientsession(hass)` per config entry, not the shared `async_get_clientsession(hass)`.
89
+
90
+ ## Error Handling
91
+
92
+ The client provides specific exception classes:
93
+
94
+ ### Exception Classes
95
+
96
+ - `MabibliError`: Base exception for all errors
97
+ - `MabibliAuthError`: Raised when the card number or password is rejected
98
+ - `MabibliConnectionError`: Raised when the site can't be reached or returns an unexpected page
99
+ - `MabibliRenewError`: Raised when a loan can't be renewed or the site refuses the renewal
100
+
101
+ ### Example Error Handling
102
+
103
+ ```python
104
+ from pymabibli import MabibliAuthError, MabibliClient, MabibliRenewError
105
+
106
+ async def renew_everything():
107
+ async with MabibliClient("your-card-number", "your-password") as client:
108
+ try:
109
+ loans = await client.get_loans()
110
+ except MabibliAuthError as e:
111
+ print(f"Login failed: {e}")
112
+ return
113
+
114
+ for loan in loans:
115
+ if not loan.renewable:
116
+ continue
117
+ try:
118
+ await client.renew_loan(loan)
119
+ except MabibliRenewError as e:
120
+ print(f"Could not renew {loan.title}: {e}")
121
+ ```
122
+
123
+ ### Authentication
124
+
125
+ mabibli.be authenticates through a CAS server (`centralcas.gminvent.fr`).
126
+ The client logs in on first use and logs in again when the site's session expires.
127
+ Renewals are never retried automatically: if the session is lost during a renewal, `MabibliAuthError` is raised
128
+ and you should check the loan before trying again.
129
+
130
+ ### API Methods
131
+
132
+ - `login() -> None`: Log in with the card number and password
133
+ - `get_account() -> Account`: Get name, card number and membership end date
134
+ - `get_loans() -> list[Loan]`: Get current loans, across all pages
135
+ - `get_history() -> list[Loan]`: Get previously borrowed items, newest first, across all pages
136
+ - `renew_loan(loan: Loan) -> list[Message]`: Renew one loan and return the site's messages
137
+ - `renew_all() -> list[Message]`: Renew all renewable loans and return the site's messages
138
+
139
+ ## Development
140
+
141
+ ```bash
142
+ pipenv install --dev
143
+ pipenv run pytest
144
+ pipenv run flake8
145
+ ```
146
+
147
+ The scripts in `examples/` run against the real site with your own card.
148
+ `examples/renew.py` really renews a loan, after asking which one.
149
+
150
+ ## License
151
+ This project is licensed under the MIT License - see the LICENSE file for details.
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ setup.py
4
+ pymabibli/__init__.py
5
+ pymabibli/client.py
6
+ pymabibli/exceptions.py
7
+ pymabibli/models.py
8
+ pymabibli/parsers.py
9
+ pymabibli.egg-info/PKG-INFO
10
+ pymabibli.egg-info/SOURCES.txt
11
+ pymabibli.egg-info/dependency_links.txt
12
+ pymabibli.egg-info/requires.txt
13
+ pymabibli.egg-info/top_level.txt
14
+ tests/test_client.py
15
+ tests/test_parsers.py
@@ -0,0 +1,2 @@
1
+ aiohttp>=3.9
2
+ beautifulsoup4>=4.12
@@ -0,0 +1 @@
1
+ pymabibli
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,32 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ with open("README.md", "r", encoding="utf-8") as fh:
4
+ long_description = fh.read()
5
+
6
+ setup(
7
+ name="pymabibli",
8
+ version="0.0.1",
9
+ author="Bestycame",
10
+ author_email="bestycame@gmail.com",
11
+ description="An async Python client for mabibli.be, the libraries of the Province of Liège",
12
+ long_description=long_description,
13
+ long_description_content_type="text/markdown",
14
+ url="https://github.com/bestycame/pymabibli",
15
+ packages=find_packages(exclude=["tests", "tests.*", "examples"]),
16
+ classifiers=[
17
+ "Development Status :: 3 - Alpha",
18
+ "Framework :: AsyncIO",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ ],
27
+ python_requires=">=3.12",
28
+ install_requires=[
29
+ "aiohttp>=3.9",
30
+ "beautifulsoup4>=4.12",
31
+ ],
32
+ )
@@ -0,0 +1,156 @@
1
+ """Tests for the client, with page fetching stubbed out."""
2
+
3
+ from collections.abc import AsyncIterator
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+ import pytest_asyncio
8
+
9
+ from pymabibli import MabibliAuthError, MabibliClient, MabibliRenewError, Message
10
+ from pymabibli.client import CAS_LOGIN_URL, SERVICE_URL
11
+
12
+ FIXTURES = Path(__file__).parent / "fixtures"
13
+ HISTORY_URL = "https://mabibli.be/page/user/mon-historique"
14
+ LOANS_URL = "https://mabibli.be/page/user/prets-en-cours"
15
+ RENEW_URL = "https://mabibli.be/bgm--prolongation--byId/1/He/2/L25vZGUvMQ=="
16
+ RENEW_ALL_URL = "https://mabibli.be/bgm--toutprolongation--byId/L25vZGUvMQ=="
17
+ LOGGED_OUT_PAGE = "<html><a href='/user/login'>Connexion</a></html>"
18
+
19
+
20
+ def _fixture(name: str) -> str:
21
+ return (FIXTURES / name).read_text(encoding="utf-8")
22
+
23
+
24
+ def _history_page(next_href: str) -> str:
25
+ return (
26
+ "<a href='/user/logout'>X</a>"
27
+ "<div class='field__item'><div class='notice'><div class='field--name-node-title'>"
28
+ "<a href='/notice/1'>Livre</a></div></div><div class='pret'></div></div>"
29
+ f"<div class='pager'><a href='{next_href}'>Suivant</a></div>"
30
+ )
31
+
32
+
33
+ class FakeSite:
34
+ """Serve fixture pages by URL, follow fake redirects, and record requests."""
35
+
36
+ def __init__(self) -> None:
37
+ self.pages: dict[str, str] = {}
38
+ self.redirects: dict[str, str] = {}
39
+ self.requested: list[str] = []
40
+
41
+ async def fetch(self, url: str, **kwargs: object) -> tuple[str, str]:
42
+ self.requested.append(url)
43
+ final_url = self.redirects.get(url, url)
44
+ return self.pages[final_url], final_url
45
+
46
+
47
+ @pytest.fixture
48
+ def site() -> FakeSite:
49
+ return FakeSite()
50
+
51
+
52
+ @pytest_asyncio.fixture
53
+ async def client(site: FakeSite, monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[MabibliClient]:
54
+ async with MabibliClient("041000000000", "secret") as client:
55
+ # Skip the CAS login; it is covered by the live example script
56
+ client.logged_in = True
57
+ monkeypatch.setattr(client, "_fetch", site.fetch)
58
+ yield client
59
+
60
+
61
+ def test_client_can_be_created_outside_event_loop() -> None:
62
+ MabibliClient("041000000000", "secret")
63
+
64
+
65
+ @pytest.mark.asyncio
66
+ async def test_login_with_valid_cas_cookie(client: MabibliClient, site: FakeSite) -> None:
67
+ client.logged_in = False
68
+ site.redirects[CAS_LOGIN_URL] = SERVICE_URL
69
+ site.pages[SERVICE_URL] = "<a href='/user/logout'>X</a>"
70
+
71
+ await client.login()
72
+
73
+ assert client.logged_in
74
+ assert site.requested == [CAS_LOGIN_URL]
75
+
76
+
77
+ @pytest.mark.asyncio
78
+ async def test_get_history_follows_pages(client: MabibliClient, site: FakeSite) -> None:
79
+ site.pages[HISTORY_URL] = _fixture("history_page1.html")
80
+ site.pages[f"{HISTORY_URL}?prh=1"] = _fixture("history_page2.html")
81
+
82
+ loans = await client.get_history()
83
+
84
+ assert [loan.title for loan in loans] == ["Premier livre", "Second livre"]
85
+ assert site.requested == [HISTORY_URL, f"{HISTORY_URL}?prh=1"]
86
+
87
+
88
+ @pytest.mark.asyncio
89
+ async def test_get_history_resolves_relative_pager_link(client: MabibliClient, site: FakeSite) -> None:
90
+ site.pages[HISTORY_URL] = _history_page("?prh=1")
91
+ site.pages[f"{HISTORY_URL}?prh=1"] = _fixture("history_page2.html")
92
+
93
+ await client.get_history()
94
+
95
+ assert site.requested == [HISTORY_URL, f"{HISTORY_URL}?prh=1"]
96
+
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_get_history_stops_on_repeated_page(client: MabibliClient, site: FakeSite) -> None:
100
+ site.pages[HISTORY_URL] = _history_page("/page/user/mon-historique")
101
+
102
+ loans = await client.get_history()
103
+
104
+ assert len(loans) == 1
105
+ assert site.requested == [HISTORY_URL]
106
+
107
+
108
+ @pytest.mark.asyncio
109
+ async def test_renew_loan(client: MabibliClient, site: FakeSite) -> None:
110
+ site.pages[LOANS_URL] = _fixture("loans.html")
111
+ site.pages[RENEW_URL] = _fixture("renew_success.html")
112
+ loan = (await client.get_loans())[0]
113
+
114
+ assert await client.renew_loan(loan) == [Message("status", "Prolongation effectuée")]
115
+ assert site.requested[-2:] == [LOANS_URL, RENEW_URL]
116
+
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_renew_loan_refused(client: MabibliClient, site: FakeSite) -> None:
120
+ site.pages[LOANS_URL] = _fixture("loans.html")
121
+ site.pages[RENEW_URL] = _fixture("renew_error.html")
122
+ loan = (await client.get_loans())[0]
123
+
124
+ with pytest.raises(MabibliRenewError, match="document réservé"):
125
+ await client.renew_loan(loan)
126
+
127
+
128
+ @pytest.mark.asyncio
129
+ async def test_renew_loan_is_not_retried(client: MabibliClient, site: FakeSite) -> None:
130
+ site.pages[LOANS_URL] = _fixture("loans.html")
131
+ site.pages[RENEW_URL] = LOGGED_OUT_PAGE
132
+ loan = (await client.get_loans())[0]
133
+
134
+ with pytest.raises(MabibliAuthError, match="check the loan"):
135
+ await client.renew_loan(loan)
136
+
137
+ assert site.requested.count(RENEW_URL) == 1
138
+ assert not client.logged_in
139
+
140
+
141
+ @pytest.mark.asyncio
142
+ async def test_renew_loan_not_renewable(client: MabibliClient, site: FakeSite) -> None:
143
+ site.pages[LOANS_URL] = _fixture("loans.html")
144
+ loan = (await client.get_loans())[1]
145
+
146
+ with pytest.raises(MabibliRenewError, match="cannot be renewed"):
147
+ await client.renew_loan(loan)
148
+
149
+
150
+ @pytest.mark.asyncio
151
+ async def test_renew_all(client: MabibliClient, site: FakeSite) -> None:
152
+ site.pages[LOANS_URL] = _fixture("loans.html")
153
+ site.pages[RENEW_ALL_URL] = _fixture("renew_success.html")
154
+
155
+ assert await client.renew_all() == [Message("status", "Prolongation effectuée")]
156
+ assert site.requested == [LOANS_URL, RENEW_ALL_URL]
@@ -0,0 +1,129 @@
1
+ """Tests for the HTML parsers."""
2
+
3
+ from datetime import date
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from pymabibli import MabibliError
9
+ from pymabibli.models import Message
10
+ from pymabibli.parsers import (
11
+ is_logged_in,
12
+ parse_account,
13
+ parse_loans,
14
+ parse_messages,
15
+ parse_next_page_url,
16
+ parse_renew_all_url,
17
+ )
18
+
19
+ FIXTURES = Path(__file__).parent / "fixtures"
20
+
21
+
22
+ def _fixture(name: str) -> str:
23
+ return (FIXTURES / name).read_text(encoding="utf-8")
24
+
25
+
26
+ def test_parse_account() -> None:
27
+ account = parse_account(_fixture("account.html"))
28
+ assert account.first_name == "Jeanne"
29
+ assert account.last_name == "DUPONT"
30
+ assert account.card_number == "041000000000"
31
+ assert account.subscription_end == date(2027, 3, 1)
32
+ assert account.subscription_type == "03 - Adulte (03)"
33
+
34
+
35
+ def test_parse_account_unexpected_page() -> None:
36
+ with pytest.raises(MabibliError, match="Account block not found"):
37
+ parse_account("<html><a href='/user/logout'>X</a></html>")
38
+
39
+
40
+ def test_parse_loans() -> None:
41
+ first, second = parse_loans(_fixture("loans.html"))
42
+
43
+ assert first.title == "L'Atelier des petits artistes"
44
+ assert first.notice_id == "111"
45
+ assert first.document_type == "Livre"
46
+ assert first.authors == ("Wilkes, Angela (1952-....)", "Doe, John Illustrateur")
47
+ assert first.publisher == "Larousse"
48
+ assert first.library == "Geer"
49
+ assert first.loan_date == date(2026, 9, 20)
50
+ assert first.due_date == date(2026, 11, 22)
51
+ assert first.renew_url == "https://mabibli.be/bgm--prolongation--byId/1/He/2/L25vZGUvMQ=="
52
+ assert first.cover_url == "https://mabibli.be/bgm--couverture/1"
53
+
54
+ assert second.title == "Le Grand voyage"
55
+ assert second.document_type == "DVD"
56
+ assert second.authors == ()
57
+ assert second.publisher is None
58
+ assert second.due_date == date(2026, 11, 5)
59
+ assert not second.renewable
60
+ assert second.cover_url is None
61
+
62
+ assert len({first, second}) == 2
63
+
64
+
65
+ def test_parse_loans_resolves_against_page_url() -> None:
66
+ html = (
67
+ "<div class='field__item'><div class='field--name-field-couverture'><img src='cover/1'></div>"
68
+ "<div class='pret'><div class='prolongation'>"
69
+ "<a class='button__prolongation' href='../renew/1'>Prolonger</a></div></div></div>"
70
+ )
71
+ (loan,) = parse_loans(html, "https://mabibli.be/page/user/prets-en-cours")
72
+ assert loan.renew_url == "https://mabibli.be/page/renew/1"
73
+ assert loan.cover_url == "https://mabibli.be/page/user/cover/1"
74
+
75
+
76
+ def test_clean_title_with_typographic_apostrophe() -> None:
77
+ html = (
78
+ "<div class='field__item'><div class='field--name-node-title'><a>L’| Été</a></div>"
79
+ "<div class='pret'></div></div>"
80
+ )
81
+ assert parse_loans(html)[0].title == "L’Été"
82
+
83
+
84
+ def test_parse_loans_from_bigpipe() -> None:
85
+ (loan,) = parse_loans(_fixture("loans_bigpipe.html"))
86
+ assert loan.title == "L'Atelier des petits artistes"
87
+ assert loan.library == "Geer"
88
+ assert loan.due_date == date(2026, 11, 22)
89
+ assert loan.renewable
90
+
91
+
92
+ def test_parse_next_page_url() -> None:
93
+ assert (
94
+ parse_next_page_url(_fixture("history_page1.html"))
95
+ == "https://mabibli.be/page/user/mon-historique?prh=1"
96
+ )
97
+ assert parse_next_page_url(_fixture("history_page2.html")) is None
98
+
99
+
100
+ def test_parse_renew_all_url() -> None:
101
+ assert (
102
+ parse_renew_all_url(_fixture("loans.html"))
103
+ == "https://mabibli.be/bgm--toutprolongation--byId/L25vZGUvMQ=="
104
+ )
105
+ assert parse_renew_all_url(_fixture("history_page1.html")) is None
106
+
107
+
108
+ def test_parse_messages() -> None:
109
+ assert parse_messages(_fixture("renew_success.html")) == [
110
+ Message("status", "Prolongation effectuée")
111
+ ]
112
+ assert parse_messages(_fixture("renew_error.html")) == [
113
+ Message("error", "Prolongation impossible : document réservé")
114
+ ]
115
+ assert parse_messages(_fixture("loans.html")) == []
116
+
117
+
118
+ def test_parse_messages_ignores_alerts_outside_message_area() -> None:
119
+ html = (
120
+ "<div class='alert alert-danger'>Retard sur un document</div>"
121
+ "<div data-drupal-messages><div class='messages messages--error'>"
122
+ "<div class='alert alert-danger'>Prolongation impossible</div></div></div>"
123
+ )
124
+ assert parse_messages(html) == [Message("error", "Prolongation impossible")]
125
+
126
+
127
+ def test_is_logged_in() -> None:
128
+ assert is_logged_in(_fixture("loans.html"))
129
+ assert not is_logged_in("<html><a href='/user/login'>Connexion</a></html>")