From 6ce9a2cba50ac56abcbfb55df321b0bcbf7b1922 Mon Sep 17 00:00:00 2001 From: rmanach Date: Tue, 17 Dec 2024 16:41:46 +0100 Subject: [PATCH 1/9] add session api endpoints --- .env.example | 5 + main.py | 72 ++++++++++- src/__init__.py | 4 + src/client.py | 321 ++++++++++++++++++++++++++++++++++++++++++++++ src/exceptions.py | 21 +++ 5 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 src/client.py create mode 100644 src/exceptions.py diff --git a/.env.example b/.env.example index e69de29..b37f586 100644 --- a/.env.example +++ b/.env.example @@ -0,0 +1,5 @@ +WHEREIS_API_BASE_URL=https://api-whereis.thegux.fr +WHEREIS_API_EMAIL= +WHEREIS_API_PASSWORD= + +WHEREIS_CERT_VERIFY=true \ No newline at end of file diff --git a/main.py b/main.py index 7040273..60465d4 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,9 @@ import logging import sys +import json -from src import VERSION +from src import VERSION, Client, OrderField +from src.exceptions import WhereIsException, UnauthorizedException stdout_handler = logging.StreamHandler(stream=sys.stdout) @@ -12,5 +14,73 @@ logging.basicConfig( ) if __name__ == "__main__": + """ + This a sample script to demonstrate how works WhereIs API client. + + All you need is to provide some environments variables describe in the `.env.example`. + You can: + - Copy `.env.example` into `.env` and feed env variables or, + - Directly export those mandatories variables + + Once its done, you can initialize the Client: + + cli = Client.from_env() + + and use all the available methods to interact with the WhereIs API. + Sessions: + - create_session + - get_sessions + - get_session + - update_session + - delete_session + """ logging.info(f"WhereIs client v{VERSION}") + # initialize the client + try: + cli = Client.from_env() + except WhereIsException as e: + logging.error(f"unable to initialize WhereIs API client: {e}") + except Exception as e: + logging.error("unexpected error occurred while initializing client", exc_info=True) + + # retrieve all user/public sessions + sessions = cli.get_sessions() + print(json.dumps(sessions, indent=2)) + + # create and update a session (must have `Streamer` role) + try: + session = cli.create_session(name="RUN-01") + session = cli.update_session(session.get("id"), name="RUN-02", description="My second run") + except WhereIsException as e: + logging.error(f"error occurred while creating/updating a session, status code: {e.error_code}") + print(json.dumps(e.content, indent=2)) + + # retrieve all user/public sessions with ordering + sessions = cli.get_sessions(ordering=OrderField.AscDateStart) + print(json.dumps(sessions, indent=2)) + + # retrieve all user/public sessions with search + sessions = cli.get_sessions(search="run", ordering=OrderField.AscDateStart) + print(json.dumps(sessions, indent=2)) + + # retrieve session by id + session = cli.get_session(session.get("id")) + print(json.dumps(session, indent=2)) + + # update session users + try: + session = cli.update_session_users(session.get("id"), []) + except WhereIsException as e: + logging.error(f"error occurred while updating users session, status code: {e.error_code}") + print(json.dumps(e.content, indent=2)) + + # close a session + try: + session = cli.close_session("fqsfsdqf") + except WhereIsException as e: + logging.error(f"error occurred while updating users session, status code: {e.error_code}") + print(json.dumps(e.content, indent=2)) + + # delete a session + cli.delete_session(session.get("id")) diff --git a/src/__init__.py b/src/__init__.py index 1cf6267..d714875 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1 +1,5 @@ +from .client import Client, OrderField + +__all__ = ["Client", "OrderField"] + VERSION = "0.1.0" diff --git a/src/client.py b/src/client.py new file mode 100644 index 0000000..b4ca923 --- /dev/null +++ b/src/client.py @@ -0,0 +1,321 @@ +import json +import logging +import os +from dataclasses import dataclass, field +from enum import Enum +from typing import Any +from urllib.parse import urljoin +from uuid import UUID + +import urllib3 +from dotenv import dotenv_values +from requests import Session + +from .exceptions import UnauthorizedException, WhereIsException + +API_DEFAULT_URL = "https://api-whereis.thegux.fr" + +__all__ = ["Client", "OrderField"] + + +def refresh(): + """ + Catch 401 status code (UnauthorizedException) + and refresh the access token and retry. + """ + + def decorator(func): + def wrapper(*args, **kwargs): + if len(args) > 0 and isinstance(args[0], Client): + try: + return func(*args, **kwargs) + except UnauthorizedException: + logging.warning("refresh access token...") + args[0]._refresh() + return func(*args, **kwargs) + + return wrapper + + return decorator + + +class OrderField(Enum): + """ + Ordering query param available fields. + + - AscDateStart: date start ascending order + - DesDateStart: date start descending order + - AscDateEnd: date end ascending order + - DesDateEnd: date end descending order + """ + + AscDateStart = "date_start" + DesDateStart = "-date_start" + AscDateEnd = "date_end" + DesDateEnd = "-date_end" + + +@dataclass(slots=True) +class Client: + """ + WhereIs API Client main class. + + Example: + -------- + cli = Client.from_env() + sessions = cli.get_sessions() + """ + + base_url: str + email: str + password: str + + session: Session = field(init=False) + + def _login(self) -> WhereIsException | None: + """Get the access token and store it in the `Session` header""" + data = { + "email": self.email, + "password": self.password, + } + + login_url = urljoin(self.base_url, "/auth/token/") + logging.info(f"login: {login_url}") + + res = self.session.post(login_url, json=data) + if res.status_code >= 400: + raise WhereIsException(login_url, res) + + access_token = res.json()["access"] + self.session.headers.update({"Authorization": f"Bearer {access_token}"}) + + return None + + def _refresh(self) -> WhereIsException | None: + """Refresh the access token and store it in the `Session` header""" + refresh_url = urljoin(self.base_url, "/auth/refresh/") + logging.info(f"refresh: {refresh_url}") + + res = self.session.post(refresh_url) + if res.status_code >= 400: + raise WhereIsException(refresh_url, res) + + access_token = res.json()["access"] + self.session.headers.update({"Authorization": f"Bearer {access_token}"}) + + return None + + @classmethod + def from_env(cls) -> "Client": + """ + Initialize the client from env variables (.env & global) and + log in into the application. + + If the login fails, and exception is raised. + """ + env_data = { + "WHEREIS_API_EMAIL": os.getenv("WHEREIS_API_EMAIL", ""), + "WHEREIS_API_PASSWORD": os.getenv("WHEREIS_API_PASSWORD", ""), + "WHEREIS_API_BASE_URL": os.getenv("WHEREIS_API_BASE_URL", API_DEFAULT_URL), + } + dotenv_data = dotenv_values() + env_data.update(dotenv_data) # type: ignore + + cli = Client( + env_data.get("WHEREIS_API_BASE_URL", ""), + env_data.get("WHEREIS_API_EMAIL", ""), + env_data.get("WHEREIS_API_PASSWORD", ""), + ) + cli.session = Session() + cli.session.headers.update({"content-type": "application/json"}) + + is_cert_verify = env_data.get("WHEREIS_CERT_VERIFY", "") != "false" + if not is_cert_verify: + urllib3.disable_warnings() + + cli.session.verify = is_cert_verify + + cli._login() + + logging.info(f"client successfully initialized for session: {cli.email}") + return cli + + @refresh() + def _get_sessions_page(self, url: str) -> dict[str, Any] | WhereIsException: + """Get paginate sessions.""" + res = self.session.get(url) + if res.status_code == 401: + raise UnauthorizedException() + + if res.status_code >= 400: + print(json.dumps(res.json(), indent=2)) + if res.status_code >= 400: + raise WhereIsException(url, res) + + return res.json() + + def get_sessions( + self, search: str | None = None, ordering: OrderField | None = None + ) -> list[dict[str, Any]] | WhereIsException: + """ + Get all user and public sessions. + If an error occurred duning the API call an exception is raised. + + Params: + ------- + search: str, search sessions over username and description + ordering: OrderField, ordering sessions by dates + """ + sessions_url = urljoin(self.base_url, "/sessions/") + + has_query_param = False + if search is not None: + if not has_query_param: + sessions_url += "?" + has_query_param = True + sessions_url += f"search={search}" + + if ordering is not None: + if not has_query_param: + sessions_url += "?" + else: + sessions_url += "&" + sessions_url += f"ordering={ordering.value}" + + logging.info(f"get sessions: {sessions_url}") + + lst_sessions = [] + while sessions_url is not None: + data = self._get_sessions_page(sessions_url) + + sessions_url = data.get("next") + lst_sessions.extend(data.get("results", [])) + + return lst_sessions + + @refresh() + def get_session(self, id_: UUID) -> list[dict[str, Any]] | WhereIsException: + session_url = urljoin(self.base_url, f"/sessions/{id_}/") + logging.info(f"get session: {session_url}") + + res = self.session.get(session_url) + if res.status_code == 401: + raise UnauthorizedException() + + if res.status_code >= 400: + raise WhereIsException(session_url, res) + + return res.json() + + @refresh() + def create_session( + self, name: str, description: str | None = None, is_public: bool = False + ) -> dict[str, Any] | WhereIsException: + sessions_url = urljoin(self.base_url, "/sessions/") + logging.info(f"create session: {sessions_url}") + + data = {"name": name, "description": description, "is_public": is_public} + if name is None: + data.pop("name") + if description is None: + data.pop("description") + if is_public is None: + data.pop("is_public") + + res = self.session.post( + sessions_url, + json=data, + ) + + if res.status_code == 401: + raise UnauthorizedException() + + if res.status_code >= 400: + raise WhereIsException(sessions_url, res) + + return res.json() + + @refresh() + def update_session( + self, + id_: UUID, + name: str | None = None, + description: str | None = None, + is_public: bool | None = None, + ) -> dict[str, Any] | WhereIsException: + session_url = urljoin(self.base_url, f"/sessions/{id_}/") + logging.info(f"update session: {session_url}") + + data = {"name": name, "description": description, "is_public": is_public} + if name is None: + data.pop("name") + if description is None: + data.pop("description") + if is_public is None: + data.pop("is_public") + + res = self.session.patch(session_url, json=data) + + if res.status_code == 401: + raise UnauthorizedException() + + if res.status_code >= 400: + raise WhereIsException(session_url, res) + + return res.json() + + @refresh() + def delete_session(self, id_: UUID) -> None | WhereIsException: + """ + Close and delete the session. Users can't be added anymore. + NOTE: The GPS positions associated to the session are not deleted ! + """ + session_url = urljoin(self.base_url, f"/sessions/{id_}/") + logging.info(f"delete session: {session_url}") + + res = self.session.delete(session_url) + + if res.status_code == 401: + raise UnauthorizedException() + + if res.status_code >= 400: + raise WhereIsException(session_url, res) + + return None + + @refresh() + def update_session_users( + self, id_: UUID, users: list[UUID] + ) -> dict[str, Any] | WhereIsException: + """ + Update users sessions. + + WARN: An empty users list parameter cleans all users. + """ + session_url = urljoin(self.base_url, f"/sessions/{id_}/users/") + logging.info(f"update session users: {session_url}") + + res = self.session.post(session_url, json={"users": users}) + + if res.status_code == 401: + raise UnauthorizedException() + + if res.status_code >= 400: + raise WhereIsException(session_url, res) + + return res.json() + + @refresh() + def close_session(self, id_: UUID) -> dict[str, Any] | WhereIsException: + session_url = urljoin(self.base_url, f"/sessions/{id_}/close/") + logging.info(f"close session: {session_url}") + + res = self.session.post(session_url) + + if res.status_code == 401: + raise UnauthorizedException() + + if res.status_code >= 400: + raise WhereIsException(session_url, res) + + return res.json() diff --git a/src/exceptions.py b/src/exceptions.py new file mode 100644 index 0000000..0f5a520 --- /dev/null +++ b/src/exceptions.py @@ -0,0 +1,21 @@ +from requests import Response + + +class WhereIsException(Exception): + """Handle all WhereIs API errors.""" + + def __init__(self, url: str, response: Response): + self.url = url + try: + self.content = response.json() + except Exception: + self.content = response.content.decode() + super().__init__(self.content) + self.error_code = response.status_code + + def __str__(self): + return f"error calling: {self.url} - {self.error_code} - {self.content}" + + +class UnauthorizedException(Exception): + pass From 557a544d4776b0c144a43be67873d9c6ce7c03c4 Mon Sep 17 00:00:00 2001 From: rmanach Date: Wed, 18 Dec 2024 09:39:16 +0100 Subject: [PATCH 2/9] add session events watcher --- main.py | 19 ++++++- requirements.txt | 3 +- src/client.py | 126 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 142 insertions(+), 6 deletions(-) diff --git a/main.py b/main.py index 60465d4..17e67d3 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,7 @@ import logging import sys import json +import time from src import VERSION, Client, OrderField from src.exceptions import WhereIsException, UnauthorizedException @@ -33,6 +34,10 @@ if __name__ == "__main__": - get_session - update_session - delete_session + - update_session_users + - close_session + - watch_session_events + - stop_watch_session """ logging.info(f"WhereIs client v{VERSION}") @@ -77,10 +82,22 @@ if __name__ == "__main__": # close a session try: - session = cli.close_session("fqsfsdqf") + session = cli.close_session("does-not-exist") except WhereIsException as e: logging.error(f"error occurred while updating users session, status code: {e.error_code}") print(json.dumps(e.content, indent=2)) + # get session events + cli.watch_session_events(session.get("id")) + + # doing your stuff... + time.sleep(5) + + # close the session + session = cli.close_session(session.get("id")) + # delete a session cli.delete_session(session.get("id")) + + # stop session events watcher + cli.stop_watch_session(session.get("id")) diff --git a/requirements.txt b/requirements.txt index 1786051..b67a478 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ requests==2.32.3 -python-dotenv==1.0.1 \ No newline at end of file +python-dotenv==1.0.1 +sseclient-py==1.8.0 \ No newline at end of file diff --git a/src/client.py b/src/client.py index b4ca923..4f11b89 100644 --- a/src/client.py +++ b/src/client.py @@ -1,15 +1,17 @@ -import json import logging import os from dataclasses import dataclass, field from enum import Enum -from typing import Any +from threading import Event, Thread +from typing import Any, Callable from urllib.parse import urljoin from uuid import UUID +import requests import urllib3 from dotenv import dotenv_values from requests import Session +from sseclient import SSEClient from .exceptions import UnauthorizedException, WhereIsException @@ -55,6 +57,66 @@ class OrderField(Enum): DesDateEnd = "-date_end" +@dataclass(frozen=True, slots=True) +class SessionWatcher: + """ + Handle the SSE client connection in a daemon thread. + + NOTE: Should not be instanciated directly, use + the `Client` instead. + """ + + session_id: UUID + _thread: Thread + _client: SSEClient + _event: Event + + @classmethod + def from_session_id( + cls, + base_url: str, + id_: UUID, + headers: dict[str, Any], + callback: Callable[[str], None] | None = None, + verify: bool = True, + ) -> "SessionWatcher": + session_url = urljoin(base_url, f"/sessions/{id_}/events/") + + headers = {**headers, "Accept": "text/event-stream"} + resp = requests.get(session_url, stream=True, headers=headers, verify=verify) + client = SSEClient(resp) # type: ignore + event = Event() + + def _job(): + logging.debug(f"SSE client daemon started for session: {id_}") + + for evt in client.events(): + logging.debug(f"event received for session: {id_}") + if callback is not None: + callback(evt.data) + + if event.is_set(): + logging.debug(f"SSE client daemon stopped for session: {id_}") + break + + t = Thread(target=_job, daemon=True) + t.start() + + return SessionWatcher(id_, t, client, event) + + def stop(self, force: bool = False): + """ + Send an event to stop the events stream and wait + for the thread to finish. + + If you want to stop the stream savagely, you can use + the optional arg: `force`. + """ + self._event.set() + self._thread.join(timeout=0 if force else None) + logging.debug(f"SSE stream client for session: {self.session_id} closed") + + @dataclass(slots=True) class Client: """ @@ -72,6 +134,10 @@ class Client: session: Session = field(init=False) + sessions_watcher: dict[UUID, SessionWatcher] = field( + default_factory=dict, init=False + ) + def _login(self) -> WhereIsException | None: """Get the access token and store it in the `Session` header""" data = { @@ -137,7 +203,7 @@ class Client: cli._login() - logging.info(f"client successfully initialized for session: {cli.email}") + logging.info(f"client successfully initialized for user: {cli.email}") return cli @refresh() @@ -148,7 +214,6 @@ class Client: raise UnauthorizedException() if res.status_code >= 400: - print(json.dumps(res.json(), indent=2)) if res.status_code >= 400: raise WhereIsException(url, res) @@ -319,3 +384,56 @@ class Client: raise WhereIsException(session_url, res) return res.json() + + def watch_session_events( + self, id_: UUID, callback: Callable[[str], None] | None = None + ): + """ + Watch session events through an SSE client. + + It will launch a daemon thread, listening for incoming events. + You can use the `callback` optional argument to pass a callable + to deal with the events. + + Example: + -------- + def treat_events(evt: str): + # your instructions + print(evt) + + cli.watch_session_events("session-id", treat_events) + + NOTE: You have to manually manage the connection error (IO, Authentication, etc...) + For authentication error, you'll receive this kind of event: + { + "condition": "forbidden", + "text": "Permission denied to channels: session_session-id", + "channels": ["session_session-id"] + } + """ # noqa + if self.sessions_watcher.get(id_) is not None: + logging.warning(f"you're already watching session events: {id_}") + return + + sw = SessionWatcher.from_session_id( + self.base_url, + id_, + self.session.headers, # type: ignore + callback, + self.session.verify, # type: ignore + ) + + logging.info(f"session events (id: {id_}) watcher started") + self.sessions_watcher[id_] = sw + + def stop_watch_session(self, id_: UUID, force: bool = False): + """ + Stop watching events for a session. + + Use `force` optional argument to kill the watcher + instead of waiting for a graceful stop. + """ + if (sw := self.sessions_watcher.get(id_)) is not None: + sw.stop(force) + del self.sessions_watcher[id_] + logging.info(f"session events (id: {id_}) watcher stopped") From a99621c42b2e4969ef0c8334a62ac1cf5a332fc6 Mon Sep 17 00:00:00 2001 From: rmanach Date: Wed, 18 Dec 2024 10:22:28 +0100 Subject: [PATCH 3/9] add ws token api --- main.py | 29 +++++++++++++++++++++++---- src/client.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++ src/exceptions.py | 2 ++ 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index 17e67d3..d59fb40 100644 --- a/main.py +++ b/main.py @@ -16,7 +16,7 @@ logging.basicConfig( if __name__ == "__main__": """ - This a sample script to demonstrate how works WhereIs API client. + This a sample script to demonstrate how to deal with WhereIs API client. All you need is to provide some environments variables describe in the `.env.example`. You can: @@ -28,6 +28,7 @@ if __name__ == "__main__": cli = Client.from_env() and use all the available methods to interact with the WhereIs API. + Sessions: - create_session - get_sessions @@ -38,6 +39,10 @@ if __name__ == "__main__": - close_session - watch_session_events - stop_watch_session + WS token: + - get_wstokens + - create_wstoken + - delete_wstoken """ logging.info(f"WhereIs client v{VERSION}") @@ -49,6 +54,22 @@ if __name__ == "__main__": except Exception as e: logging.error("unexpected error occurred while initializing client", exc_info=True) + # get ws tokens + tokens = cli.get_wstokens() + print(json.dumps(tokens, indent=2)) + + # delete ws token + if len(tokens) > 0: + token = cli.delete_wstoken(tokens[0].get("id")) + print(json.dumps(tokens, indent=2)) + + # create ws token + try: + token = cli.create_wstoken() + except WhereIsException as e: + logging.error(f"error occurred while creating a ws token, status code: {e.error_code}") + print(json.dumps(e.content, indent=2)) + # retrieve all user/public sessions sessions = cli.get_sessions() print(json.dumps(sessions, indent=2)) @@ -84,14 +105,14 @@ if __name__ == "__main__": try: session = cli.close_session("does-not-exist") except WhereIsException as e: - logging.error(f"error occurred while updating users session, status code: {e.error_code}") + logging.error(f"error occurred while closing session, status code: {e.error_code}") print(json.dumps(e.content, indent=2)) # get session events cli.watch_session_events(session.get("id")) # doing your stuff... - time.sleep(5) + time.sleep(2) # close the session session = cli.close_session(session.get("id")) @@ -100,4 +121,4 @@ if __name__ == "__main__": cli.delete_session(session.get("id")) # stop session events watcher - cli.stop_watch_session(session.get("id")) + cli.stop_watch_session(session.get("id"), force=True) diff --git a/src/client.py b/src/client.py index 4f11b89..5434fed 100644 --- a/src/client.py +++ b/src/client.py @@ -437,3 +437,54 @@ class Client: sw.stop(force) del self.sessions_watcher[id_] logging.info(f"session events (id: {id_}) watcher stopped") + + @refresh() + def get_wstokens(self) -> list[dict[str, Any]] | WhereIsException: + wstoken_url = urljoin(self.base_url, "/auth/ws-token/") + logging.info(f"get ws token: {wstoken_url}") + + res = self.session.get(wstoken_url) + + if res.status_code == 401: + raise UnauthorizedException() + + if res.status_code >= 400: + raise WhereIsException(wstoken_url, res) + + return res.json() + + @refresh() + def create_wstoken(self) -> dict[str, Any] | WhereIsException: + """ + Create a websocket JWT to authenticate your real time connection. + + NOTE: only one, and only one ws token per user is allowed. + If it expired, delete it and create a new one. + """ + wstoken_url = urljoin(self.base_url, "/auth/ws-token/") + logging.info(f"create ws token: {wstoken_url}") + + res = self.session.post(wstoken_url) + + if res.status_code == 401: + raise UnauthorizedException() + + if res.status_code >= 400: + raise WhereIsException(wstoken_url, res) + + return res.json() + + @refresh() + def delete_wstoken(self, id_: UUID) -> None | WhereIsException: + wstoken_url = urljoin(self.base_url, f"/auth/ws-token/{id_}/") + logging.info(f"delete ws token: {wstoken_url}") + + res = self.session.delete(wstoken_url) + + if res.status_code == 401: + raise UnauthorizedException() + + if res.status_code >= 400: + raise WhereIsException(wstoken_url, res) + + return None diff --git a/src/exceptions.py b/src/exceptions.py index 0f5a520..61af4c9 100644 --- a/src/exceptions.py +++ b/src/exceptions.py @@ -1,5 +1,7 @@ from requests import Response +__all__ = ["WhereIsException"] + class WhereIsException(Exception): """Handle all WhereIs API errors.""" From 0e482fcbc45b4d9764d97e41471a52400e7f485f Mon Sep 17 00:00:00 2001 From: rmanach Date: Wed, 18 Dec 2024 11:30:31 +0100 Subject: [PATCH 4/9] add gps positions get endpoint --- main.py | 9 +++-- pyproject.toml | 4 ++- src/__init__.py | 2 +- src/client.py | 92 +++++++++++++++++++++++++++++++++++++---------- src/exceptions.py | 4 +-- 5 files changed, 86 insertions(+), 25 deletions(-) diff --git a/main.py b/main.py index d59fb40..5da3376 100644 --- a/main.py +++ b/main.py @@ -28,7 +28,7 @@ if __name__ == "__main__": cli = Client.from_env() and use all the available methods to interact with the WhereIs API. - + Sessions: - create_session - get_sessions @@ -43,6 +43,8 @@ if __name__ == "__main__": - get_wstokens - create_wstoken - delete_wstoken + GPS positions: + - get_gps_positions """ logging.info(f"WhereIs client v{VERSION}") @@ -111,8 +113,9 @@ if __name__ == "__main__": # get session events cli.watch_session_events(session.get("id")) - # doing your stuff... - time.sleep(2) + # get gps positions from "2024-12-25T23:00:00" to infinity... + gps_positions = cli.get_gps_positions(date_start="2024-12-25T23:00:00") + print(json.dumps(gps_positions, indent=2)) # close the session session = cli.close_session(session.get("id")) diff --git a/pyproject.toml b/pyproject.toml index 8b96326..fe053d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,8 @@ dynamic = ["version"] description = "WhereIs API client library" dependencies = [ "requests==2.32.3", + "python-dotenv==1.0.1", + "sseclient-py==1.8.0", ] [tool.hatch.version] @@ -20,7 +22,7 @@ packages = ["src"] only-include = ["src"] [tool.hatch.build.targets.wheel.sources] -"src" = "whereis-client" +"src" = "whereis_client" [tool.ruff] select = ["E", "F", "I"] diff --git a/src/__init__.py b/src/__init__.py index d714875..6160875 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -2,4 +2,4 @@ from .client import Client, OrderField __all__ = ["Client", "OrderField"] -VERSION = "0.1.0" +VERSION = "0.1.0a0" diff --git a/src/client.py b/src/client.py index 5434fed..d333cfe 100644 --- a/src/client.py +++ b/src/client.py @@ -1,6 +1,7 @@ import logging import os from dataclasses import dataclass, field +from datetime import datetime as dt from enum import Enum from threading import Event, Thread from typing import Any, Callable @@ -29,12 +30,17 @@ def refresh(): def decorator(func): def wrapper(*args, **kwargs): if len(args) > 0 and isinstance(args[0], Client): - try: - return func(*args, **kwargs) - except UnauthorizedException: - logging.warning("refresh access token...") - args[0]._refresh() - return func(*args, **kwargs) + for i in range(2): + try: + return func(*args, **kwargs) + except UnauthorizedException as e: + if i == 1: # second attempt + logging.error( + "second call attempt failed after refreshing token" + ) + raise e + logging.warning("refresh access token...") + args[0]._refresh() return wrapper @@ -211,11 +217,10 @@ class Client: """Get paginate sessions.""" res = self.session.get(url) if res.status_code == 401: - raise UnauthorizedException() + raise UnauthorizedException(url, res) if res.status_code >= 400: - if res.status_code >= 400: - raise WhereIsException(url, res) + raise WhereIsException(url, res) return res.json() @@ -265,7 +270,7 @@ class Client: res = self.session.get(session_url) if res.status_code == 401: - raise UnauthorizedException() + raise UnauthorizedException(session_url, res) if res.status_code >= 400: raise WhereIsException(session_url, res) @@ -293,7 +298,7 @@ class Client: ) if res.status_code == 401: - raise UnauthorizedException() + raise UnauthorizedException(sessions_url, res) if res.status_code >= 400: raise WhereIsException(sessions_url, res) @@ -322,7 +327,7 @@ class Client: res = self.session.patch(session_url, json=data) if res.status_code == 401: - raise UnauthorizedException() + raise UnauthorizedException(session_url, res) if res.status_code >= 400: raise WhereIsException(session_url, res) @@ -341,7 +346,7 @@ class Client: res = self.session.delete(session_url) if res.status_code == 401: - raise UnauthorizedException() + raise UnauthorizedException(session_url, res) if res.status_code >= 400: raise WhereIsException(session_url, res) @@ -363,7 +368,7 @@ class Client: res = self.session.post(session_url, json={"users": users}) if res.status_code == 401: - raise UnauthorizedException() + raise UnauthorizedException(session_url, res) if res.status_code >= 400: raise WhereIsException(session_url, res) @@ -378,7 +383,7 @@ class Client: res = self.session.post(session_url) if res.status_code == 401: - raise UnauthorizedException() + raise UnauthorizedException(session_url, res) if res.status_code >= 400: raise WhereIsException(session_url, res) @@ -446,7 +451,7 @@ class Client: res = self.session.get(wstoken_url) if res.status_code == 401: - raise UnauthorizedException() + raise UnauthorizedException(wstoken_url, res) if res.status_code >= 400: raise WhereIsException(wstoken_url, res) @@ -467,7 +472,7 @@ class Client: res = self.session.post(wstoken_url) if res.status_code == 401: - raise UnauthorizedException() + raise UnauthorizedException(wstoken_url, res) if res.status_code >= 400: raise WhereIsException(wstoken_url, res) @@ -482,9 +487,60 @@ class Client: res = self.session.delete(wstoken_url) if res.status_code == 401: - raise UnauthorizedException() + raise UnauthorizedException(wstoken_url, res) if res.status_code >= 400: raise WhereIsException(wstoken_url, res) return None + + @refresh() + def _get_paginate_gps_postions(self, url: str) -> dict[str, Any] | WhereIsException: + res = self.session.get(url) + + if res.status_code == 401: + raise UnauthorizedException(url, res) + + if res.status_code >= 400: + raise WhereIsException(url, res) + + return res.json() + + def get_gps_positions( + self, + date_start: str | None = None, + date_end: str | None = None, + ) -> list[dict[str, Any]] | ValueError | WhereIsException: + """ + Gets GPS positions data. + + You can get GPS positions filtered by date interval using + optionals arguments `date_start` and `date_end`: + - `date_start`: [date_start,] + - `date_end`: [date_end,] + - `date_start` & `date_end`: [date_start,date_end] + + The dates formats must be in any valid ISO 8601 format otherwise + it will raise a ValueError. + """ + gps_url = urljoin(self.base_url, "/gps/positions/") + lst_gps_positions: list[dict[str, Any]] = [] + + if date_start: + ds = dt.fromisoformat(date_start) + gps_url += f"?date_start={ds.isoformat()}" + if date_end: + de = dt.fromisoformat(date_end) + if date_start: + gps_url += f"&date_end={de.isoformat()}" + else: + gps_url += f"?date_end={de.isoformat()}" + + while gps_url is not None: + logging.info(f"get gps data from: {gps_url}") + + data = self._get_paginate_gps_postions(gps_url) + lst_gps_positions.extend([d for d in data["results"]]) + gps_url = data["next"] + + return lst_gps_positions diff --git a/src/exceptions.py b/src/exceptions.py index 61af4c9..9eb5cc3 100644 --- a/src/exceptions.py +++ b/src/exceptions.py @@ -1,6 +1,6 @@ from requests import Response -__all__ = ["WhereIsException"] +__all__ = ["WhereIsException", "UnauthorizedException"] class WhereIsException(Exception): @@ -19,5 +19,5 @@ class WhereIsException(Exception): return f"error calling: {self.url} - {self.error_code} - {self.content}" -class UnauthorizedException(Exception): +class UnauthorizedException(WhereIsException): pass From 8755ef2f7cda9e2327cbdc740e6efcbb97557641 Mon Sep 17 00:00:00 2001 From: rmanach Date: Wed, 18 Dec 2024 11:49:29 +0100 Subject: [PATCH 5/9] add README.md --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e1855a --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# whereis-client + +A simple Python client library to interact easyly with the WhereIs REST API. + +## Install + +```bash +pip install whereis_client== -i https://gitea.thegux.fr/api/packages/whereis-client/pypi/simple/ --extra-index-url https://pypi.org/simple +``` + +## How to ? +In order to use the client you need to provide some environments variables defined in `.env.example`. You can either: +- Copy the `.env.example` into `.env` and feed the variables (`.env` should be next to your main script using the lib) +- Add those environments variables to your environment profile. + +Once it's done, you're good to use the client. + +For some code samples on how to use the client, take a look at the [main.py](main.py) sample script. + +Enjoy ! + +## Contact + +If you have any issues, feel free to contact **admin@thegux.fr** for fixes or upgrades. From d52cc59eec9a985be8bbafe550e9efbe59541485 Mon Sep 17 00:00:00 2001 From: rmanach Date: Wed, 18 Dec 2024 12:03:45 +0100 Subject: [PATCH 6/9] fix readme for install + makefile for publish --- Makefile | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 7d31afe..29e7ba2 100644 --- a/Makefile +++ b/Makefile @@ -22,4 +22,4 @@ build: check $(PYTHON) -m hatch -v build -t wheel publish: build - $(PYTHON) -m twine upload --repository gitea dist/*.whl \ No newline at end of file + $(PYTHON) -m twine upload --repository whereis-client dist/*.whl \ No newline at end of file diff --git a/README.md b/README.md index 3e1855a..76a15d8 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A simple Python client library to interact easyly with the WhereIs REST API. ## Install ```bash -pip install whereis_client== -i https://gitea.thegux.fr/api/packages/whereis-client/pypi/simple/ --extra-index-url https://pypi.org/simple +pip install --index-url https://gitea.thegux.fr/api/packages/rmanach/pypi/simple/ whereis-client== --extra-index-url https://pypi.org/simple ``` ## How to ? From 3fab2cb64934952ada02e2447f93bff881c3974b Mon Sep 17 00:00:00 2001 From: rmanach Date: Wed, 18 Dec 2024 14:45:31 +0100 Subject: [PATCH 7/9] update README --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 76a15d8..62754b3 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,15 @@ In order to use the client you need to provide some environments variables defin Once it's done, you're good to use the client. +```python +from whereis_client import Client + +cli = Client.from_env() +lst_gps_positions = cli.get_gps_positions( + date_start="2022-12-25", date_end="2022-12-30" +) +``` + For some code samples on how to use the client, take a look at the [main.py](main.py) sample script. Enjoy ! From 46cbbd749c937142c6d958fcb74e801a38036eaf Mon Sep 17 00:00:00 2001 From: rmanach Date: Wed, 18 Dec 2024 14:58:08 +0100 Subject: [PATCH 8/9] fix typo --- src/client.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/client.py b/src/client.py index d333cfe..6fbd58b 100644 --- a/src/client.py +++ b/src/client.py @@ -24,7 +24,7 @@ __all__ = ["Client", "OrderField"] def refresh(): """ Catch 401 status code (UnauthorizedException) - and refresh the access token and retry. + refresh the access token and retry. """ def decorator(func): @@ -183,7 +183,7 @@ class Client: Initialize the client from env variables (.env & global) and log in into the application. - If the login fails, and exception is raised. + If the login fails, an exception is raised. """ env_data = { "WHEREIS_API_EMAIL": os.getenv("WHEREIS_API_EMAIL", ""), @@ -337,7 +337,8 @@ class Client: @refresh() def delete_session(self, id_: UUID) -> None | WhereIsException: """ - Close and delete the session. Users can't be added anymore. + Close and delete the session. + NOTE: The GPS positions associated to the session are not deleted ! """ session_url = urljoin(self.base_url, f"/sessions/{id_}/") @@ -377,6 +378,7 @@ class Client: @refresh() def close_session(self, id_: UUID) -> dict[str, Any] | WhereIsException: + """Close the DEFINITIVELY the session. Users can't be added anymore.""" session_url = urljoin(self.base_url, f"/sessions/{id_}/close/") logging.info(f"close session: {session_url}") @@ -495,7 +497,9 @@ class Client: return None @refresh() - def _get_paginate_gps_postions(self, url: str) -> dict[str, Any] | WhereIsException: + def _get_paginate_gps_positions( + self, url: str + ) -> dict[str, Any] | WhereIsException: res = self.session.get(url) if res.status_code == 401: @@ -539,7 +543,7 @@ class Client: while gps_url is not None: logging.info(f"get gps data from: {gps_url}") - data = self._get_paginate_gps_postions(gps_url) + data = self._get_paginate_gps_positions(gps_url) lst_gps_positions.extend([d for d in data["results"]]) gps_url = data["next"] From 7196f5ce7a41b30d42e99ea0dc38e3ad430d6286 Mon Sep 17 00:00:00 2001 From: rmanach Date: Wed, 18 Dec 2024 15:01:27 +0100 Subject: [PATCH 9/9] bump version number --- src/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__init__.py b/src/__init__.py index 6160875..d714875 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -2,4 +2,4 @@ from .client import Client, OrderField __all__ = ["Client", "OrderField"] -VERSION = "0.1.0a0" +VERSION = "0.1.0"