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.
- pymabibli-0.0.1/LICENSE +21 -0
- pymabibli-0.0.1/PKG-INFO +151 -0
- pymabibli-0.0.1/README.md +119 -0
- pymabibli-0.0.1/pymabibli/__init__.py +21 -0
- pymabibli-0.0.1/pymabibli/client.py +214 -0
- pymabibli-0.0.1/pymabibli/exceptions.py +17 -0
- pymabibli-0.0.1/pymabibli/models.py +46 -0
- pymabibli-0.0.1/pymabibli/parsers.py +176 -0
- pymabibli-0.0.1/pymabibli.egg-info/PKG-INFO +151 -0
- pymabibli-0.0.1/pymabibli.egg-info/SOURCES.txt +15 -0
- pymabibli-0.0.1/pymabibli.egg-info/dependency_links.txt +1 -0
- pymabibli-0.0.1/pymabibli.egg-info/requires.txt +2 -0
- pymabibli-0.0.1/pymabibli.egg-info/top_level.txt +1 -0
- pymabibli-0.0.1/setup.cfg +4 -0
- pymabibli-0.0.1/setup.py +32 -0
- pymabibli-0.0.1/tests/test_client.py +156 -0
- pymabibli-0.0.1/tests/test_parsers.py +129 -0
pymabibli-0.0.1/LICENSE
ADDED
|
@@ -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.
|
pymabibli-0.0.1/PKG-INFO
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pymabibli
|
pymabibli-0.0.1/setup.py
ADDED
|
@@ -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>")
|