diff --git a/docs/HEMS.rst b/docs/HEMS.rst index aceee1d9..ed24a9af 100644 --- a/docs/HEMS.rst +++ b/docs/HEMS.rst @@ -77,3 +77,9 @@ In the third terminal, run the client script using the `/examples/HEMS` folder a cd examples/HEMS python3 HEMS_setup.py + +.. note:: + Report generation (see :ref:`hems-tutorial` note above) shells out to a ``flexmeasures`` CLI process, which by default is expected on ``PATH`` and configured against the same database as the server. If your FlexMeasures server runs elsewhere (e.g. inside a Docker Compose service), point report generation at it instead via two environment variables: + + - ``FLEXMEASURES_CLI_CMD``: the command used to invoke the CLI, e.g. ``"docker compose exec -T server flexmeasures"``. + - ``FLEXMEASURES_CLI_CONFIG_DIR``: the directory the CLI process sees the ``examples/HEMS/configs/`` files at, if different from their local path (e.g. because that directory is bind-mounted into a container at a different path). diff --git a/examples/HEMS/reporters.py b/examples/HEMS/reporters.py index 36e90c90..958e387e 100644 --- a/examples/HEMS/reporters.py +++ b/examples/HEMS/reporters.py @@ -11,7 +11,11 @@ pv_name, ) from utils.asset_utils import find_sensors_by_asset -from utils.reporter_utils import fill_reporter_params, run_report_cmd +from utils.reporter_utils import ( + cli_command_prefix, + fill_reporter_params, + run_report_cmd, +) from flexmeasures_client import FlexMeasuresClient @@ -22,8 +26,9 @@ async def create_reports( """Generate reports using FlexMeasures CLI.""" print("Generating reports...") - # Check if flexmeasures CLI is available - check_cmd = ["which", "flexmeasures"] + # Check if the configured FlexMeasures CLI command is available + # (only meaningful to check the first token, e.g. "flexmeasures" or "docker") + check_cmd = ["which", cli_command_prefix()[0]] check_result = subprocess.run(check_cmd, capture_output=True, text=True) if check_result.returncode != 0: diff --git a/examples/HEMS/utils/reporter_utils.py b/examples/HEMS/utils/reporter_utils.py index 629eeaa0..105a35b8 100644 --- a/examples/HEMS/utils/reporter_utils.py +++ b/examples/HEMS/utils/reporter_utils.py @@ -1,11 +1,42 @@ import json import os +import shlex import subprocess from pathlib import Path BASE_DIR = Path(__file__).parent.parent +def cli_command_prefix() -> list[str]: + """ + The command used to invoke the FlexMeasures CLI, split into argv tokens. + + Defaults to a plain ``flexmeasures`` on PATH, which requires the CLI to be + installed locally and configured to talk to the same database as the + server the client is scripting against (see the "Running the Tutorial" + instructions in docs/HEMS.rst). + + Override via the ``FLEXMEASURES_CLI_CMD`` environment variable to run the + CLI elsewhere, e.g. inside a Docker Compose service: + + FLEXMEASURES_CLI_CMD="docker compose -f /path/to/docker-compose.yml exec -T server flexmeasures" + """ + return shlex.split(os.environ.get("FLEXMEASURES_CLI_CMD", "flexmeasures")) + + +def _cli_config_path(local_path: str) -> str: + """ + Translate a local config/parameter file path to the path the CLI process + will see it at, when that process runs somewhere other than this host + (e.g. inside a container with the ``configs/`` directory bind-mounted + elsewhere). Controlled via ``FLEXMEASURES_CLI_CONFIG_DIR``; a no-op if unset. + """ + remote_dir = os.environ.get("FLEXMEASURES_CLI_CONFIG_DIR") + if not remote_dir: + return local_path + return os.path.join(remote_dir, os.path.basename(local_path)) + + def fill_reporter_params( input_sensors: list[dict], output_sensors: list[dict] | dict, @@ -75,16 +106,22 @@ def run_report_cmd(reporter_map: dict, start: str, end: str) -> bool: start (str): Start time of the report period (ISO 8601 format). end (str): End time of the report period (ISO 8601 format). """ + config_path = os.path.join( + BASE_DIR, f"configs/{reporter_map['name']}_reporter_config.json" + ) + param_path = os.path.join( + BASE_DIR, f"configs/{reporter_map['name']}_reporter_param.json" + ) cmd = [ - "flexmeasures", + *cli_command_prefix(), "add", "report", "--reporter", reporter_map["reporter"], "--config", - os.path.join(BASE_DIR, f"configs/{reporter_map['name']}_reporter_config.json"), + _cli_config_path(config_path), "--parameters", - os.path.join(BASE_DIR, f"configs/{reporter_map['name']}_reporter_param.json"), + _cli_config_path(param_path), "--start", start, "--end", diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index c3a568fb..2757b618 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -604,8 +604,10 @@ async def _post_sensor_data_file( allow_redirects=False, ) - # Check response status - if response.status != 200: + # Check response status (any 2xx is success, e.g. 202 Accepted + # for asynchronous processing, matching check_for_status()'s + # convention used elsewhere in this client) + if not (200 <= response.status < 300): try: error_data = await response.json() error_message = f"Request failed with status code {response.status}: {error_data}" @@ -619,7 +621,8 @@ async def _post_sensor_data_file( # Parse response response_data = await response.json() self.logger.info( - f"File uploaded successfully: {os.path.basename(file_path)}" + f"File uploaded successfully: {os.path.basename(file_path)} " + f"(status {response.status})" ) return response_data, response.status diff --git a/tests/client/test_sensor.py b/tests/client/test_sensor.py index 2080566b..463e300f 100644 --- a/tests/client/test_sensor.py +++ b/tests/client/test_sensor.py @@ -436,6 +436,36 @@ async def test_post_sensor_data_with_file(): os.unlink(csv_path) +@pytest.mark.asyncio +async def test_post_sensor_data_with_file_accepted(): + """202 Accepted (asynchronous processing) is treated as success, not an error.""" + csv_path = "/tmp/test_sensor_data_accepted.csv" + with open(csv_path, "w") as f: + f.write("datetime,value\n2023-01-01T00:00+00:00,1.0\n") + + try: + with aioresponses() as m: + client = FlexMeasuresClient(email="test@test.test", password="test") + client.access_token = "test-token" + m.post( + "http://localhost:5000/api/v3_0/sensors/1/data/upload", + status=202, + payload={ + "job_id": "test-job-id", + "message": "Sensor data has been accepted for processing.", + "status": "ACCEPTED", + }, + ) + response_data, status = await client.post_sensor_data( + sensor_id=1, + file_path=csv_path, + ) + assert status == 202 + await client.close() + finally: + os.unlink(csv_path) + + @pytest.mark.asyncio async def test_post_sensor_data_json_with_prior(): """prior parameter is included in payload."""