diff --git a/.github/auto-assign.yml b/.github/auto-assign.yml index 740c50523..5b0b21e2f 100644 --- a/.github/auto-assign.yml +++ b/.github/auto-assign.yml @@ -4,12 +4,13 @@ addReviewers: true # Set to 'author' to add PR's author as a assignee addAssignees: author -# A list of reviewers to be added to PRs (GitHub user name) +# A list of reviewers to be added to PRs (GitHub user name) reviewers: - Gui-FernandesBR - giovaniceotto - MateusStano - + - phmbressan + # A number of reviewers added to the PR # Set 0 to add all the reviewers (default: 0) numberOfReviewers: 0 @@ -17,3 +18,5 @@ numberOfReviewers: 0 # A list of keywords to be skipped the process if PR's title include it skipKeywords: - wip + - work in progress + - draft diff --git a/.github/workflows/auto-assign-projects b/.github/workflows/auto-assign-projects deleted file mode 100644 index 0c75b7aed..000000000 --- a/.github/workflows/auto-assign-projects +++ /dev/null @@ -1,21 +0,0 @@ -name: Auto Assign to Project(s) - -on: - issues: - types: [opened] - pull_request: - types: [opened] -env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - -jobs: - assign_one_project: - runs-on: ubuntu-latest - name: Assign to One Project - steps: - - name: Assign NEW issues and NEW pull requests to RocketPy's main project - uses: srggrs/assign-one-project-github-action@1.3.1 - if: github.event.action == 'opened' - with: - project: 'https://github.com/orgs/RocketPy-Team/projects/1' - column_name: '🆕 New' diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml index 122a3107d..c58140c0d 100644 --- a/.github/workflows/auto-assign.yml +++ b/.github/workflows/auto-assign.yml @@ -1,7 +1,5 @@ -name: Auto Assign Issues and PRs once opened +name: Auto Assign PRs once opened on: - issues: - types: [opened] pull_request: types: [opened] jobs: diff --git a/.github/workflows/test_pytest.yaml b/.github/workflows/test_pytest.yaml index c06c3d1f6..8d71eb857 100644 --- a/.github/workflows/test_pytest.yaml +++ b/.github/workflows/test_pytest.yaml @@ -23,10 +23,15 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements-tests.txt - - name: Build RocketPy + - name: Build RocketPy (without optional dependencies) + run: | + pip install . + - name: Import rocketpy in python and test if it works run: | - pip install -e.[all] + python -c "import sys, rocketpy; print(f'{rocketpy.__name__} running on Python {sys.version}')" + - name: Install optional dependencies + run: | + pip install -r requirements-tests.txt - name: Test with pytest run: | pytest diff --git a/docs/user/requirements.rst b/docs/user/requirements.rst index a5edf83e9..3e56829ae 100644 --- a/docs/user/requirements.rst +++ b/docs/user/requirements.rst @@ -65,6 +65,7 @@ In case you want to use this class, you will need to install the following packa - `timezonefinder` : to allow for automatic timezone detection, - `windrose` : to allow for windrose plots, +- `ipython` : to allow for animated plots, - `ipywidgets` : to allow for GIFs generation, - `jsonpickle` : to allow for saving and loading of class instances. diff --git a/requirements-optional.txt b/requirements-optional.txt new file mode 100644 index 000000000..699bbde14 --- /dev/null +++ b/requirements-optional.txt @@ -0,0 +1,5 @@ +windrose>=1.6.8 +ipython +ipywidgets>=7.6.3 +jsonpickle +timezonefinder \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9b7619ead..18463c5d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,11 +2,6 @@ numpy>=1.13 scipy>=1.0 matplotlib>=3.0 netCDF4>=1.6.4 -windrose>=1.6.8 -ipywidgets>=7.6.3 requests pytz -timezonefinder simplekml -jsonpickle -numericalunits diff --git a/rocketpy/EnvironmentAnalysis.py b/rocketpy/EnvironmentAnalysis.py index c661ebca7..172402d3a 100644 --- a/rocketpy/EnvironmentAnalysis.py +++ b/rocketpy/EnvironmentAnalysis.py @@ -10,7 +10,6 @@ import json from collections import defaultdict -import matplotlib.ticker as mtick import netCDF4 import numpy as np import pytz @@ -19,23 +18,14 @@ from rocketpy.Function import Function from rocketpy.units import convert_units -try: - import ipywidgets as widgets - import jsonpickle - from timezonefinder import TimezoneFinder - from windrose import WindroseAxes -except ImportError as error: - raise ImportError( - f"The following error was encountered while importing dependencies: '{error}'. " - "Please note that the EnvironmentAnalysis requires additional dependencies, " - "which can be installed by running 'pip install rocketpy[env_analysis]'." - ) from .plots.environment_analysis_plots import _EnvironmentAnalysisPlots from .prints.environment_analysis_prints import _EnvironmentAnalysisPrints from .tools import ( bilinear_interpolation, + check_requirement_version, geopotential_to_height_agl, geopotential_to_height_asl, + import_optional_dependency, time_num_to_date_string, ) @@ -194,6 +184,9 @@ def __init__( self.unit_system = unit_system self.max_expected_altitude = max_expected_altitude + # Check if extra requirements are installed + self.__check_requirements() + # Manage units and timezones self.__init_data_parsing_units() self.__find_preferred_timezone() @@ -232,6 +225,39 @@ def __init__( # Private, auxiliary methods + def __check_requirements(self): + """Check if extra requirements are installed. If not, print a message + informing the user that some methods may not work and how to install + the extra requirements for environment analysis. + + Returns + ------- + None + """ + env_analysis_require = { # The same as in the setup.py file + "timezonefinder": "", + "windrose": ">=1.6.8", + "IPython": "", + "ipywidgets": ">=7.6.3", + "jsonpickle": "", + } + has_error = False + for module_name, version in env_analysis_require.items(): + version = ">=0" if not version else version + try: + check_requirement_version(module_name, version) + except (ValueError, ImportError) as e: + has_error = True + print( + f"The following error occurred while importing {module_name}: {e}" + ) + if has_error: + print( + "Given the above errors, some methods may not work. Please run " + + "'pip install rocketpy[env_analysis]' to install extra requirements." + ) + return None + def __init_surface_dictionary(self): # Create dictionary of file variable names to process surface data return { @@ -382,10 +408,19 @@ def __localize_input_dates(self): def __find_preferred_timezone(self): if self.preferred_timezone is None: # Use local timezone based on lat lon pair - tf = TimezoneFinder() - self.preferred_timezone = pytz.timezone( - tf.timezone_at(lng=self.longitude, lat=self.latitude) - ) + try: + timezonefinder = import_optional_dependency("timezonefinder") + tf = timezonefinder.TimezoneFinder() + self.preferred_timezone = pytz.timezone( + tf.timezone_at(lng=self.longitude, lat=self.latitude) + ) + except ImportError: + print( + "'timezonefinder' not installed, defaulting to UTC." + + " Install timezonefinder to get local time zone." + + " To do so, run 'pip install timezonefinder'" + ) + self.preferred_timezone = pytz.timezone("UTC") elif isinstance(self.preferred_timezone, str): self.preferred_timezone = pytz.timezone(self.preferred_timezone) @@ -2727,6 +2762,7 @@ def load(self, filename="env_analysis_dict"): EnvironmentAnalysis object """ + jsonpickle = import_optional_dependency("jsonpickle") encoded_class = open(filename).read() return jsonpickle.decode(encoded_class) @@ -2743,6 +2779,7 @@ def save(self, filename="env_analysis_dict"): ------- None """ + jsonpickle = import_optional_dependency("jsonpickle") encoded_class = jsonpickle.encode(self) file = open(filename, "w") file.write(encoded_class) diff --git a/rocketpy/plots/environment_analysis_plots.py b/rocketpy/plots/environment_analysis_plots.py index ffd5491ef..773933d87 100644 --- a/rocketpy/plots/environment_analysis_plots.py +++ b/rocketpy/plots/environment_analysis_plots.py @@ -2,20 +2,17 @@ __copyright__ = "Copyright 20XX, RocketPy Team" __license__ = "MIT" -import ipywidgets as widgets import matplotlib.pyplot as plt import matplotlib.ticker as mtick import numpy as np -from IPython.display import HTML from matplotlib import pyplot as plt from matplotlib.animation import FuncAnimation from matplotlib.animation import PillowWriter as ImageWriter from scipy import stats -from windrose import WindroseAxes from rocketpy.units import convert_units -from ..tools import find_two_closest_integers +from ..tools import find_two_closest_integers, import_optional_dependency # TODO: `wind_speed_limit` and `clear_range_limits` and should be numbers, not booleans @@ -856,6 +853,8 @@ def plot_wind_rose( ------- WindroseAxes """ + windrose = import_optional_dependency("windrose") + WindroseAxes = windrose.WindroseAxes ax = WindroseAxes.from_ax(fig=fig, rect=rect) ax.bar( wind_direction, @@ -990,8 +989,9 @@ def animate_average_wind_rose(self, figsize=(5, 5), filename="wind_rose.gif"): Returns ------- - Image : ipywidgets.widgets.widget_media.Image + Image : ipywidgets.widget_media.Image """ + widgets = import_optional_dependency("ipywidgets") metadata = dict( title="windrose", artist="windrose", @@ -1113,6 +1113,9 @@ def animate_wind_gust_distribution(self): HTML : IPython.core.display.HTML The animation as an HTML object """ + module = import_optional_dependency("IPython.display") + HTML = module.HTML # this is a class + # Gather animation data wind_gusts = self.env_analysis.surface_wind_gust_by_hour @@ -1311,6 +1314,9 @@ def animate_surface_wind_speed_distribution(self, wind_speed_limit=False): ------- HTML : IPython.core.display.HTML """ + module = import_optional_dependency("IPython.display") + HTML = module.HTML # this is a class + # Gather animation data surface_wind_speeds_at_given_hour = self.env_analysis.surface_wind_speed_by_hour @@ -1608,6 +1614,8 @@ def animate_wind_speed_profile(self, clear_range_limits=False): Whether to clear the sky range limits or not, by default False. This is useful when the launch site is constrained in terms or altitude. """ + module = import_optional_dependency("IPython.display") + HTML = module.HTML # this is a class # Create animation fig, ax = plt.subplots(dpi=200) @@ -1687,6 +1695,8 @@ def animate_wind_heading_profile(self, clear_range_limits=False): Whether to clear the sky range limits or not, by default False. This is useful when the launch site is constrained in terms or altitude. """ + module = import_optional_dependency("IPython.display") + HTML = module.HTML # this is a class # Create animation fig, ax = plt.subplots(dpi=200) diff --git a/rocketpy/tools.py b/rocketpy/tools.py index 3a4f97270..44df0d778 100644 --- a/rocketpy/tools.py +++ b/rocketpy/tools.py @@ -1,12 +1,17 @@ -from itertools import product +import importlib, importlib.metadata +import re +from bisect import bisect_left from cmath import isclose +from itertools import product -_NOT_FOUND = object() - -import numpy as np import pytz from cftime import num2pydate -from bisect import bisect_left +from packaging import version as packaging_version + +_NOT_FOUND = object() + +# Mapping of module name and the name of the package that should be installed +INSTALL_MAPPING = {"IPython": "ipython"} class cached_property: @@ -1231,6 +1236,86 @@ def find_closest(ordered_sequence, value): return pivot_index - 1 if value - smaller <= greater - value else pivot_index +def import_optional_dependency(name): + """Import an optional dependency. If the dependency is not installed, an + ImportError is raised. This function is based on the implementation found in + pandas repository: + github.com/pandas-dev/pandas/blob/main/pandas/compat/_optional.py + + Parameters + ---------- + name : str + The name of the module to import. Can be used to import submodules too. + The name will be used as an argument to importlib.import_module method. + + Examples: + --------- + >>> from rocketpy.tools import import_optional_dependency + >>> matplotlib = import_optional_dependency("matplotlib") + >>> matplotlib.__name__ + 'matplotlib' + >>> plt = import_optional_dependency("matplotlib.pyplot") + >>> plt.__name__ + 'matplotlib.pyplot' + """ + try: + module = importlib.import_module(name) + except ImportError as exc: + module_name = name.split(".")[0] + package_name = INSTALL_MAPPING.get(module_name, module_name) + raise ImportError( + f"{package_name} is an optional dependency and is not installed.\n" + + f"\t\tUse 'pip install {package_name}' to install it or " + + "'pip install rocketpy[all]' to install all optional dependencies." + ) from exc + return module + + +def check_requirement_version(module_name, version): + """This function tests if a module is installed and if the version is + correct. If the module is not installed, an ImportError is raised. If the + version is not correct, an error is raised. + + Parameters + ---------- + module_name : str + The name of the module to be tested. + version : str + The version of the module that is required. The string must start with + one of the following operators: ">", "<", ">=", "<=", "==", "!=". + + Example: + -------- + >>> from rocketpy.tools import check_requirement_version + >>> check_requirement_version("numpy", ">=1.0.0") + True + >>> check_requirement_version("matplotlib", ">=3.0") + True + """ + operators = [">=", "<=", "==", ">", "<", "!="] + # separator the operator from the version number + operator, v_number = re.match(f"({'|'.join(operators)})(.*)", version).groups() + + if operator not in operators: + raise ValueError( + f"Version must start with one of the following operators: {operators}" + ) + if importlib.util.find_spec(module_name) is None: + raise ImportError( + f"{module_name} is not installed. You can install it by running " + + f"'pip install {module_name}'" + ) + installed_version = packaging_version.parse(importlib.metadata.version(module_name)) + required_version = packaging_version.parse(v_number) + if installed_version < required_version: + raise ImportError( + f"{module_name} version is {installed_version}, which is not correct" + + f". A version {version} is required. You can install a correct " + + f"version by running 'pip install {module_name}{version}'" + ) + return True + + if __name__ == "__main__": import doctest diff --git a/setup.py b/setup.py index 24dd36f17..e481e9822 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,7 @@ env_analysis_require = [ "timezonefinder", "windrose>=1.6.8", + "IPython", "ipywidgets>=7.6.3", "jsonpickle", ] diff --git a/tests/test_environment_analysis.py b/tests/test_environment_analysis.py index a09d48e00..7b5d9570c 100644 --- a/tests/test_environment_analysis.py +++ b/tests/test_environment_analysis.py @@ -2,10 +2,10 @@ import os from unittest.mock import patch -import ipywidgets as widgets import matplotlib as plt import pytest -from IPython.display import HTML + +from rocketpy.tools import import_optional_dependency plt.rcParams.update({"figure.max_open_warning": 0}) @@ -146,6 +146,9 @@ def test_animation_plots(mock_show, env_analysis): env_analysis : EnvironmentAnalysis A simple object of the EnvironmentAnalysis class. """ + # import dependencies + widgets = import_optional_dependency("ipywidgets") + HTML = import_optional_dependency("IPython.display").HTML # Check animation plots assert isinstance(env_analysis.plots.animate_average_wind_rose(), widgets.Image)