diff --git a/src/client/qcc/cli/__init__.py b/src/client/qcc/cli/__init__.py index 9b0684b7..b76a27b2 100644 --- a/src/client/qcc/cli/__init__.py +++ b/src/client/qcc/cli/__init__.py @@ -7,11 +7,13 @@ from ..client import QCC from ..conf import settings -from . import auth, static +from . import auth, session, static, status app = typer.Typer(name="qcc", no_args_is_help=True) app.add_typer(auth.app) app.add_typer(static.app) +app.add_typer(status.app) +app.add_typer(session.app) @app.callback() diff --git a/src/client/qcc/cli/api.py b/src/client/qcc/cli/api.py new file mode 100644 index 00000000..8c040107 --- /dev/null +++ b/src/client/qcc/cli/api.py @@ -0,0 +1,21 @@ +"""QualiCharge API client CLI: api.""" + +from typing import Any + +import typer +from anyio import run +from rich import print + +from ..exceptions import APIRequestError +from .codes import QCCExitCodes + + +def async_run_api_query(*args) -> Any: + """An anyio.run wrapper to handle APIRequestError.""" + try: + return_value = run(*args) + except APIRequestError as err: + print("[red]An error occurred while querying the API! More details follow.") + print(err.args[0]) + raise typer.Exit(QCCExitCodes.API_EXCEPTION) from err + return return_value diff --git a/src/client/qcc/cli/session.py b/src/client/qcc/cli/session.py new file mode 100644 index 00000000..22588515 --- /dev/null +++ b/src/client/qcc/cli/session.py @@ -0,0 +1,59 @@ +"""QualiCharge API client CLI: statuc.""" + +from typing import Annotated, Optional + +import click +import typer +from rich import print + +from ..client import QCC +from .api import async_run_api_query +from .utils import parse_input_json_lines, parse_json_parameter + +app = typer.Typer(name="session", no_args_is_help=True) + + +@app.command() +def create( + ctx: typer.Context, + session: Optional[str] = None, + interactive: Annotated[ + bool, typer.Option(help="Read session from standard input (JSON string)") + ] = True, +): + """Create a charging point session. + + You can submit your session entry to create as a JSON string argument for + the `--status` option. Without `--status` option (but with `--interactive`) + the command will read and parse the standard input as a JSON string. + + Note that when using the `--interactive` option (active by default), the command + expects your JSON string on a single row. + """ + client: QCC = ctx.obj + data = parse_json_parameter("session", session, interactive) # type: ignore[arg-type] + created = async_run_api_query(client.session.create, data) + print("[green]Created session successfully.[/green]") + print(created) + + +@app.command() +def bulk( + ctx: typer.Context, + chunk_size: int = 10, + ignore_errors: bool = False, +): + """Bulk create new sessions. + + Sessions will be read from the standard input (one JSON per line). + """ + client: QCC = ctx.obj + + n_created = async_run_api_query( + client.session.bulk, + parse_input_json_lines(click.get_text_stream("stdin"), ignore_errors), + chunk_size, + ignore_errors, + ) + + print(f"[green]Created {n_created} sessions successfully.[/green]") diff --git a/src/client/qcc/cli/static.py b/src/client/qcc/cli/static.py index 8d238e9a..b65df857 100644 --- a/src/client/qcc/cli/static.py +++ b/src/client/qcc/cli/static.py @@ -1,35 +1,24 @@ """QualiCharge API client CLI: static.""" import json -from typing import Any, Generator, Optional +from typing import Optional import click import typer -from anyio import run from rich import print from typing_extensions import Annotated from ..client import QCC -from ..exceptions import APIRequestError +from .api import async_run_api_query from .codes import QCCExitCodes +from .utils import parse_input_json_lines, parse_json_parameter app = typer.Typer(name="static", no_args_is_help=True) -def async_run_api_query(*args) -> Any: - """An anyio.run wrapper to handle APIRequestError.""" - try: - return_value = run(*args) - except APIRequestError as err: - print("[red]An error occurred while querying the API! More details follow.") - print(err.args[0]) - raise typer.Exit(QCCExitCodes.API_EXCEPTION) from err - return return_value - - @app.command() def list(ctx: typer.Context): - """Get all static entries.""" + """Get all statique entries.""" client: QCC = ctx.obj async def statiques(): @@ -57,30 +46,15 @@ def create( expects your JSON string on a single row. """ client: QCC = ctx.obj - if not statique and interactive: - statique = click.get_text_stream("stdin").readline() - - if statique is None: - print( - "[red]A statique object is required either from stdin or as an option[/red]" - ) - raise typer.Exit(QCCExitCodes.PARAMETER_EXCEPTION) - - try: - data = json.loads(statique) - except json.JSONDecodeError as err: - print("[red]Invalid JSON input string[/red]") - raise typer.Exit(QCCExitCodes.PARAMETER_EXCEPTION) from err - + data = parse_json_parameter("statique", statique, interactive) # type: ignore[arg-type] created = async_run_api_query(client.static.create, data) - print("[green]Created statique successfully.[/green]") print(created) @app.command() def read(ctx: typer.Context, id_pdc_itinerance: str): - """Get all static entries.""" + """Read a statique entry.""" client: QCC = ctx.obj read = async_run_api_query(client.static.read, id_pdc_itinerance) @@ -105,20 +79,7 @@ def update( expects your JSON string on a single row. """ client: QCC = ctx.obj - if not statique and interactive: - statique = click.get_text_stream("stdin").readline() - - if statique is None: - print( - "[red]A statique object is required either from stdin or as an option[/red]" - ) - raise typer.Exit(QCCExitCodes.PARAMETER_EXCEPTION) - - try: - data = json.loads(statique) - except json.JSONDecodeError as err: - print("[red]Invalid JSON input string[/red]") - raise typer.Exit(QCCExitCodes.PARAMETER_EXCEPTION) from err + data = parse_json_parameter("statique", statique, interactive) # type: ignore[arg-type] if "id_pdc_itinerance" not in data: print("[red]Statique object requires an `id_pdc_itinerance` field[/red]") @@ -142,22 +103,9 @@ def bulk( """ client: QCC = ctx.obj - def parse_input_json_lines(lines) -> Generator[dict, None, None]: - """Read and JSON parse stdin line by line.""" - for statique in lines: - try: - data = json.loads(statique) - except json.JSONDecodeError as err: - if ignore_errors: - print(f"[orange]Ignored invalid line:[/orange]\n{statique}") - continue - print("[red]Invalid JSON input string[/red]") - raise typer.Exit(QCCExitCodes.PARAMETER_EXCEPTION) from err - yield data - n_created = async_run_api_query( client.static.bulk, - parse_input_json_lines(click.get_text_stream("stdin")), + parse_input_json_lines(click.get_text_stream("stdin"), ignore_errors), chunk_size, ignore_errors, ) diff --git a/src/client/qcc/cli/status.py b/src/client/qcc/cli/status.py new file mode 100644 index 00000000..ffaa8172 --- /dev/null +++ b/src/client/qcc/cli/status.py @@ -0,0 +1,103 @@ +"""QualiCharge API client CLI: status.""" + +import json +from datetime import datetime +from typing import Annotated, List, Optional + +import click +import typer +from rich import print + +from ..client import QCC +from .api import async_run_api_query +from .utils import parse_input_json_lines, parse_json_parameter + +app = typer.Typer(name="status", no_args_is_help=True) + + +@app.command() +def list( + ctx: typer.Context, + from_: Annotated[Optional[datetime], typer.Option("--from")] = None, + pdc: Optional[List[str]] = None, + station: Optional[List[str]] = None, +): + """List charging points last known status.""" + client: QCC = ctx.obj + + async def statuses(): + async for status in client.status.list(from_=from_, pdc=pdc, station=station): + typer.echo(json.dumps(status)) + + async_run_api_query(statuses) + + +@app.command() +def create( + ctx: typer.Context, + status: Optional[str] = None, + interactive: Annotated[ + bool, typer.Option(help="Read status from standard input (JSON string)") + ] = True, +): + """Create a charging point status. + + You can submit your status entry to create as a JSON string argument for + the `--status` option. Without `--status` option (but with `--interactive`) + the command will read and parse the standard input as a JSON string. + + Note that when using the `--interactive` option (active by default), the command + expects your JSON string on a single row. + """ + client: QCC = ctx.obj + data = parse_json_parameter("status", status, interactive) # type: ignore[arg-type] + created = async_run_api_query(client.status.create, data) + print("[green]Created status successfully.[/green]") + print(created) + + +@app.command() +def read(ctx: typer.Context, id_pdc_itinerance: str): + """Get charging point status.""" + client: QCC = ctx.obj + + read = async_run_api_query(client.status.read, id_pdc_itinerance) + typer.echo(json.dumps(read)) + + +@app.command() +def history( + ctx: typer.Context, + id_pdc_itinerance: str, + from_: Annotated[Optional[datetime], typer.Option("--from")] = None, +): + """Get charging point history.""" + client: QCC = ctx.obj + + async def statuses(): + async for status in client.status.history(id_pdc_itinerance, from_=from_): + typer.echo(json.dumps(status)) + + async_run_api_query(statuses) + + +@app.command() +def bulk( + ctx: typer.Context, + chunk_size: int = 10, + ignore_errors: bool = False, +): + """Bulk create new statuses. + + Statuses will be read from the standard input (one JSON per line). + """ + client: QCC = ctx.obj + + n_created = async_run_api_query( + client.status.bulk, + parse_input_json_lines(click.get_text_stream("stdin"), ignore_errors), + chunk_size, + ignore_errors, + ) + + print(f"[green]Created {n_created} statuses successfully.[/green]") diff --git a/src/client/qcc/cli/utils.py b/src/client/qcc/cli/utils.py new file mode 100644 index 00000000..20d0e5e2 --- /dev/null +++ b/src/client/qcc/cli/utils.py @@ -0,0 +1,50 @@ +"""QualiCharge API client CLI: utils.""" + +import json +from typing import Generator, TextIO + +import click +import typer +from rich import print + +from .codes import QCCExitCodes + + +def parse_json_parameter(name: str, parameter: str, interactive: bool) -> dict: + """Read and JSON parse parameter from option or stdin.""" + # Get parameter value from stdin if empty + if not parameter and interactive: + parameter = click.get_text_stream("stdin").readline() + + if parameter is None: + print( + ( + f"[red]A {name} object is required " + "either from stdin or as an option[/red]" + ) + ) + raise typer.Exit(QCCExitCodes.PARAMETER_EXCEPTION) + + # Parse parameter as JSON + try: + data = json.loads(parameter) + except json.JSONDecodeError as err: + print("[red]Invalid JSON input string[/red]") + raise typer.Exit(QCCExitCodes.PARAMETER_EXCEPTION) from err + return data + + +def parse_input_json_lines( + lines: TextIO, ignore_errors: bool +) -> Generator[dict, None, None]: + """Read and JSON parse stdin line by line.""" + for line in lines: + try: + data = json.loads(line) + except json.JSONDecodeError as err: + if ignore_errors: + print(f"[orange]Ignored invalid line:[/orange]\n{line}") + continue + print("[red]Invalid JSON input string[/red]") + raise typer.Exit(QCCExitCodes.PARAMETER_EXCEPTION) from err + yield data diff --git a/src/client/qcc/client.py b/src/client/qcc/client.py index 8fa86e80..27b1403d 100644 --- a/src/client/qcc/client.py +++ b/src/client/qcc/client.py @@ -1,6 +1,7 @@ """QualiCharge API client.""" from .endpoints.auth import Auth +from .endpoints.dynamic import Session, Status from .endpoints.static import Static from .exceptions import ConfigurationError from .http import HTTPClient @@ -26,3 +27,5 @@ def __init__( ) self.auth = Auth(self.client) self.static = Static(self.client) + self.status = Status(self.client) + self.session = Session(self.client) diff --git a/src/client/qcc/endpoints/base.py b/src/client/qcc/endpoints/base.py new file mode 100644 index 00000000..47040639 --- /dev/null +++ b/src/client/qcc/endpoints/base.py @@ -0,0 +1,88 @@ +"""QualiCharge API client endpoints base.""" + +import logging +from abc import ABC, abstractmethod +from typing import AsyncIterator, Sequence + +import httpx + +from qcc.conf import settings +from qcc.exceptions import APIRequestError +from qcc.http import HTTPClient + +logger = logging.getLogger(__name__) + + +class BaseCreateEndpoint: + """Base create endpoint.""" + + endpoint: str + + def __init__(self, client: HTTPClient) -> None: + """Set /auth endpoints HTTP client.""" + self.client = client + + async def create(self, obj: dict) -> dict | None: + """Query the /{endpoint}/ endpoint (POST).""" + response = await self.client.post(f"{self.endpoint}/", json=obj) + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: + raise APIRequestError(response.json()) from err + + # Response body may be empty (201) + if not response.content: + return None + + return response.json() + + async def bulk( + self, + objs: Sequence[dict], + chunk_size: int = settings.API_BULK_CREATE_MAX_SIZE, + ignore_errors: bool = False, + ) -> int: + """Query the /{endpoint}/bulk endpoint (POST).""" + chunk: list = [] + n_created = 0 + + async def send_chunk(client, chunk: list[dict]) -> int: + """Submit a chunk to the API.""" + response = await client.post(f"{self.endpoint}/bulk", json=chunk) + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: + if ignore_errors: + logger.debug("Ignored chunk: %s", chunk) + logger.warning("Ignored query error: %s", response) + return 0 + raise APIRequestError(response.json()) from err + return response.json()["size"] + + for obj in objs: + chunk.append(obj) + if len(chunk) == chunk_size: + n_created += await send_chunk(self.client, chunk) + chunk = [] + + if len(chunk): + n_created += await send_chunk(self.client, chunk) + return n_created + + +class BaseEndpoint(ABC, BaseCreateEndpoint): + """Base endpoint CRUD actions.""" + + @abstractmethod + def list(self) -> AsyncIterator[dict]: + """Query the /{endpoint}/ endpoint (GET).""" + + async def read(self, id_: str) -> dict: + """Query the /{endpoint}/{id_} endpoint (GET).""" + response = await self.client.get(f"{self.endpoint}/{id_}") + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: + raise APIRequestError(response.json()) from err + + return response.json() diff --git a/src/client/qcc/endpoints/dynamic.py b/src/client/qcc/endpoints/dynamic.py new file mode 100644 index 00000000..d4fe1eb0 --- /dev/null +++ b/src/client/qcc/endpoints/dynamic.py @@ -0,0 +1,71 @@ +"""QualiCharge API client dynamic endpoints.""" + +import logging +from datetime import datetime +from typing import AsyncIterator, List, Optional + +import httpx + +from qcc.endpoints.base import BaseCreateEndpoint + +from ..exceptions import APIRequestError +from .base import BaseEndpoint + +logger = logging.getLogger(__name__) + + +class Status(BaseEndpoint): + """/dynamique/status endpoints.""" + + endpoint: str = "/dynamique/status" + + async def list( + self, + from_: Optional[datetime] = None, + pdc: Optional[List[str]] = None, + station: Optional[List[str]] = None, + ) -> AsyncIterator[dict]: + """Query the /dynamique/status endpoint (GET).""" + # Get isotring for the `from_` parameter + from_str = from_.isoformat() if from_ else None + + # Query filters + params = dict( + p + for p in (("from", from_str), ("pdc", pdc), ("station", station)) + if p[1] is not None + ) + + response = await self.client.get(f"{self.endpoint}/", params=params) + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: + raise APIRequestError(response.json()) from err + + for status in response.json(): + yield status + + async def history( + self, id_: str, from_: Optional[datetime] = None + ) -> AsyncIterator[dict]: + """Query the /{endpoint}/{id_}/history endpoint (GET).""" + # Get isotring for the `from_` parameter + from_str = from_.isoformat() if from_ else None + params = {"from": from_str} if from_str else {} + + response = await self.client.get( + f"{self.endpoint}/{id_}/history", params=params + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: + raise APIRequestError(response.json()) from err + + for status in response.json(): + yield status + + +class Session(BaseCreateEndpoint): + """/dynamique/session endpoints.""" + + endpoint: str = "/dynamique/session" diff --git a/src/client/qcc/endpoints/static.py b/src/client/qcc/endpoints/static.py index 58c97dfe..7b17325a 100644 --- a/src/client/qcc/endpoints/static.py +++ b/src/client/qcc/endpoints/static.py @@ -1,28 +1,25 @@ """QualiCharge API client static endpoints.""" import logging -from typing import AsyncIterator, Sequence +from typing import AsyncIterator import httpx -from qcc.conf import settings -from qcc.exceptions import APIRequestError -from qcc.http import HTTPClient +from ..exceptions import APIRequestError +from .base import BaseEndpoint logger = logging.getLogger(__name__) -class Static: +class Static(BaseEndpoint): """/statique endpoints.""" - def __init__(self, client: HTTPClient) -> None: - """Set /auth endpoints HTTP client.""" - self.client = client + endpoint: str = "/statique" async def list(self) -> AsyncIterator[dict]: """Query the /statique/ endpoint (GET).""" - async def get_statiques(url="/statique/"): + async def get_statiques(url=f"{self.endpoint}/"): """Get statique items.""" response = await self.client.get(url) try: @@ -39,67 +36,12 @@ async def get_statiques(url="/statique/"): async for statique in get_statiques(): yield statique - async def create(self, statique: dict) -> dict: - """Query the /statique/ endpoint (POST).""" - response = await self.client.post("/statique/", json=statique) + async def update(self, id_: str, obj: dict) -> dict: + """Query the /{endpoint}/{id_} endpoint (PUT).""" + response = await self.client.put(f"{self.endpoint}/{id_}", json=obj) try: response.raise_for_status() except httpx.HTTPStatusError as err: raise APIRequestError(response.json()) from err return response.json() - - async def read(self, id_pdc_itinerance: str) -> dict: - """Query the /statique/{id_pdc_itinerance} endpoint (GET).""" - response = await self.client.get(f"/statique/{id_pdc_itinerance}") - try: - response.raise_for_status() - except httpx.HTTPStatusError as err: - raise APIRequestError(response.json()) from err - - return response.json() - - async def update(self, id_pdc_itinerance: str, statique: dict) -> dict: - """Query the /statique/{id_pdc_itinerance} endpoint (PUT).""" - response = await self.client.put( - f"/statique/{id_pdc_itinerance}", json=statique - ) - try: - response.raise_for_status() - except httpx.HTTPStatusError as err: - raise APIRequestError(response.json()) from err - - return response.json() - - async def bulk( - self, - statiques: Sequence[dict], - chunk_size: int = settings.API_BULK_CREATE_MAX_SIZE, - ignore_errors: bool = False, - ) -> int: - """Query the /statique/bulk endpoint (POST).""" - chunk: list = [] - n_created = 0 - - async def send_chunk(client, chunk: list[dict]) -> int: - """Submit a chunk to the API.""" - response = await client.post("/statique/bulk", json=chunk) - try: - response.raise_for_status() - except httpx.HTTPStatusError as err: - if ignore_errors: - logger.debug("Ignored chunk: %s", chunk) - logger.warning("Ignored query error: %s", response) - return 0 - raise APIRequestError(response.json()) from err - return response.json()["size"] - - for statique in statiques: - chunk.append(statique) - if len(chunk) == chunk_size: - n_created += await send_chunk(self.client, chunk) - chunk = [] - - if len(chunk): - n_created += await send_chunk(self.client, chunk) - return n_created diff --git a/src/client/tests/cli/test_session.py b/src/client/tests/cli/test_session.py new file mode 100644 index 00000000..dbb9e2ce --- /dev/null +++ b/src/client/tests/cli/test_session.py @@ -0,0 +1,137 @@ +"""Tests for the qcc.cli.session module.""" + +import json + +import pytest + +from qcc.cli.codes import QCCExitCodes +from qcc.cli.session import app + + +def test_cli_session_create(runner, qcc, httpx_mock): + """Test the `session create` command.""" + # Empty interactive status + result = runner.invoke(app, ["create"], obj=qcc, input="\n") + assert result.exit_code == QCCExitCodes.PARAMETER_EXCEPTION + assert "Invalid JSON input string" in result.stdout + + # Invalid interactive input + result = runner.invoke(app, ["create"], obj=qcc, input="foo\n") + assert result.exit_code == QCCExitCodes.PARAMETER_EXCEPTION + assert "Invalid JSON input string" in result.stdout + + # Missing --session parameter in non-interactive mode + result = runner.invoke(app, ["create", "--no-interactive"], obj=qcc) + assert result.exit_code == QCCExitCodes.PARAMETER_EXCEPTION + assert ( + "A session object is required either from stdin or as an option" + in result.stdout + ) + + session = {"id_pdc_itinerance": "FRS63E0001"} + status_json = json.dumps(session) + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/session/", + json=session, + ) + + # Valid parameter input + result = runner.invoke(app, ["create", "--session", status_json], obj=qcc) + assert result.exit_code == QCCExitCodes.OK + assert "Created session successfully" in result.stdout + + # Valid interactive input + result = runner.invoke(app, ["create"], obj=qcc, input=f"{status_json}\n") + assert result.exit_code == QCCExitCodes.OK + assert "Created session successfully" in result.stdout + + # Raise an HTTP 500 error + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/session/", + status_code=500, + json={"message": "An unknown error occured."}, + ) + + result = runner.invoke(app, ["create", "--session", status_json], obj=qcc) + assert result.exit_code == QCCExitCodes.API_EXCEPTION + assert "An error occurred while querying the API" in result.stdout + assert "An unknown error occured" in result.stdout + + +def test_cli_session_create_bulk(runner, qcc, httpx_mock): + """Test the `session bulk` command.""" + total = 12 + + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/session/bulk", + json={"size": 10}, + ) + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/session/bulk", + json={"size": 2}, + ) + + input = ( + "\n".join( + [ + json.dumps({"id_pdc_itinerance": f"FRS63E00{x:02d}"}) + for x in range(total) + ] + ) + + "\n" + ) + + # Valid input + result = runner.invoke(app, ["bulk"], obj=qcc, input=input) + assert result.exit_code == QCCExitCodes.OK + assert "Created 12 sessions successfully" in result.stdout + + # Insert invalid row in input + input = "foo\n" + input + result = runner.invoke(app, ["bulk"], obj=qcc, input=input) + assert result.exit_code == QCCExitCodes.PARAMETER_EXCEPTION + assert "Invalid JSON input string" in result.stdout + + # Badger mode: ignore all errors! + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/session/bulk", + json={"size": 10}, + ) + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/session/bulk", + json={"size": 2}, + ) + result = runner.invoke(app, ["bulk", "--ignore-errors"], obj=qcc, input=input) + assert result.exit_code == QCCExitCodes.OK + assert "Created 12 sessions successfully" in result.stdout + + +@pytest.mark.parametrize("chunk_size", (5, 6, 10)) +def test_cli_session_bulk_chunks(runner, qcc, httpx_mock, chunk_size): + """Test the `session bulk` command with different chunk sizes.""" + total = 30 + input = ( + "\n".join( + [ + json.dumps({"id_pdc_itinerance": f"FRS63E00{x:02d}"}) + for x in range(total) + ] + ) + + "\n" + ) + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/session/bulk", + json={"size": chunk_size}, + ) + result = runner.invoke( + app, ["bulk", "--chunk-size", chunk_size], obj=qcc, input=input + ) + assert result.exit_code == QCCExitCodes.OK + assert "Created 30 sessions successfully" in result.stdout diff --git a/src/client/tests/cli/test_status.py b/src/client/tests/cli/test_status.py new file mode 100644 index 00000000..ce64b00d --- /dev/null +++ b/src/client/tests/cli/test_status.py @@ -0,0 +1,272 @@ +"""Tests for the qcc.cli.status module.""" + +import json + +import pytest + +from qcc.cli.codes import QCCExitCodes +from qcc.cli.status import app + + +def test_cli_status_list(runner, qcc, httpx_mock): + """Test the `status list` command.""" + # No parameters + httpx_mock.add_response( + method="GET", + url="http://example.com/api/v1/dynamique/status/", + json=list(range(0, 10)), + ) + result = runner.invoke(app, ["list"], obj=qcc) + assert result.exit_code == QCCExitCodes.OK + assert result.stdout == "\n".join(str(x) for x in range(10)) + "\n" + + # Filter: from_ + httpx_mock.add_response( + method="GET", + url="http://example.com/api/v1/dynamique/status/?from=2024-06-13T12%3A02%3A33", + json=list(range(0, 2)), + ) + result = runner.invoke(app, ["list", "--from", "2024-06-13T12:02:33"], obj=qcc) + assert result.exit_code == QCCExitCodes.OK + assert result.stdout == "\n".join(str(x) for x in range(2)) + "\n" + + # Filter: charge points + httpx_mock.add_response( + method="GET", + url="http://example.com/api/v1/dynamique/status/?pdc=FR911E1111ER1", + json=list(range(2, 5)), + ) + result = runner.invoke(app, ["list", "--pdc", "FR911E1111ER1"], obj=qcc) + assert result.exit_code == QCCExitCodes.OK + assert result.stdout == "\n".join(str(x) for x in range(2, 5)) + "\n" + + # Filter: multiple charge points + httpx_mock.add_response( + method="GET", + url="http://example.com/api/v1/dynamique/status/?pdc=FR911E1111ER1&pdc=FR911E1111ER2", + json=list(range(1, 3)), + ) + result = runner.invoke( + app, ["list", "--pdc", "FR911E1111ER1", "--pdc", "FR911E1111ER2"], obj=qcc + ) + assert result.exit_code == QCCExitCodes.OK + assert result.stdout == "\n".join(str(x) for x in range(1, 3)) + "\n" + + # Filter: stations + httpx_mock.add_response( + method="GET", + url="http://example.com/api/v1/dynamique/status/?station=FR911P0001", + json=list(range(5, 10)), + ) + result = runner.invoke(app, ["list", "--station", "FR911P0001"], obj=qcc) + assert result.exit_code == QCCExitCodes.OK + assert result.stdout == "\n".join(str(x) for x in range(5, 10)) + "\n" + + # Filter: stations + from_ + httpx_mock.add_response( + method="GET", + url="http://example.com/api/v1/dynamique/status/?station=FR911P0001&from=2024-06-13T12%3A02%3A33", + json=list(range(2, 8)), + ) + result = runner.invoke( + app, + ["list", "--station", "FR911P0001", "--from", "2024-06-13T12:02:33"], + obj=qcc, + ) + assert result.exit_code == QCCExitCodes.OK + assert result.stdout == "\n".join(str(x) for x in range(2, 8)) + "\n" + + # Raise an HTTP 500 error + httpx_mock.add_response( + method="GET", + url="http://example.com/api/v1/dynamique/status/", + status_code=500, + json={"message": "An unknown error occured."}, + ) + + result = runner.invoke(app, ["list"], obj=qcc) + assert result.exit_code == QCCExitCodes.API_EXCEPTION + assert "An error occurred while querying the API" in result.stdout + assert "An unknown error occured" in result.stdout + + +def test_cli_status_read(runner, qcc, httpx_mock): + """Test the `status read` command.""" + id_pdc_itinerance = "FRS63E0001" + httpx_mock.add_response( + method="GET", + url=f"http://example.com/api/v1/dynamique/status/{id_pdc_itinerance}", + json={"id_pdc_itinerance": id_pdc_itinerance}, + ) + + result = runner.invoke(app, ["read", id_pdc_itinerance], obj=qcc) + assert result.exit_code == QCCExitCodes.OK + assert '{"id_pdc_itinerance": "FRS63E0001"}' in result.stdout + + # Unknown PDC + httpx_mock.add_response( + method="GET", + url=f"http://example.com/api/v1/dynamique/status/{id_pdc_itinerance}", + status_code=404, + json={"message": "PDC not found"}, + ) + result = runner.invoke(app, ["read", id_pdc_itinerance], obj=qcc) + assert result.exit_code == QCCExitCodes.API_EXCEPTION + assert "An error occurred while querying the API" in result.stdout + assert "PDC not found" in result.stdout + + +def test_cli_status_history(runner, qcc, httpx_mock): + """Test the `status history` command.""" + id_pdc_itinerance = "FRS63E0001" + httpx_mock.add_response( + method="GET", + url=f"http://example.com/api/v1/dynamique/status/{id_pdc_itinerance}/history", + json=list(range(10)), + ) + result = runner.invoke(app, ["history", id_pdc_itinerance], obj=qcc) + assert result.exit_code == QCCExitCodes.OK + assert result.stdout == "\n".join(str(x) for x in range(10)) + "\n" + + # Add --from option + httpx_mock.add_response( + method="GET", + url=( + f"http://example.com/api/v1/dynamique/status/{id_pdc_itinerance}" + "/history?from=2024-06-13T12%3A02%3A33" + ), + json=list(range(3, 10)), + ) + result = runner.invoke( + app, ["history", id_pdc_itinerance, "--from", "2024-06-13T12:02:33"], obj=qcc + ) + assert result.exit_code == QCCExitCodes.OK + assert result.stdout == "\n".join(str(x) for x in range(3, 10)) + "\n" + + +def test_cli_status_create(runner, qcc, httpx_mock): + """Test the `status create` command.""" + # Empty interactive status + result = runner.invoke(app, ["create"], obj=qcc, input="\n") + assert result.exit_code == QCCExitCodes.PARAMETER_EXCEPTION + assert "Invalid JSON input string" in result.stdout + + # Invalid interactive input + result = runner.invoke(app, ["create"], obj=qcc, input="foo\n") + assert result.exit_code == QCCExitCodes.PARAMETER_EXCEPTION + assert "Invalid JSON input string" in result.stdout + + # Missing --status parameter in non-interactive mode + result = runner.invoke(app, ["create", "--no-interactive"], obj=qcc) + assert result.exit_code == QCCExitCodes.PARAMETER_EXCEPTION + assert ( + "A status object is required either from stdin or as an option" in result.stdout + ) + + status = {"id_pdc_itinerance": "FRS63E0001"} + status_json = json.dumps(status) + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/status/", + json=status, + ) + + # Valid parameter input + result = runner.invoke(app, ["create", "--status", status_json], obj=qcc) + assert result.exit_code == QCCExitCodes.OK + assert "Created status successfully" in result.stdout + + # Valid interactive input + result = runner.invoke(app, ["create"], obj=qcc, input=f"{status_json}\n") + assert result.exit_code == QCCExitCodes.OK + assert "Created status successfully" in result.stdout + + # Raise an HTTP 500 error + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/status/", + status_code=500, + json={"message": "An unknown error occured."}, + ) + + result = runner.invoke(app, ["create", "--status", status_json], obj=qcc) + assert result.exit_code == QCCExitCodes.API_EXCEPTION + assert "An error occurred while querying the API" in result.stdout + assert "An unknown error occured" in result.stdout + + +def test_cli_status_create_bulk(runner, qcc, httpx_mock): + """Test the `status bulk` command.""" + total = 12 + + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/status/bulk", + json={"size": 10}, + ) + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/status/bulk", + json={"size": 2}, + ) + + input = ( + "\n".join( + [ + json.dumps({"id_pdc_itinerance": f"FRS63E00{x:02d}"}) + for x in range(total) + ] + ) + + "\n" + ) + + # Valid input + result = runner.invoke(app, ["bulk"], obj=qcc, input=input) + assert result.exit_code == QCCExitCodes.OK + assert "Created 12 statuses successfully" in result.stdout + + # Insert invalid row in input + input = "foo\n" + input + result = runner.invoke(app, ["bulk"], obj=qcc, input=input) + assert result.exit_code == QCCExitCodes.PARAMETER_EXCEPTION + assert "Invalid JSON input string" in result.stdout + + # Badger mode: ignore all errors! + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/status/bulk", + json={"size": 10}, + ) + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/status/bulk", + json={"size": 2}, + ) + result = runner.invoke(app, ["bulk", "--ignore-errors"], obj=qcc, input=input) + assert result.exit_code == QCCExitCodes.OK + assert "Created 12 statuses successfully" in result.stdout + + +@pytest.mark.parametrize("chunk_size", (5, 6, 10)) +def test_cli_status_bulk_chunks(runner, qcc, httpx_mock, chunk_size): + """Test the `status bulk` command with different chunk sizes.""" + total = 30 + input = ( + "\n".join( + [ + json.dumps({"id_pdc_itinerance": f"FRS63E00{x:02d}"}) + for x in range(total) + ] + ) + + "\n" + ) + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/status/bulk", + json={"size": chunk_size}, + ) + result = runner.invoke( + app, ["bulk", "--chunk-size", chunk_size], obj=qcc, input=input + ) + assert result.exit_code == QCCExitCodes.OK + assert "Created 30 statuses successfully" in result.stdout diff --git a/src/client/tests/endpoints/test_dynamic.py b/src/client/tests/endpoints/test_dynamic.py new file mode 100644 index 00000000..409ded50 --- /dev/null +++ b/src/client/tests/endpoints/test_dynamic.py @@ -0,0 +1,326 @@ +"""Tests for the qcc.endpoints.dynamic module.""" + +from datetime import datetime + +import pytest + +from qcc.endpoints.dynamic import Session, Status +from qcc.exceptions import APIRequestError + + +def test_dynamic_status_initialization(client): + """Test the Status endpoint __init__ method.""" + status = Status(client) + assert status.client == client + + +@pytest.mark.anyio +async def test_dynamic_status_list(client, httpx_mock): + """Test the /dynamique/status/ endpoint call.""" + status = Status(client) + + # No parameters + httpx_mock.add_response( + method="GET", + url="http://example.com/api/v1/dynamique/status/", + json=list(range(0, 10)), + ) + assert [item async for item in status.list()] == list(range(0, 10)) + + # Filter: from_ + httpx_mock.add_response( + method="GET", + url="http://example.com/api/v1/dynamique/status/?from=2024-06-13T12%3A02%3A33", + json=list(range(0, 2)), + ) + assert [ + item async for item in status.list(from_=datetime(2024, 6, 13, 12, 2, 33)) + ] == list(range(0, 2)) + + # Filter: charge points + httpx_mock.add_response( + method="GET", + url="http://example.com/api/v1/dynamique/status/?pdc=FR911E1111ER1", + json=list(range(2, 5)), + ) + assert [item async for item in status.list(pdc=["FR911E1111ER1"])] == list( + range(2, 5) + ) + # Filter: multiple charge points + httpx_mock.add_response( + method="GET", + url="http://example.com/api/v1/dynamique/status/?pdc=FR911E1111ER1&pdc=FR911E1111ER2", + json=list(range(1, 3)), + ) + assert [ + item async for item in status.list(pdc=["FR911E1111ER1", "FR911E1111ER2"]) + ] == list(range(1, 3)) + + # Filter: stations + httpx_mock.add_response( + method="GET", + url="http://example.com/api/v1/dynamique/status/?station=FR911P0001", + json=list(range(5, 10)), + ) + assert [item async for item in status.list(station=["FR911P0001"])] == list( + range(5, 10) + ) + + # Filter: stations + from_ + httpx_mock.add_response( + method="GET", + url="http://example.com/api/v1/dynamique/status/?station=FR911P0001&from=2024-06-13T12%3A02%3A33", + json=list(range(2, 8)), + ) + assert [ + item + async for item in status.list( + station=["FR911P0001"], from_=datetime(2024, 6, 13, 12, 2, 33) + ) + ] == list(range(2, 8)) + + # Raise an HTTP 500 error + httpx_mock.add_response( + method="GET", + url="http://example.com/api/v1/dynamique/status/", + status_code=500, + json={"message": "An unknown error occured."}, + ) + with pytest.raises(APIRequestError, match="An unknown error occured"): + assert await anext(status.list()) + + +@pytest.mark.anyio +async def test_dynamic_status_read(client, httpx_mock): + """Test the /dynamique/status/{id_pdc_itinerance} endpoint call.""" + status = Status(client) + + id_pdc_itinerance = "FRS63E0001" + httpx_mock.add_response( + method="GET", + url=f"http://example.com/api/v1/dynamique/status/{id_pdc_itinerance}", + json={"id_pdc_itinerance": id_pdc_itinerance}, + ) + assert await status.read(id_pdc_itinerance) == { + "id_pdc_itinerance": id_pdc_itinerance + } + + # API error + httpx_mock.add_response( + method="GET", + url=f"http://example.com/api/v1/dynamique/status/{id_pdc_itinerance}", + status_code=404, + json={"message": "Selected point of charge does not have status record yet"}, + ) + with pytest.raises( + APIRequestError, + match="Selected point of charge does not have status record yet", + ): + await status.read(id_pdc_itinerance) + + +@pytest.mark.anyio +async def test_dynamic_status_history(client, httpx_mock): + """Test the /dynamique/status/{id_pdc_itinerance}/history endpoint call.""" + status = Status(client) + + id_pdc_itinerance = "FRS63E0001" + httpx_mock.add_response( + method="GET", + url=f"http://example.com/api/v1/dynamique/status/{id_pdc_itinerance}/history", + json=list(range(10)), + ) + assert [item async for item in status.history(id_pdc_itinerance)] == list(range(10)) + + # API error + httpx_mock.add_response( + method="GET", + url=f"http://example.com/api/v1/dynamique/status/{id_pdc_itinerance}/history", + status_code=404, + json={"message": "Selected point of charge does not have status record yet"}, + ) + with pytest.raises( + APIRequestError, + match="Selected point of charge does not have status record yet", + ): + assert await anext(status.history(id_pdc_itinerance)) + + +@pytest.mark.anyio +async def test_dynamic_status_create(client, httpx_mock): + """Test the /dynamique/status endpoint call.""" + status = Status(client) + + data = {"id_pdc_itinerance": "FRS63E0001"} + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/status/", + status_code=201, + ) + assert await status.create(data) is None + + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/status/", + status_code=404, + json={"message": "Attached point of charge does not exist"}, + ) + with pytest.raises( + APIRequestError, match="Attached point of charge does not exist" + ): + assert await status.create(data) + + +@pytest.mark.anyio +async def test_dynamic_status_bulk(client, httpx_mock): + """Test the /dynamique/status/bulk endpoint call.""" + status = Status(client) + + # total % chunk_size == 0 + total = 30 + statuses = [{"id_pdc_itinerance": f"FRS63E00{x:02d}"} for x in range(total)] + for chunk_size in (5, 6, 10): + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/status/bulk", + json={"size": chunk_size}, + ) + assert await status.bulk(statuses, chunk_size=chunk_size) == total + + # total % chunk_size == 2 + total = 12 + statuses = [{"id_pdc_itinerance": f"FRS63E00{x:02d}"} for x in range(total)] + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/status/bulk", + json={"size": 10}, + ) + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/status/bulk", + json={"size": 2}, + ) + assert await status.bulk(statuses, chunk_size=10) == total + + # API Errors + total = 7 + statuses = [{"id_pdc_itinerance": f"FRS63E00{x:02d}"} for x in range(total)] + chunk_size = 5 + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/status/bulk", + status_code=500, + json={"message": "Invalid data"}, + ) + with pytest.raises(APIRequestError, match="Invalid data"): + await status.bulk(statuses, chunk_size=chunk_size) + + # Ignore errors + assert await status.bulk(statuses, chunk_size=chunk_size, ignore_errors=True) == 0 + + # What if an error occurs for the second chunk? + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/status/bulk", + json={"size": chunk_size}, + ) + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/status/bulk", + status_code=500, + json={"message": "Invalid data"}, + ) + assert ( + await status.bulk(statuses, chunk_size=chunk_size, ignore_errors=True) + == chunk_size + ) + + +@pytest.mark.anyio +async def test_dynamic_session_create(client, httpx_mock): + """Test the /dynamique/session endpoint call.""" + session = Session(client) + + data = {"id_pdc_itinerance": "FRS63E0001"} + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/session/", + status_code=201, + ) + assert await session.create(data) is None + + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/session/", + status_code=404, + json={"message": "Attached point of charge does not exist"}, + ) + with pytest.raises( + APIRequestError, match="Attached point of charge does not exist" + ): + assert await session.create(data) + + +@pytest.mark.anyio +async def test_dynamic_session_bulk(client, httpx_mock): + """Test the /dynamique/session/bulk endpoint call.""" + session = Session(client) + + # total % chunk_size == 0 + total = 30 + sessions = [{"id_pdc_itinerance": f"FRS63E00{x:02d}"} for x in range(total)] + for chunk_size in (5, 6, 10): + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/session/bulk", + json={"size": chunk_size}, + ) + assert await session.bulk(sessions, chunk_size=chunk_size) == total + + # total % chunk_size == 2 + total = 12 + sessions = [{"id_pdc_itinerance": f"FRS63E00{x:02d}"} for x in range(total)] + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/session/bulk", + json={"size": 10}, + ) + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/session/bulk", + json={"size": 2}, + ) + assert await session.bulk(sessions, chunk_size=10) == total + + # API Errors + total = 7 + sessions = [{"id_pdc_itinerance": f"FRS63E00{x:02d}"} for x in range(total)] + chunk_size = 5 + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/session/bulk", + status_code=500, + json={"message": "Invalid data"}, + ) + with pytest.raises(APIRequestError, match="Invalid data"): + await session.bulk(sessions, chunk_size=chunk_size) + + # Ignore errors + assert await session.bulk(sessions, chunk_size=chunk_size, ignore_errors=True) == 0 + + # What if an error occurs for the second chunk? + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/session/bulk", + json={"size": chunk_size}, + ) + httpx_mock.add_response( + method="POST", + url="http://example.com/api/v1/dynamique/session/bulk", + status_code=500, + json={"message": "Invalid data"}, + ) + assert ( + await session.bulk(sessions, chunk_size=chunk_size, ignore_errors=True) + == chunk_size + ) diff --git a/src/client/tests/endpoints/test_static.py b/src/client/tests/endpoints/test_static.py index 7a80c761..7cbe8161 100644 --- a/src/client/tests/endpoints/test_static.py +++ b/src/client/tests/endpoints/test_static.py @@ -41,13 +41,7 @@ async def test_static_list(client, httpx_mock): ) assert [item async for item in static.list()] == list(range(0, 20)) - -@pytest.mark.anyio -async def test_static_list_with_error(client, httpx_mock): - """Test the /statique endpoint call when an error occurs.""" - static = Static(client) - - # Raise an HTTP 500 error + # API errors httpx_mock.add_response( method="GET", url="http://example.com/api/v1/statique/", @@ -71,12 +65,7 @@ async def test_static_create(client, httpx_mock): ) assert await static.create(statique) == statique - -@pytest.mark.anyio -async def test_static_create_with_error(client, httpx_mock): - """Test the /statique/ POST endpoint call when an error occurs.""" - static = Static(client) - + # API errors statique = {"id_pdc_itinerance": "FRS63E0001"} httpx_mock.add_response( method="POST", @@ -103,12 +92,7 @@ async def test_static_read(client, httpx_mock): "id_pdc_itinerance": id_pdc_itinerance } - -@pytest.mark.anyio -async def test_static_read_with_error(client, httpx_mock): - """Test the /statique/{id_pdc_itinerance} endpoint call when an error occurs.""" - static = Static(client) - + # API errors id_pdc_itinerance = "FRS63E0001" httpx_mock.add_response( method="GET", @@ -136,12 +120,7 @@ async def test_static_update(client, httpx_mock): "id_pdc_itinerance": id_pdc_itinerance } - -@pytest.mark.anyio -async def test_static_update_with_error(client, httpx_mock): - """Test the /statique/{id_pdc_itinerance} PUT endpoint call when an error occurs.""" - static = Static(client) - + # API errors id_pdc_itinerance = "FRS63E0001" statique = {"id_pdc_itinerance": "FRS63E0001"} httpx_mock.add_response( @@ -185,12 +164,7 @@ async def test_static_bulk(client, httpx_mock): ) assert await static.bulk(statiques, chunk_size=10) == total - -@pytest.mark.anyio -async def test_static_bulk_with_error(client, httpx_mock): - """Test the /statique/bulk endpoint call when an error occurs.""" - static = Static(client) - + # API errors total = 7 statiques = [{"id_pdc_itinerance": f"FRS63E00{x:02d}"} for x in range(total)] chunk_size = 5 diff --git a/src/client/tests/test_client.py b/src/client/tests/test_client.py index 4e7e9432..da360ab7 100644 --- a/src/client/tests/test_client.py +++ b/src/client/tests/test_client.py @@ -4,6 +4,7 @@ from qcc.client import QCC from qcc.endpoints.auth import Auth +from qcc.endpoints.dynamic import Session, Status from qcc.endpoints.static import Static from qcc.exceptions import ConfigurationError from qcc.http import HTTPClient @@ -34,3 +35,7 @@ def test_client_initialization(): assert qcc.auth.client == qcc.client assert isinstance(qcc.static, Static) assert qcc.static.client == qcc.client + assert isinstance(qcc.status, Status) + assert qcc.status.client == qcc.client + assert isinstance(qcc.session, Session) + assert qcc.session.client == qcc.client