From 05ab711751ca680382bcf2a18115e722507487cb Mon Sep 17 00:00:00 2001 From: Marchma0 Date: Tue, 4 Nov 2025 18:02:45 +0000 Subject: [PATCH 1/8] Add the function to create a motor from the API of thrustcurve and the test --- rocketpy/motors/motor.py | 59 ++++++++++++++++++++++++++ tests/unit/motors/test_genericmotor.py | 47 ++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index 7930ed52b..cc21c822b 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1,4 +1,6 @@ +import base64 import re +import tempfile import warnings import xml.etree.ElementTree as ET from abc import ABC, abstractmethod @@ -6,6 +8,7 @@ from os import path import numpy as np +import requests from ..mathutils.function import Function, funcify_method from ..plots.motor_plots import _MotorPlots @@ -1914,6 +1917,62 @@ def load_from_rse_file( coordinate_system_orientation=coordinate_system_orientation, ) + @staticmethod + def load_from_thrustcurve_api(name: str, **kwargs): + """ + Creates a Motor instance by downloading a .eng file from the ThrustCurve API + based on the given motor name. + + Parameters + ---------- + name : str + The motor name according to the API (e.g., "Cesaroni_M1670"). + **kwargs : + Additional arguments passed to the Motor constructor, such as dry_mass, nozzle_radius, etc. + + Returns + ------- + instance : cls + A new Motor instance initialized using the downloaded .eng file. + """ + + base_url = "https://www.thrustcurve.org/api/v1" + + # Step 1. Search motor + response = requests.get(f"{base_url}/search.json", params={"commonName": name}) + response.raise_for_status() + data = response.json() + + if not data.get("results"): + print("No motor found.") + return None + + motor = data["results"][0] + motor_id = motor["motorId"] + designation = motor["designation"].replace("/", "-") + print(f"Motor found: {designation} ({motor['manufacturer']})") + + # Step 2. Download the .eng file + dl_response = requests.get( + f"{base_url}/download.json", + params={"motorIds": motor_id, "format": "RASP", "data": "file"}, + ) + dl_response.raise_for_status() + data = dl_response.json() + + data_base64 = data["results"][0]["data"] + data_bytes = base64.b64decode(data_base64) + + # Step 3. Create the motor from the .eng file + + with tempfile.NamedTemporaryFile(suffix=".eng", delete=True) as tmp_file: + tmp_file.write(data_bytes) + tmp_file.flush() + + motor = GenericMotor.load_from_eng_file(tmp_file.name, **kwargs) + + return motor + def all_info(self): """Prints out all data and graphs available about the Motor.""" # Print motor details diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index 776d7b691..82ff4547e 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -211,3 +211,50 @@ def test_load_from_rse_file(generic_motor): assert thrust_curve[0][1] == 0.0 # First thrust point assert thrust_curve[-1][0] == 2.2 # Last point of time assert thrust_curve[-1][1] == 0.0 # Last thrust point + + +def test_load_from_thrustcurve_api(generic_motor): + """Tests the GenericMotor.load_from_thrustcurve_api method. + + Parameters + ---------- + generic_motor : rocketpy.GenericMotor + The GenericMotor object to be used in the tests. + """ + # using cesaroni data as example + burn_time = (0, 3.9) + dry_mass = 5.231 - 3.101 # 2.130 kg + propellant_initial_mass = 3.101 + chamber_radius = 75 / 1000 + chamber_height = 757 / 1000 + nozzle_radius = chamber_radius * 0.85 # 85% of chamber radius + + # Parameters from manual testing using the SolidMotor class as a reference + average_thrust = 1545.218 + total_impulse = 6026.350 + max_thrust = 2200.0 + exhaust_velocity = 1943.357 + + # creating motor from .eng file + generic_motor = generic_motor.load_from_thrustcurve_api("M1670") + + # testing relevant parameters + assert generic_motor.burn_time == burn_time + assert generic_motor.dry_mass == dry_mass + assert generic_motor.propellant_initial_mass == propellant_initial_mass + assert generic_motor.chamber_radius == chamber_radius + assert generic_motor.chamber_height == chamber_height + assert generic_motor.chamber_position == 0 + assert generic_motor.average_thrust == pytest.approx(average_thrust) + assert generic_motor.total_impulse == pytest.approx(total_impulse) + assert generic_motor.exhaust_velocity.average(*burn_time) == pytest.approx( + exhaust_velocity + ) + assert generic_motor.max_thrust == pytest.approx(max_thrust) + assert generic_motor.nozzle_radius == pytest.approx(nozzle_radius) + + # testing thrust curve + _, _, points = Motor.import_eng("data/motors/cesaroni/Cesaroni_M1670.eng") + assert generic_motor.thrust.y_array == pytest.approx( + Function(points, "Time (s)", "Thrust (N)", "linear", "zero").y_array + ) From da39fcbe39e763da46c33b7d7027e07548ccf063 Mon Sep 17 00:00:00 2001 From: monta Date: Tue, 4 Nov 2025 22:31:36 +0100 Subject: [PATCH 2/8] Improve load_from_thrustcurve_api and test_load_from_thrustcurve_api with clean imports --- rocketpy/motors/motor.py | 80 +++++++++++++++------- tests/unit/motors/test_genericmotor.py | 93 +++++++++++++++++++------- 2 files changed, 123 insertions(+), 50 deletions(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index cc21c822b..8678ce65d 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -5,16 +5,18 @@ import xml.etree.ElementTree as ET from abc import ABC, abstractmethod from functools import cached_property -from os import path +from os import path, remove import numpy as np import requests - +import logging from ..mathutils.function import Function, funcify_method from ..plots.motor_plots import _MotorPlots from ..prints.motor_prints import _MotorPrints from ..tools import parallel_axis_theorem_from_com, tuple_handler +logger = logging.getLogger(__name__) + # pylint: disable=too-many-public-methods class Motor(ABC): @@ -1916,7 +1918,7 @@ def load_from_rse_file( interpolation_method=interpolation_method, coordinate_system_orientation=coordinate_system_orientation, ) - + @staticmethod def load_from_thrustcurve_api(name: str, **kwargs): """ @@ -1926,16 +1928,25 @@ def load_from_thrustcurve_api(name: str, **kwargs): Parameters ---------- name : str - The motor name according to the API (e.g., "Cesaroni_M1670"). + The motor name according to the API (e.g., "Cesaroni_M1670" or "M1670"). + Both manufacturer-prefixed and shorthand names are commonly used; if multiple + motors match the search, the first result is used. **kwargs : - Additional arguments passed to the Motor constructor, such as dry_mass, nozzle_radius, etc. + Additional arguments passed to the Motor constructor or loader, such as + dry_mass, nozzle_radius, etc. Returns ------- - instance : cls - A new Motor instance initialized using the downloaded .eng file. + instance : GenericMotor + A new GenericMotor instance initialized using the downloaded .eng file. + + Raises + ------ + ValueError + If no motor is found or if the downloaded .eng data is missing. + requests.exceptions.RequestException + If a network or HTTP error occurs during the API call. """ - base_url = "https://www.thrustcurve.org/api/v1" # Step 1. Search motor @@ -1944,13 +1955,17 @@ def load_from_thrustcurve_api(name: str, **kwargs): data = response.json() if not data.get("results"): - print("No motor found.") - return None + raise ValueError( + f"No motor found for name '{name}'. " + "Please verify the motor name format (e.g., 'Cesaroni_M1670' or 'M1670') and try again." + ) - motor = data["results"][0] - motor_id = motor["motorId"] - designation = motor["designation"].replace("/", "-") - print(f"Motor found: {designation} ({motor['manufacturer']})") + motor_info = data["results"][0] + motor_id = motor_info.get("motorId") + designation = motor_info.get("designation", "").replace("/", "-") + manufacturer = motor_info.get("manufacturer", "") + # Logging the fact that the motor was found + logger.info(f"Motor found: {designation} ({manufacturer})") # Step 2. Download the .eng file dl_response = requests.get( @@ -1958,20 +1973,37 @@ def load_from_thrustcurve_api(name: str, **kwargs): params={"motorIds": motor_id, "format": "RASP", "data": "file"}, ) dl_response.raise_for_status() - data = dl_response.json() + dl_data = dl_response.json() - data_base64 = data["results"][0]["data"] - data_bytes = base64.b64decode(data_base64) - - # Step 3. Create the motor from the .eng file + if not dl_data.get("results"): + raise ValueError(f"No .eng file found for motor '{name}' in the ThrustCurve API.") - with tempfile.NamedTemporaryFile(suffix=".eng", delete=True) as tmp_file: - tmp_file.write(data_bytes) - tmp_file.flush() + data_base64 = dl_data["results"][0].get("data") + if not data_base64: + raise ValueError(f"Downloaded .eng data for motor '{name}' is empty or invalid.") - motor = GenericMotor.load_from_eng_file(tmp_file.name, **kwargs) + data_bytes = base64.b64decode(data_base64) - return motor + # Step 3. Create the motor from the .eng file + tmp_path = None + try: + # create a temporary file that persists until we explicitly remove it + with tempfile.NamedTemporaryFile(suffix=".eng", delete=False) as tmp_file: + tmp_file.write(data_bytes) + tmp_file.flush() + tmp_path = tmp_file.name + + + motor_instance = GenericMotor.load_from_eng_file(tmp_path, **kwargs) + return motor_instance + finally: + # Ensuring the temporary file is removed + if tmp_path and path.exists(tmp_path): + try: + remove(tmp_path) + except OSError: + # If cleanup fails, don't raise: we don't want to mask prior exceptions. + pass def all_info(self): """Prints out all data and graphs available about the Motor.""" diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index 82ff4547e..3a4a41873 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -1,6 +1,9 @@ import numpy as np import pytest import scipy.integrate +import requests +import base64 + from rocketpy import Function, Motor @@ -212,16 +215,56 @@ def test_load_from_rse_file(generic_motor): assert thrust_curve[-1][0] == 2.2 # Last point of time assert thrust_curve[-1][1] == 0.0 # Last thrust point - -def test_load_from_thrustcurve_api(generic_motor): - """Tests the GenericMotor.load_from_thrustcurve_api method. - +def test_load_from_thrustcurve_api(monkeypatch, generic_motor): + """ + Tests the GenericMotor.load_from_thrustcurve_api method with mocked ThrustCurve API responses. Parameters ---------- + monkeypatch : pytest.MonkeyPatch + The pytest monkeypatch fixture for mocking. generic_motor : rocketpy.GenericMotor The GenericMotor object to be used in the tests. + """ - # using cesaroni data as example + + class MockResponse: + def __init__(self, json_data): + self._json_data = json_data + + def json(self): + return self._json_data + + def raise_for_status(self): + # Simulate a successful HTTP response (200) + return None + + # Provide mocked responses for the two endpoints: search.json and download.json + def mock_get(url, params=None): + if "search.json" in url: + # Return a mock search result with a motorId and designation + return MockResponse( + { + "results": [ + { + "motorId": "12345", + "designation": "Cesaroni_M1670", + "manufacturer": "Cesaroni", + } + ] + } + ) + elif "download.json" in url: + # Read the local .eng file and return its base64-encoded content as the API would + eng_path = "data/motors/cesaroni/Cesaroni_M1670.eng" + with open(eng_path, "rb") as f: + encoded = base64.b64encode(f.read()).decode("utf-8") + return MockResponse({"results": [{"data": encoded}]}) + else: + raise RuntimeError(f"Unexpected URL called in test mock: {url}") + + monkeypatch.setattr(requests, "get", mock_get) + + # Expected parameters from the original test burn_time = (0, 3.9) dry_mass = 5.231 - 3.101 # 2.130 kg propellant_initial_mass = 3.101 @@ -229,32 +272,30 @@ def test_load_from_thrustcurve_api(generic_motor): chamber_height = 757 / 1000 nozzle_radius = chamber_radius * 0.85 # 85% of chamber radius - # Parameters from manual testing using the SolidMotor class as a reference average_thrust = 1545.218 total_impulse = 6026.350 max_thrust = 2200.0 exhaust_velocity = 1943.357 - # creating motor from .eng file - generic_motor = generic_motor.load_from_thrustcurve_api("M1670") - - # testing relevant parameters - assert generic_motor.burn_time == burn_time - assert generic_motor.dry_mass == dry_mass - assert generic_motor.propellant_initial_mass == propellant_initial_mass - assert generic_motor.chamber_radius == chamber_radius - assert generic_motor.chamber_height == chamber_height - assert generic_motor.chamber_position == 0 - assert generic_motor.average_thrust == pytest.approx(average_thrust) - assert generic_motor.total_impulse == pytest.approx(total_impulse) - assert generic_motor.exhaust_velocity.average(*burn_time) == pytest.approx( - exhaust_velocity - ) - assert generic_motor.max_thrust == pytest.approx(max_thrust) - assert generic_motor.nozzle_radius == pytest.approx(nozzle_radius) - - # testing thrust curve + # Call the method using the class (works if it's a staticmethod); using type(generic_motor) + # ensures test works if the method is invoked on a GenericMotor instance in the project + motor = type(generic_motor).load_from_thrustcurve_api("M1670") + + # Assertions (same as original) + assert motor.burn_time == burn_time + assert motor.dry_mass == dry_mass + assert motor.propellant_initial_mass == propellant_initial_mass + assert motor.chamber_radius == chamber_radius + assert motor.chamber_height == chamber_height + assert motor.chamber_position == 0 + assert motor.average_thrust == pytest.approx(average_thrust) + assert motor.total_impulse == pytest.approx(total_impulse) + assert motor.exhaust_velocity.average(*burn_time) == pytest.approx(exhaust_velocity) + assert motor.max_thrust == pytest.approx(max_thrust) + assert motor.nozzle_radius == pytest.approx(nozzle_radius) + + # testing thrust curve equality against the local .eng import (as in original test) _, _, points = Motor.import_eng("data/motors/cesaroni/Cesaroni_M1670.eng") - assert generic_motor.thrust.y_array == pytest.approx( + assert motor.thrust.y_array == pytest.approx( Function(points, "Time (s)", "Thrust (N)", "linear", "zero").y_array ) From 9fdc704ba4111991b6c9fd573248041cb784e281 Mon Sep 17 00:00:00 2001 From: monta Date: Wed, 5 Nov 2025 08:37:52 +0100 Subject: [PATCH 3/8] Clean up load_from_thrustcurve_api and improve test_load_from_thrustcurve_api with exception testing --- rocketpy/motors/motor.py | 19 ++++---- tests/unit/motors/test_genericmotor.py | 63 ++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index 8678ce65d..1f548a48d 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1,4 +1,5 @@ import base64 +import logging import re import tempfile import warnings @@ -9,7 +10,7 @@ import numpy as np import requests -import logging + from ..mathutils.function import Function, funcify_method from ..plots.motor_plots import _MotorPlots from ..prints.motor_prints import _MotorPrints @@ -1918,7 +1919,7 @@ def load_from_rse_file( interpolation_method=interpolation_method, coordinate_system_orientation=coordinate_system_orientation, ) - + @staticmethod def load_from_thrustcurve_api(name: str, **kwargs): """ @@ -1964,7 +1965,7 @@ def load_from_thrustcurve_api(name: str, **kwargs): motor_id = motor_info.get("motorId") designation = motor_info.get("designation", "").replace("/", "-") manufacturer = motor_info.get("manufacturer", "") - # Logging the fact that the motor was found + # Logging the fact that the motor was found logger.info(f"Motor found: {designation} ({manufacturer})") # Step 2. Download the .eng file @@ -1976,11 +1977,15 @@ def load_from_thrustcurve_api(name: str, **kwargs): dl_data = dl_response.json() if not dl_data.get("results"): - raise ValueError(f"No .eng file found for motor '{name}' in the ThrustCurve API.") + raise ValueError( + f"No .eng file found for motor '{name}' in the ThrustCurve API." + ) data_base64 = dl_data["results"][0].get("data") if not data_base64: - raise ValueError(f"Downloaded .eng data for motor '{name}' is empty or invalid.") + raise ValueError( + f"Downloaded .eng data for motor '{name}' is empty or invalid." + ) data_bytes = base64.b64decode(data_base64) @@ -1993,9 +1998,7 @@ def load_from_thrustcurve_api(name: str, **kwargs): tmp_file.flush() tmp_path = tmp_file.name - - motor_instance = GenericMotor.load_from_eng_file(tmp_path, **kwargs) - return motor_instance + return GenericMotor.load_from_eng_file(tmp_path, **kwargs) finally: # Ensuring the temporary file is removed if tmp_path and path.exists(tmp_path): diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index 3a4a41873..306f6cd74 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -1,9 +1,9 @@ +import base64 + import numpy as np import pytest -import scipy.integrate import requests -import base64 - +import scipy.integrate from rocketpy import Function, Motor @@ -215,6 +215,7 @@ def test_load_from_rse_file(generic_motor): assert thrust_curve[-1][0] == 2.2 # Last point of time assert thrust_curve[-1][1] == 0.0 # Last thrust point + def test_load_from_thrustcurve_api(monkeypatch, generic_motor): """ Tests the GenericMotor.load_from_thrustcurve_api method with mocked ThrustCurve API responses. @@ -224,7 +225,7 @@ def test_load_from_thrustcurve_api(monkeypatch, generic_motor): The pytest monkeypatch fixture for mocking. generic_motor : rocketpy.GenericMotor The GenericMotor object to be used in the tests. - + """ class MockResponse: @@ -299,3 +300,57 @@ def mock_get(url, params=None): assert motor.thrust.y_array == pytest.approx( Function(points, "Time (s)", "Thrust (N)", "linear", "zero").y_array ) + + # 1. No motor found + def mock_get_no_motor(url, params=None): + if "search.json" in url: + return MockResponse({"results": []}) + return MockResponse({"results": []}) + + monkeypatch.setattr(requests, "get", mock_get_no_motor) + with pytest.raises(ValueError, match="No motor found"): + type(generic_motor).load_from_thrustcurve_api("NonexistentMotor") + + # 2. No .eng file found + def mock_get_no_eng(url, params=None): + if "search.json" in url: + return MockResponse( + { + "results": [ + { + "motorId": "123", + "designation": "Fake", + "manufacturer": "Test", + } + ] + } + ) + elif "download.json" in url: + return MockResponse({"results": []}) + return MockResponse({}) + + monkeypatch.setattr(requests, "get", mock_get_no_eng) + with pytest.raises(ValueError, match="No .eng file found"): + type(generic_motor).load_from_thrustcurve_api("FakeMotor") + + # 3. Empty .eng data + def mock_get_empty_data(url, params=None): + if "search.json" in url: + return MockResponse( + { + "results": [ + { + "motorId": "123", + "designation": "Fake", + "manufacturer": "Test", + } + ] + } + ) + elif "download.json" in url: + return MockResponse({"results": [{"data": ""}]}) + return MockResponse({}) + + monkeypatch.setattr(requests, "get", mock_get_empty_data) + with pytest.raises(ValueError, match="Downloaded .eng data"): + type(generic_motor).load_from_thrustcurve_api("FakeMotor") From d123b47c9302eed2bfcf927cf3160c63e59a3143 Mon Sep 17 00:00:00 2001 From: monta Date: Wed, 5 Nov 2025 12:43:16 +0100 Subject: [PATCH 4/8] Use warnings.warn() in load_from_thrustcurve_api when motor is found (as requested) --- rocketpy/motors/motor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index 1f548a48d..dc1575fb1 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1,5 +1,4 @@ import base64 -import logging import re import tempfile import warnings @@ -16,8 +15,6 @@ from ..prints.motor_prints import _MotorPrints from ..tools import parallel_axis_theorem_from_com, tuple_handler -logger = logging.getLogger(__name__) - # pylint: disable=too-many-public-methods class Motor(ABC): @@ -1965,8 +1962,7 @@ def load_from_thrustcurve_api(name: str, **kwargs): motor_id = motor_info.get("motorId") designation = motor_info.get("designation", "").replace("/", "-") manufacturer = motor_info.get("manufacturer", "") - # Logging the fact that the motor was found - logger.info(f"Motor found: {designation} ({manufacturer})") + warnings.warn(f"Motor found: {designation} ({manufacturer})", UserWarning) # Step 2. Download the .eng file dl_response = requests.get( From d6c5deeb007e7095abb0c836274321ce8190709f Mon Sep 17 00:00:00 2001 From: monta Date: Wed, 5 Nov 2025 15:00:44 +0100 Subject: [PATCH 5/8] Added documentation for the load_from_thrustcurve_api method into the genericmotors.rst file --- docs/user/motors/genericmotor.rst | 80 +++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index f9da46fd0..35745597b 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -106,3 +106,83 @@ note that the user can still provide the parameters manually if needed. The ``load_from_eng_file`` method is a very useful tool for simulating motors \ when the user does not have all the information required to build a ``SolidMotor`` yet. +The ``load_from_thrustcurve_api`` method +--------------------------------------- + +The ``GenericMotor`` class provides a convenience loader that downloads a temporary +`.eng` file from the ThrustCurve.org public API and builds a ``GenericMotor`` +instance from it. This is useful when you know a motor designation (for example +``"M1670"``) but do not want to manually download and +save the `.eng` file. + +.. note:: + + This method performs network requests to the ThrustCurve API. Use it only + when you have network access. For automated testing or reproducible runs, + prefer using local `.eng` files. +Signature +---------- + +``GenericMotor.load_from_thrustcurve_api(name: str, **kwargs) -> GenericMotor`` + +Parameters +---------- +name : str + Motor name to search on ThrustCurve (example: + ``"M1670"``).Only shorthand names are accepted (e.g. ``"M1670"``, not + ``"Cesaroni M1670"``). + when multiple matches occur the first result returned by the API is used. +**kwargs : + Same optional arguments accepted by the :class:`GenericMotor` constructor + (e.g. ``dry_mass``, ``nozzle_radius``, ``interpolation_method``). Any + parameters provided here override values parsed from the downloaded file. + +Returns +---------- +GenericMotor + A new ``GenericMotor`` instance created from the .eng data downloaded from + ThrustCurve. + +Raises +---------- +ValueError + If the API search returns no motor, or if the download endpoint returns no + .eng file or empty/invalid data. +requests.exceptions.RequestException + +Behavior notes +--------------- +- The method first performs a search on ThrustCurve using the provided name. + If no results are returned a :class:`ValueError` is raised. +- If a motor is found the method requests the .eng file in RASP format, decodes + it and temporarily writes it to disk; a ``GenericMotor`` is then constructed + using the existing .eng file loader. The temporary file is removed even if an + error occurs. +- The function emits a non-fatal informational warning when a motor is found + (``warnings.warn(...)``). This follows the repository convention for + non-critical messages; callers can filter or suppress warnings as needed. + +Example +--------------- + +.. jupyter-execute:: + + from rocketpy.motors import GenericMotor + + # Build a motor by name (requires network access) + motor = GenericMotor.load_from_thrustcurve_api("M1670") + + # Use the motor as usual + motor.info() + +Testing advice +--------------- +- ``pytest``'s ``caplog`` or ``capfd`` to assert on log/warning output. + +Security & reliability +---------------- +- The method makes outgoing HTTP requests and decodes base64-encoded content; + validate inputs in upstream code if you accept motor names from untrusted + sources. +- Network failures, API rate limits, or changes to the ThrustCurve API may + break loading; consider caching downloaded `.eng` files for production use. \ No newline at end of file From 142eaf8566907596b67da955499253c8121d688c Mon Sep 17 00:00:00 2001 From: Marchma0 Date: Wed, 12 Nov 2025 14:41:32 +0000 Subject: [PATCH 6/8] Changes to conform to lint --- rocketpy/motors/motor.py | 42 +++++++++++++++++++++----- tests/unit/motors/test_genericmotor.py | 3 ++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index dc1575fb1..c2e00a428 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1918,9 +1918,9 @@ def load_from_rse_file( ) @staticmethod - def load_from_thrustcurve_api(name: str, **kwargs): + def call_thrustcurve_api(name: str): """ - Creates a Motor instance by downloading a .eng file from the ThrustCurve API + Download a .eng file from the ThrustCurve API based on the given motor name. Parameters @@ -1929,14 +1929,11 @@ def load_from_thrustcurve_api(name: str, **kwargs): The motor name according to the API (e.g., "Cesaroni_M1670" or "M1670"). Both manufacturer-prefixed and shorthand names are commonly used; if multiple motors match the search, the first result is used. - **kwargs : - Additional arguments passed to the Motor constructor or loader, such as - dry_mass, nozzle_radius, etc. Returns ------- - instance : GenericMotor - A new GenericMotor instance initialized using the downloaded .eng file. + data_base64 : String + The .eng file of the motor in base64 Raises ------ @@ -1982,7 +1979,38 @@ def load_from_thrustcurve_api(name: str, **kwargs): raise ValueError( f"Downloaded .eng data for motor '{name}' is empty or invalid." ) + return data_base64 + + @staticmethod + def load_from_thrustcurve_api(name: str, **kwargs): + """ + Creates a Motor instance by downloading a .eng file from the ThrustCurve API + based on the given motor name. + + Parameters + ---------- + name : str + The motor name according to the API (e.g., "Cesaroni_M1670" or "M1670"). + Both manufacturer-prefixed and shorthand names are commonly used; if multiple + motors match the search, the first result is used. + **kwargs : + Additional arguments passed to the Motor constructor or loader, such as + dry_mass, nozzle_radius, etc. + + Returns + ------- + instance : GenericMotor + A new GenericMotor instance initialized using the downloaded .eng file. + + Raises + ------ + ValueError + If no motor is found or if the downloaded .eng data is missing. + requests.exceptions.RequestException + If a network or HTTP error occurs during the API call. + """ + data_base64 = GenericMotor.call_thrustcurve_api(name) data_bytes = base64.b64decode(data_base64) # Step 3. Create the motor from the .eng file diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index 306f6cd74..40a19f24d 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -229,6 +229,9 @@ def test_load_from_thrustcurve_api(monkeypatch, generic_motor): """ class MockResponse: + """ + Class to Mock the API + """ def __init__(self, json_data): self._json_data = json_data From 8e03cbd6422a2cca90acfc36c3fc20ad9cc224b5 Mon Sep 17 00:00:00 2001 From: monta Date: Wed, 12 Nov 2025 16:42:08 +0100 Subject: [PATCH 7/8] Update GenericMotor documentation and API loader usage --- .github/workflows/linters.yml | 1 + .pylintrc | 2 +- docs/user/motors/genericmotor.rst | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 3e5a3e81d..3e9fe6154 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -29,6 +29,7 @@ jobs: pip install .[all] pip install .[tests] pip install pylint ruff + pip install --upgrade pylint - name: Ruff (lint) run: ruff check --output-format=github . - name: Ruff (format) diff --git a/.pylintrc b/.pylintrc index e417e0b11..434a36819 100644 --- a/.pylintrc +++ b/.pylintrc @@ -101,7 +101,7 @@ source-roots= # When enabled, pylint would attempt to guess common misconfiguration and emit # user-friendly hints instead of false-positive error messages. -suggestion-mode=yes +#suggestion-mode=yes # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst index 35745597b..8e4b78e31 100644 --- a/docs/user/motors/genericmotor.rst +++ b/docs/user/motors/genericmotor.rst @@ -1,5 +1,7 @@ +# pylint: disable=unrecognized-option .. _genericmotor: + GenericMotor Class Usage ======================== From 29a7aaae9a522d3921c1816ee47d1862aef6755c Mon Sep 17 00:00:00 2001 From: monta Date: Wed, 12 Nov 2025 17:12:13 +0100 Subject: [PATCH 8/8] Fix pylint warnings in GenericMotor tests --- .pylintrc | 1 + tests/unit/motors/test_genericmotor.py | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.pylintrc b/.pylintrc index 434a36819..e25204956 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,4 +1,5 @@ [MAIN] +# pylint: disable=unrecognized-option # Analyse import fallback blocks. This can be used to support both Python 2 and # 3 compatible code, which means that the block might have code that exists diff --git a/tests/unit/motors/test_genericmotor.py b/tests/unit/motors/test_genericmotor.py index 40a19f24d..d2880fddc 100644 --- a/tests/unit/motors/test_genericmotor.py +++ b/tests/unit/motors/test_genericmotor.py @@ -216,7 +216,7 @@ def test_load_from_rse_file(generic_motor): assert thrust_curve[-1][1] == 0.0 # Last thrust point -def test_load_from_thrustcurve_api(monkeypatch, generic_motor): +def test_load_from_thrustcurve_api(monkeypatch, _generic_motor): # pylint: disable=too-many-statements """ Tests the GenericMotor.load_from_thrustcurve_api method with mocked ThrustCurve API responses. Parameters @@ -243,7 +243,7 @@ def raise_for_status(self): return None # Provide mocked responses for the two endpoints: search.json and download.json - def mock_get(url, params=None): + def mock_get(url, _params=None): if "search.json" in url: # Return a mock search result with a motorId and designation return MockResponse( @@ -266,7 +266,7 @@ def mock_get(url, params=None): else: raise RuntimeError(f"Unexpected URL called in test mock: {url}") - monkeypatch.setattr(requests, "get", mock_get) + monkeypatch.setattr(requests, "get", mock_get) # noqa: F821 # Expected parameters from the original test burn_time = (0, 3.9) @@ -283,7 +283,7 @@ def mock_get(url, params=None): # Call the method using the class (works if it's a staticmethod); using type(generic_motor) # ensures test works if the method is invoked on a GenericMotor instance in the project - motor = type(generic_motor).load_from_thrustcurve_api("M1670") + motor = type(_generic_motor).load_from_thrustcurve_api("M1670") # noqa: F821 # Assertions (same as original) assert motor.burn_time == burn_time @@ -305,17 +305,17 @@ def mock_get(url, params=None): ) # 1. No motor found - def mock_get_no_motor(url, params=None): + def mock_get_no_motor(url, _params=None): if "search.json" in url: return MockResponse({"results": []}) return MockResponse({"results": []}) - monkeypatch.setattr(requests, "get", mock_get_no_motor) + monkeypatch.setattr(requests, "get", mock_get_no_motor) # noqa: F821 with pytest.raises(ValueError, match="No motor found"): - type(generic_motor).load_from_thrustcurve_api("NonexistentMotor") + type(_generic_motor).load_from_thrustcurve_api("NonexistentMotor") # 2. No .eng file found - def mock_get_no_eng(url, params=None): + def mock_get_no_eng(url, _params=None): if "search.json" in url: return MockResponse( { @@ -332,12 +332,12 @@ def mock_get_no_eng(url, params=None): return MockResponse({"results": []}) return MockResponse({}) - monkeypatch.setattr(requests, "get", mock_get_no_eng) + monkeypatch.setattr(requests, "get", mock_get_no_eng) # noqa :F821 with pytest.raises(ValueError, match="No .eng file found"): - type(generic_motor).load_from_thrustcurve_api("FakeMotor") + type(_generic_motor).load_from_thrustcurve_api("FakeMotor") #noqa:F821 # 3. Empty .eng data - def mock_get_empty_data(url, params=None): + def mock_get_empty_data(url, _params=None): if "search.json" in url: return MockResponse( { @@ -354,6 +354,6 @@ def mock_get_empty_data(url, params=None): return MockResponse({"results": [{"data": ""}]}) return MockResponse({}) - monkeypatch.setattr(requests, "get", mock_get_empty_data) + monkeypatch.setattr(requests, "get", mock_get_empty_data) #noqa: F821 with pytest.raises(ValueError, match="Downloaded .eng data"): - type(generic_motor).load_from_thrustcurve_api("FakeMotor") + type(_generic_motor).load_from_thrustcurve_api("FakeMotor") #noqa: F821