Improve linting, add typechecking and documentation.

This commit is contained in:
Frédéric Tronel
2026-07-21 14:11:04 +02:00
parent fcf4d914c9
commit f5bb018e6d
2 changed files with 609 additions and 153 deletions
+568 -114
View File
@@ -1,41 +1,229 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""
espace_citoyens_cli
===================
A small commandline utility that logs into a French “espacecitoyens”
municipal portal, lists the different reservation categories that are available
for a given city and, on request, exports the reservations of a chosen category
to an iCalendar (``.ics``) file.
The script is deliberately *failfast*: any unrecoverable error (missing
configuration, HTTP error, unexpected HTML markup, unknown reservation code,
etc.) is logged and the process terminates with ``sys.exit(-1)``. This makes
failures immediately visible in CI pipelines or in automated scripts that call
the tool.
Why the script exists
---------------------
Many French municipalities expose a public “espacecitoyens” where residents
can book facilities (sports halls, cultural rooms, playgrounds, ...).
The web interface is JavaScriptdriven and does not provide an official API.
This module reproduces the steps a browser would take, extracts the reservation data,
and converts it into a standard iCalendar file that can be imported into any
calendar application (Outlook, Google Calendar, Apple Calendar, ...).
Highlevel workflow
-------------------
1. **Authentication** ``authenticate()`` fetches the public landing page,
discovers the login form, extracts the hidden ``__RequestVerificationToken``,
posts the credentials, follows the 302 redirect and returns the URL of the
users main page together with the session cookies.
2. **Discover reservation kinds** ``get_reservations_kind()`` loads the main
page, parses the *tblDalleDetail_Reservation* table and builds a mapping
``{kind_name: absolute_url}``.
3. **Export a reservation calendar** ``dump_reservation()`` retrieves the
JavaScript variables required for the calendar request, calls the JSONbased
calendar endpoint, walks the nested week → day → reservation structure,
builds an ``ics.Calendar`` object and writes it to the requested output file.
4. **Commandline entry point** ``main()`` parses arguments (login,
city, optional config file, debug flag, subcommand), merges a possible
configuration file, performs authentication and dispatches to the functions
above.
Public API
----------
- :func:`get_key(dictionnary, key) → Any`
- :func:`authenticate(base_url, city, login, password) → (str, RequestsCookieJar)`
- :func:`get_reservations_kind(base_url, url, cookies) → dict[str, str]`
- :func:`dump_reservation(base_url, city, url, cookies, output) → None`
- :func:`main() → None`
Typical usage
-------------
```bash
# 1. Verify that the credentials work
$ python reservation_cli.py -l john.doe@example.com -C paris A
# 2. List all reservation categories for the city
$ python reservation_cli.py -l john -C lyon L
1 - Sport
2 - Culture
3 - Bibliothèque
# 3. Export the second category to an iCalendar file
$ python reservation_cli.py -l john -C lyon D -i 2 -o my_reservations.ics
"""
import configparser import configparser
import argparse import argparse
import requests
from sys import exit
import logging import logging
import coloredlogs
import json import json
import re import re
import sys
from io import StringIO from io import StringIO
import getpass import getpass
import os
from bs4 import BeautifulSoup
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from datetime import datetime from datetime import datetime
from math import floor from collections.abc import Hashable
from typing import Any
from requests.cookies import RequestsCookieJar
from typeguard import typechecked
import requests
import coloredlogs
from bs4 import BeautifulSoup
from ics import Calendar, Event from ics import Calendar, Event
@typechecked
def get_key(dictionnary: dict, key: Hashable) -> Any:
"""
Retrieve a value from a dictionary, logging an error and terminating the program
if the requested key is missing.
def getKey(dictionnary, key): This helper centralises the “failfast” pattern for required configuration
values (or any other mandatory mapping entries). Instead of propagating a
`KeyError`, the function logs a clear error message and exits with a nonzero
status code, making the failure immediately visible in logs and in the
process exit code.
Args:
dictionnary (dict):
The dictionary (or mapping) to search. The name *dictionnary*
mirrors the original code, but any mapping type that supports
``__getitem__`` works (e.g., ``dict``, ``collections.UserDict``,
``defaultdict``).
key (Hashable):
The key whose associated value should be returned.
Returns:
Any:
The value stored under ``key`` in ``dictionnary``.
Raises:
SystemExit:
If ``key`` is not present in ``dictionnary``. The function logs an
error message of the form ``"Missing key: <key>"`` before exiting
with status ``-1`` (equivalent to an exit code of ``255`` on most
POSIX systems).
Side Effects:
* Writes an error entry to the logger for the current module
(``logging.getLogger(__name__)``).
* Calls ``sys.exit(-1)`` which terminates the interpreter.
Example:
>>> config = {"host": "localhost", "port": 8080}
>>> get_key(config, "host")
'localhost'
>>> get_key(config, "username")
# Logs: "Missing key: username"
# Terminates the program with exit code -1
"""
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
return dictionnary[key] return dictionnary[key]
except: except KeyError:
logger.error('Missing key: %s' % key) logger.error('Missing key: %s', key)
exit(-1) sys.exit(-1)
def authenticate(baseURL, city, login, password): @typechecked
def authenticate(base_url: str, city: str, login: str, password: str) -> (str, RequestsCookieJar):
"""
Perform a twostep web authentication for a citizenportal site and return the
URL of the users main page together with the session cookies.
The routine mimics the steps a browser would take:
1. Retrieve the public “espacecitoyens” page for the specified city.
2. Parse the HTML to discover the formaction URL used for login.
3. Extract the hidden ``__RequestVerificationToken`` required for the POST.
4. Submit the credentials and token via a POST request.
5. Verify that the server responded with a 302 redirect (successful login) and
build the absolute URL of the landing page.
All failures are logged (using the modulelevel logger) and cause the
process to exit with status ``-1`` via ``sys.exit``.
Args:
base_url (str):
The root of the portal, e.g. ``"https://example.com"``. The function
will append ``/<city>/espace-citoyens`` to form the initial URL.
city (str):
Identifier of the city (or subsite) to target, inserted into the
URL path.
login (str):
The username/email used for authentication.
password (str):
The password associated with ``login``.
Returns:
tuple:
``(main_page_url, cookies)`` where:
* **main_page_url** (str) Absolute URL of the page the user is
redirected to after a successful login (taken from the
``Location`` header of the 302 response).
* **cookies** (RequestsCookieJar) Cookie jar captured from the first
GET request; it must be passed to subsequent requests to keep the
session alive.
Raises:
SystemExit:
The function deliberately exits the interpreter on any of the
following conditions, after writing an appropriate error message to
the logger:
* Unable to fetch the initial page (non200 status).
* Unable to locate the login forms ``action`` URL.
* Unable to extract the hidden ``__RequestVerificationToken``.
* Login POST does not return a 302 redirect.
Side Effects:
* Writes informational, debug, and error messages to the module logger.
* Calls ``sys.exit(-1)`` on fatal errors, terminating the process.
* Performs network I/O via the ``requests`` library.
Example:
>>> base = "https://mycity.gov"
>>> city = "paris"
>>> user = "john.doe@example.com"
>>> pwd = "s3cr3t"
>>> main_url, sess_cookies = authenticate(base, city, user, pwd)
>>> print(main_url)
https://mycity.gov/dashboard
>>> # Subsequent requests can reuse ``sess_cookies``:
>>> resp = requests.get(main_url, cookies=sess_cookies)
Note:
The function parses the HTML with regular expressions and ``StringIO``;
it assumes the login form and token follow the exact patterns used in
the original site. If the page layout changes, the regexes may need to
be updated.
"""
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
url = baseURL + '/%s/espace-citoyens' % city url = f"{base_url}/{city}/espace-citoyens"
logger.info('Retrieve base site') logger.info('Retrieve base site')
html = requests.get(url, allow_redirects=False) html = requests.get(url, allow_redirects=False, timeout=10)
if html.status_code != 200: if html.status_code != 200:
logger.error('Impossible to retrieve Web site: %d' % html.status_code) logger.error('Impossible to retrieve Web site: %d', html.status_code)
exit(-1) sys.exit(-1)
cookies = html.cookies cookies = html.cookies
@@ -45,16 +233,16 @@ def authenticate(baseURL, city, login, password):
for line in content.readlines(): for line in content.readlines():
m = p.match(line) m = p.match(line)
if m != None: if m is not None:
found = True found = True
logonURL = baseURL+m.group('url') logon_url = base_url+m.group('url')
break break
if not found: if not found:
logger.error('Impossible to retrieve logon URL') logger.error('Impossible to retrieve logon URL')
exit(-1) sys.exit(-1)
logger.debug('Found logon: %s' % (logonURL)) logger.debug('Found logon: %s', logon_url)
found = False found = False
p = re.compile('^.*name="__RequestVerificationToken" type="hidden" value="(?P<token>[^"]+)".*$') p = re.compile('^.*name="__RequestVerificationToken" type="hidden" value="(?P<token>[^"]+)".*$')
@@ -62,119 +250,276 @@ def authenticate(baseURL, city, login, password):
for line in content.readlines(): for line in content.readlines():
m = p.match(line) m = p.match(line)
if m != None: if m is not None:
found = True found = True
token = m.group('token') token = m.group('token')
break break
if not found: if not found:
logger.error('Impossible to retrieve verification token') logger.error('Impossible to retrieve verification token')
exit(-1) sys.exit(-1)
logger.debug('Found token: %s' % (token)) logger.debug('Found token: %s', token)
payload = { 'username':login, 'password':password, '__RequestVerificationToken':token } payload = { 'username':login, 'password':password, '__RequestVerificationToken':token }
auth = requests.post(logonURL, data=payload, cookies=cookies, allow_redirects=False) auth = requests.post(logon_url, data=payload, cookies=cookies, allow_redirects=False,
timeout=10)
if auth.status_code != 302: if auth.status_code != 302:
logger.error('Impossible to login: %d' % html.status_code) logger.error('Impossible to login: %d', html.status_code)
exit(-1) sys.exit(-1)
else: else:
logger.info('Authentication successful') logger.info('Authentication successful')
mainpage = baseURL+auth.headers['Location'] mainpage = base_url+auth.headers['Location']
return mainpage, cookies return mainpage, cookies
def getReservationsKind(baseURL, url, cookies): def get_reservations_kind(base_url: str, url: str, cookies: RequestsCookieJar) -> dict[str, str]:
"""
Scrape a reservationportal page and return a mapping of reservationtype
names → absolute URLs that list the corresponding reservations.
The function expects the *url* argument to point to a page that contains a
single ``<table id="tblDalleDetail_Reservation">`` element. Each row of the
table represents a different kind of reservation (e.g. “Sport”, “Culture”,
…). For each row the function extracts:
* the textual description found in the ``<td>`` whose *id* matches the
regular expression ``ReservationActivite``;
* the relative hyperlink (``href``) of the first ``<a>`` tag in the row.
The relative link is then prefixed with *base_url* to produce an absolute
URL. All pairs are collected in a dictionary and returned to the caller.
**Behaviour on failure** The function logs the problem and terminates the
process with ``sys.exit(-1)`` (i.e. a ``SystemExit`` exception). This mirrors
the “failfast” style used throughout the code base.
Args:
base_url (str):
The root URL of the site (e.g. ``"https://example.com"``
without a trailing slash). It is concatenated with the relative
links found in the table to build the final URLs.
url (str):
The full URL of the page that lists the different reservation types.
cookies (requests.cookies.RequestsCookieJar):
Cookie jar obtained during the authentication step; passed to
``requests.get`` so the request is made within the loggedin session.
Returns:
dict[str, str]:
A dictionary where each key is the reservation type name
(as extracted from the table cell) and each value is the absolute
URL that leads to the detailed list of reservations for that type.
Raises:
SystemExit:
* If the HTTP GET request does not return a *200* status.
* If the page does not contain exactly one table with the expected
``id`` (either none found or more than one).
* Any other unexpected parsing error that would lead to a missing
element (the code will log an error and exit).
Side effects:
* Writes informational and error messages to the module logger.
* Calls ``sys.exit(-1)`` on fatal errors, terminating the interpreter.
Example:
>>> base = "https://mycity.gov"
>>> page = "https://mycity.gov/paris/espace-citoyens/reservations"
>>> session_cookies = authenticate(base, "paris", "john", "s3cr3t")[1]
>>> kinds = get_reservations_kind(base, page, session_cookies)
>>> for kind, link in kinds.items():
... print(f"{kind}: {link}")
Sport: https://mycity.gov/paris/reservations/sport
Culture: https://mycity.gov/paris/reservations/culture
Note:
The function uses **BeautifulSoup** (HTML parser) and a simple regular
expression to locate the ``<td>`` element that holds the reservation type.
If the upstream website changes its markup (different table id, different
column names, etc.) the function will need to be updated accordingly.
"""
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Retrieve main page # Retrieve main page
html = requests.get(url, cookies=cookies, allow_redirects=False) html = requests.get(url, cookies=cookies, allow_redirects=False, timeout=10)
if html.status_code != 200: if html.status_code != 200:
logger.error('Impossible to retrieve main page: %d' % html.status_code) logger.error('Impossible to retrieve main page: %d', html.status_code)
exit(-1) sys.exit(-1)
soup = BeautifulSoup(html.text, 'html.parser') soup = BeautifulSoup(html.text, 'html.parser')
resas = soup.find_all('table', id='tblDalleDetail_Reservation') resas = soup.find_all('table', id='tblDalleDetail_Reservation')
if len(resas) != 1: if len(resas) != 1:
logger.error('Too many kind of reservations') logger.error('Too many kind of reservations')
exit(-1) sys.exit(-1)
resas = resas[0] resas = resas[0]
resas = resas.find_all('tr') resas = resas.find_all('tr')
resaTypes = {} resa_types = {}
for resa in resas: for resa in resas:
resaType = resa.find('td', id=re.compile('ReservationActivite')).get_text() resa_type = resa.find('td', id=re.compile('ReservationActivite')).get_text()
url = resa.find('a').get('href') url = resa.find('a').get('href')
resaTypes[resaType] = baseURL+url resa_types[resa_type] = base_url+url
return resaTypes return resa_types
# Dump reservation into a calendar file def dump_reservation(base_url: str, city: str, url: str, cookies: RequestsCookieJar,
def dumpReservation(resaType, baseURL, city, url, cookies, output): output: str) -> None:
"""
Download a citizenportal reservation calendar, convert it to iCalendar
format and write the result to *output*.
The routine performs the full extraction pipeline that the original web
interface uses:
1. **Fetch the reservation page** (``url``) and parse four hidden JavaScript
variables ``idPer``, ``idIns``, ``idLie`` and ``idClg`` that are later
required to request the calendar data.
2. **Query the calendar endpoint** ``/espace-citoyens/DemandeEnfance/
NouvelleDemandeReservationGetCalendrier`` with the extracted variables
as GET parameters.
3. **Deserialize the JSON payload** and walk through the nested structure
(weeks → days → reservation units) to build an ``ics.Calendar`` object.
4. **Derive start/end times** from the reservation code (e.g. “Matin”, “AM”,
“Repas”). Times are expressed in the *Europe/Paris* timezone.
5. **Serialise the calendar** and write it as UTF8 text to *output*.
The function follows the **failfast** philosophy used throughout the
project: any unexpected HTTP status code, missing variable, or unknown
reservation code is logged as an error and the process terminates with
``sys.exit(-1)`` (i.e. a ``SystemExit`` exception).
Parameters
----------
base_url : str
Root URL of the portal, e.g. ``"https://mycity.gov"`` (no trailing slash).
city : str
City identifier that is part of the calendar endpoints path
(e.g. ``"paris"``).
url : str
Full URL of the page that contains the JavaScript variables needed for
the calendar request.
cookies : requests.cookies.RequestsCookieJar
Cookie jar obtained from the authentication step; it must be passed
to every request to stay within the authenticated session.
output : str
Path of the file that will receive the iCalendar representation
(the file is opened in *write* mode with UTF8 encoding).
Returns
-------
None
The function writes its result to *output*; it does not return a value.
Raises
------
SystemExit
The function calls ``sys.exit(-1)`` (and logs an error) when encountering
any of the following conditions:
* HTTP GET to *url* or to the calendar endpoint returns a status code
other than **200**.
* One of the required JavaScript variables (`idPer`, `idIns`,
`idLie`, `idClg`) cannot be found in the page source.
* The JSON payload does not contain the expected keys
(`listeSemainesAffichees`, `listeUnitesInscr`, …).
* A reservation code cannot be mapped to a time slot.
Side Effects
------------
* Writes log messages (INFO, DEBUG, WARNING, ERROR) to the modules logger.
* Terminates the interpreter on fatal errors via ``sys.exit``.
* Creates and writes an iCalendar file on disk.
Example
-------
```python
# Assume the user is already authenticated and we have a cookie jar:
base = "https://mycity.gov"
city = "paris"
page = f"{base}/{city}/espace-citoyens/reservations"
cookies = authenticate(base, city, "john.doe@example.com", "s3cr3t")[1]
dump_reservation(
base_url=base,
city=city,
url=page,
cookies=cookies,
output="my_reservations.ics"
)
# → ``my_reservations.ics`` now contains a standard iCalendar file that can
# be imported into Outlook, Google Calendar, etc.
```
Notes
-----
"""
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
html = requests.get(url, cookies=cookies, allow_redirects=False) html = requests.get(url, cookies=cookies, allow_redirects=False, timeout=10)
if html.status_code != 200: if html.status_code != 200:
logger.info('Impossible to retrieve reservation page: %d' % html.status_code) logger.info('Impossible to retrieve reservation page: %d', html.status_code)
exit(-1) sys.exit(-1)
variables = ['idPer', 'idIns', 'idLie', 'idClg'] variables = ['idPer', 'idIns', 'idLie', 'idClg']
values = {} values = {}
for var in variables: for var in variables:
found = False found = False
content = StringIO(html.content.decode('utf8')) content = StringIO(html.content.decode('utf8'))
p = re.compile('^.*var %s = (?P<value>[0-9]+).*$' % var) p = re.compile(f'^.*var {var} = (?P<value>[0-9]+).*$')
for line in content.readlines(): for line in content.readlines():
m = p.match(line) m = p.match(line)
if m != None: if m is not None:
found = True found = True
value = int(m.group('value')) value = int(m.group('value'))
values[var] = value values[var] = value
break break
if not found: if not found:
logger.error('Impossible to find value for variable: %s' % var) logger.error('Impossible to find value for variable: %s', var)
exit(-1) sys.exit(-1)
else: else:
logger.debug('Found value for var %s: %d' % (var, value)) logger.debug('Found value for var %s: %d', var, value)
# This URL should be retrieved more automatically ... # This URL should be retrieved more automatically ...
calendar = requests.get(baseURL+'/%s/espace-citoyens/DemandeEnfance/NouvelleDemandeReservationGetCalendrier' % city, params=values, cookies=cookies) calendar = requests.get(f"{base_url}/{city}/\
espace-citoyens/DemandeEnfance/NouvelleDemandeReservationGetCalendrier",
params=values, cookies=cookies, timeout=10)
if calendar.status_code != 200: if calendar.status_code != 200:
logger.info('Impossible to retrieve calendar: %d' % html.status_code) logger.info('Impossible to retrieve calendar: %d', html.status_code)
exit(-1) sys.exit(-1)
calendar = json.load(StringIO(calendar.content.decode('utf8'))) calendar = json.load(StringIO(calendar.content.decode('utf8')))
weeks = getKey(calendar, 'listeSemainesAffichees') weeks = get_key(calendar, 'listeSemainesAffichees')
typeResas = getKey(calendar, 'listeUnitesInscr') type_resas = get_key(calendar, 'listeUnitesInscr')
dictResa = {} dict_resa = {}
for typeResa in typeResas: for type_resa in type_resas:
idResa = getKey(typeResa, 'idUnite') id_resa = get_key(type_resa, 'idUnite')
codeResa = getKey(typeResa, 'codeUnite') code_resa = get_key(type_resa, 'codeUnite')
descResa = getKey(typeResa, 'libUnite') desc_resa = get_key(type_resa, 'libUnite')
dictResa[idResa] = (descResa, codeResa) dict_resa[id_resa] = (desc_resa, code_resa)
cal = Calendar() cal = Calendar()
for week in weeks: for week in weeks:
numSemaine = int(getKey(week, 'numSemaine')) num_semaine = int(get_key(week, 'numSemaine'))
days = getKey(week, 'listeJoursAffiches') days = get_key(week, 'listeJoursAffiches')
for day in days: for day in days:
resas = getKey(day, 'listeUnitesJour') resas = get_key(day, 'listeUnitesJour')
date = int(getKey(day, 'idJour')) date = int(get_key(day, 'idJour'))
year = int(date/10000) year = int(date/10000)
month = int((date - year*10000) / 100) month = int((date - year*10000) / 100)
day = date - year*10000 - month*100 day = date - year*10000 - month*100
for resa in resas: for resa in resas:
checked = getKey(resa, 'nbConsoBase') != None checked = get_key(resa, 'nbConsoBase') is not None
if checked: if checked:
typeResa = getKey(resa, 'idUnite') type_resa = get_key(resa, 'idUnite')
e = Event() e = Event()
e.name = dictResa[typeResa][0] e.name = dict_resa[type_resa][0]
code = dictResa[typeResa][1] code = dict_resa[type_resa][1]
if 'Matin' in code: if 'Matin' in code:
begin = datetime(year,month,day,7,30,0, tzinfo=ZoneInfo("Europe/Paris")) begin = datetime(year,month,day,7,30,0, tzinfo=ZoneInfo("Europe/Paris"))
end = datetime(year,month,day,12,0,0, tzinfo=ZoneInfo("Europe/Paris")) end = datetime(year,month,day,12,0,0, tzinfo=ZoneInfo("Europe/Paris"))
@@ -185,99 +530,208 @@ def dumpReservation(resaType, baseURL, city, url, cookies, output):
begin = datetime(year,month,day,12,00,0, tzinfo=ZoneInfo("Europe/Paris")) begin = datetime(year,month,day,12,00,0, tzinfo=ZoneInfo("Europe/Paris"))
end = datetime(year,month,day,13,30,0, tzinfo=ZoneInfo("Europe/Paris")) end = datetime(year,month,day,13,30,0, tzinfo=ZoneInfo("Europe/Paris"))
else: else:
logger.warning('Impossible to determine the type of reservation: %s' % code) logger.warning('Impossible to determine the type of reservation: %s', code)
begin = datetime(year,month,day) begin = datetime(year,month,day)
end = datetime(year,month,day) end = datetime(year,month,day)
e.begin = begin e.begin = begin
e.end = end e.end = end
cal.events.add(e) cal.events.add(e)
with open(output, 'w') as f: with open(output, 'w', encoding="utf-8") as f:
f.writelines(cal.serialize_iter()) f.writelines(cal.serialize_iter())
def main(): def main():
"""
Commandline entry point that drives the whole *espacecitoyens* reservation
workflow.
The script can be used in three modes, selected through subcommands:
* ``A`` Test the authentication step only.
* ``L`` List every available reservation type (e.g. “Sports”, “Culture”).
* ``D`` Export the reservations of a specific type to an iCalendar file.
The function parses commandline arguments, optionally merges them with a
configuration file, performs authentication, and then dispatches to the
appropriate helper functions (`authenticate`, `get_reservations_kind`,
`dump_reservation`). All errors are logged and cause the process to exit
with ``sys.exit(-1)`` (i.e. a nonzero status code).
No explicit parameters are passed to the function it reads ``sys.argv``
directly via ``argparse`` and it does not return a value; the program
terminates either normally (``sys.exit(0)``) or with an error code.
Workflow summary
----------------
1. **Argument parsing** Handles login, city, optional config file,
password, debug flag and the three subcommands. The ``-d/--debug``
switch raises the logging level to ``DEBUG``.
2. **Configuration file** If ``--config`` is supplied, the file is read
with ``configparser``. Settings in the ``[Login]`` and ``[Espace]``
sections override the corresponding commandline options.
3. **Mandatory values** ``city`` and ``login`` must finally be known;
otherwise the script prints the help text and exits with an error.
4. **Password handling** If no password is provided via CLI or config,
the user is prompted securely with ``getpass.getpass()``.
5. **Authentication** Calls :func:`authenticate` to obtain the main page
URL and the session cookies.
6. **Command dispatch**
* ``A`` (or no subcommand) → exit after a successful login.
* ``L`` → list reservation types (printed to stdout).
* ``D`` → export the reservations of the selected type:
* validates the supplied index,
* retrieves the URL for the chosen reservation kind,
* calls :func:`dump_reservation` to create the ``.ics`` file.
Parameters
----------
None (uses ``sys.argv``).
Returns
-------
None. The function terminates the process with ``sys.exit``.
Raises
------
SystemExit
* Invalid commandline usage (handled by ``argparse``).
* Failure to read or parse the optional configuration file.
* Missing required arguments (city or login).
* Authentication failure (non200 response from the portal).
* Invalid reservation index for the ``D`` subcommand.
* Any other unrecoverable error reported by the helper functions.
Side effects
------------
* Configures coloured logging via ``coloredlogs``.
* Writes log messages (INFO, DEBUG, WARNING, ERROR) to the module logger.
* May prompt the user for a password on the terminal.
* Reads a configuration file if supplied.
* Performs HTTP requests to the remote portal.
* Writes an iCalendar file to disk when the ``D`` command is used.
Example usage
-------------
```bash
# 1. Test authentication only
$ python reservation_cli.py -l john.doe@example.com -C paris A
# 2. List all reservation categories
$ python reservation_cli.py -l john -C lyon L
1 - Sport
2 - Culture
3 - Bibliothèque
# 3. Export the second reservation type to a calendar file
$ python reservation_cli.py -l john -C lyon D -i 2 -o my_reservations.ics
```
Notes
-----
* The script relies on the thirdparty libraries **coloredlogs**, **argparse**,
**configparser**, **requests**, **BeautifulSoup**, **ics**, and the standard
**logging**, **getpass**, **sys**, **json**, **datetime**, and **zoneinfo**
modules.
* All helper functions (`authenticate`, `get_reservations_kind`,
`dump_reservation`) follow a *failfast* approach: they log an error and
invoke ``sys.exit(-1)`` on any abnormal condition. Consequently,
``main()`` does not need to catch exceptions from them.
* The entrypoint guard ``if __name__ == "__main__":`` ensures the script
runs only when executed directly, not when imported as a module.
"""
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
coloredlogs.install() coloredlogs.install()
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("-l", "--login", dest='login', type=str, required=False, help="Login") parser.add_argument("-l", "--login", dest='login', type=str, required=False, help="Login")
parser.add_argument("-C", "--city", dest='city', type=str, required=False, help="City.") parser.add_argument("-C", "--city", dest='city', type=str, required=False, help="City.")
parser.add_argument("-c", "--config", dest='configFileName', required=False, default=None, help="Configuration file.") parser.add_argument("-c", "--config", dest='config_filename', required=False, default=None,
parser.add_argument("-p", "--password", dest='password', nargs='?', required=False, default=None, help="Password.") help="Configuration file.")
parser.add_argument("-d", "--debug", dest='debug', action='store_true', required=False, help="Activate debug.") parser.add_argument("-p", "--password", dest='password', nargs='?', required=False,
default=None, help="Password.")
parser.add_argument("-d", "--debug", dest='debug', action='store_true', required=False,
help="Activate debug.")
subparsers = parser.add_subparsers(title='subcommands', dest='command', help='subcommand help') subparsers = parser.add_subparsers(title='subcommands', dest='command', help='subcommand help')
parserauth = subparsers.add_parser('A', help='Test authentication.') subparsers.add_parser('A', help='Test authentication.')
parserlist = subparsers.add_parser('L', help='List all possible reservations types.') subparsers.add_parser('L', help='List all possible reservations types.')
parserdump = subparsers.add_parser('D', help='Dump all reservation of some kind') parserdump = subparsers.add_parser('D', help='Dump all reservation of some kind')
parserdump.add_argument("-i", "--index", dest='index', type=int, required=True, help="Index of reservations to dump.") parserdump.add_argument("-i", "--index", dest='index', type=int, required=True,
parserdump.add_argument("-o", "--output", dest='calendar', required=True, default='cal.ics', help="Output calendar file.") help="Index of reservations to dump.")
parserdump.add_argument("-o", "--output", dest='calendar', required=True, default='cal.ics',
help="Output calendar file.")
args = parser.parse_args() args = parser.parse_args()
logger.debug("Initial arguments: %s" % args) logger.debug("Initial arguments: %s", args)
if args.debug: if args.debug:
logger.info('Setting logging to debug mode') logger.info('Setting logging to debug mode')
coloredlogs.set_level(level=logging.DEBUG) coloredlogs.set_level(level=logging.DEBUG)
if args.configFileName != None: if args.config_filename is not None:
try: try:
configFile = open(args.configFileName, 'r') with open(args.config_filename, 'r', encoding="utf-8") as config_file:
except Exception as e: config = configparser.ConfigParser()
logger.info('Impossible to open configuration file. Error: %s' % e) config.read_file(config_file)
exit(-1)
config = configparser.ConfigParser() sections = config.sections()
config.read_file(configFile)
sections = config.sections() if 'Login' in sections:
options = config.options('Login')
if 'login' in options:
args.login = config.get('Login','login')
if 'password' in options:
args.password = config.get('Login','password')
if 'Espace' in sections:
options = config.options('Espace')
if 'ville' in options:
args.city = config.get('Espace', 'ville')
except IOError as e:
logger.error('Impossible to open configuration file. Error: %s', e)
sys.exit(-1)
if 'Login' in sections: logger.debug("Final arguments: %s", args)
options = config.options('Login')
if 'login' in options:
args.login = config.get('Login','login')
if 'password' in options:
args.password = config.get('Login','password')
if 'Espace' in sections:
options = config.options('Espace')
if 'ville' in options:
args.city = config.get('Espace', 'ville')
logger.debug("Final arguments: %s" % args) if args.city is None:
if args.city == None:
logger.error('City must be provided.') logger.error('City must be provided.')
parser.print_help() parser.print_help()
exit(-1) sys.exit(-1)
if args.login == None: if args.login is None:
logger.error('Login must be provided.') logger.error('Login must be provided.')
parser.print_help() parser.print_help()
exit(-1) sys.exit(-1)
if args.password == None: if args.password is None:
args.password = getpass.getpass() args.password = getpass.getpass()
baseURL = 'https://www.espace-citoyens.net' base_url = 'https://www.espace-citoyens.net'
# Authentication # Authentication
mainpage, cookies = authenticate(baseURL, args.city, args.login, args.password) mainpage, cookies = authenticate(base_url, args.city, args.login, args.password)
if (args.command == None) or (args.command == 'A'): if (args.command is None) or (args.command == 'A'):
exit(0) sys.exit(0)
# Switch between commands # Switch between commands
logger.info('Retrieve reservation kinds') logger.info('Retrieve reservation kinds')
resaTypes = getReservationsKind(baseURL, mainpage, cookies) resa_types = get_reservations_kind(base_url, mainpage, cookies)
if (args.command == 'L'): if args.command == 'L':
resaNum = 1 resa_num = 1
for resaType in resaTypes.keys(): for resa_type in resa_types:
print("%d - %s" % (resaNum, resaType)) print(f"{resa_num:d} - {resa_type}")
resaNum+=1 resa_num+=1
elif (args.command == 'D'): elif args.command == 'D':
resaType = list(resaTypes)[args.index-1] if args.index < 0:
logger.info('Retrieve reservations for "%s"' % resaType) logger.error('Index can not be negative')
url = resaTypes[resaType] sys.exit(-1)
dumpReservation(resaType, baseURL, args.city, url, cookies, args.calendar) if args.index > len(resa_types):
logger.error('Index is larger than number of reservations: %d', len(resa_types))
sys.exit(-1)
resa_type = list(resa_types)[args.index-1]
logger.info('Retrieve reservations for "%s"', resa_type)
url = resa_types[resa_type]
dump_reservation(base_url, args.city, url, cookies, args.calendar)
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+2
View File
@@ -2,3 +2,5 @@ requests
coloredlogs coloredlogs
bs4 bs4
ics ics
typeguard