diff --git a/.github/conda-env/test-env.yml b/.github/conda-env/test-env.yml index cc91a1ade..a4944f768 100644 --- a/.github/conda-env/test-env.yml +++ b/.github/conda-env/test-env.yml @@ -6,6 +6,7 @@ dependencies: - pytest - pytest-cov - pytest-timeout + - pytest-xvfb - numpy - matplotlib - - scipy \ No newline at end of file + - scipy diff --git a/.github/workflows/control-slycot-src.yml b/.github/workflows/control-slycot-src.yml index ffbeca3f9..2ce2a11dd 100644 --- a/.github/workflows/control-slycot-src.yml +++ b/.github/workflows/control-slycot-src.yml @@ -13,19 +13,8 @@ jobs: path: python-control - name: Set up Python uses: actions/setup-python@v2 - - name: Install Python dependencies - run: | - # Set up conda - echo $CONDA/bin >> $GITHUB_PATH - - # Set up (virtual) X11 - sudo apt install -y xvfb - - # Install test tools - conda install pip pytest pytest-timeout - - # Install python-control dependencies - conda install numpy matplotlib scipy + - name: Install Python dependencies and test tools + run: pip install -v -e './python-control[test]' - name: Checkout Slycot uses: actions/checkout@v3 @@ -43,11 +32,10 @@ jobs: # Install compilers, libraries, and development environment sudo apt-get -y install gfortran cmake --fix-missing sudo apt-get -y install libblas-dev liblapack-dev - conda install -c conda-forge scikit-build setuptools-scm # Compile and install slycot - pip install -v --no-build-isolation --no-deps . + pip install -v . - name: Test with pytest working-directory: python-control - run: xvfb-run --auto-servernum pytest control/tests + run: pytest -v control/tests diff --git a/.github/workflows/install_examples.yml b/.github/workflows/install_examples.yml index b36ff3e7f..84cd706f5 100644 --- a/.github/workflows/install_examples.yml +++ b/.github/workflows/install_examples.yml @@ -1,4 +1,4 @@ -name: setup.py, examples +name: Setup, Examples, Notebooks on: [push, pull_request] @@ -7,26 +7,23 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - - name: Install Python dependencies + - uses: actions/checkout@v3 + - name: Install Python dependencies from conda-forge run: | - # Set up conda + # Set up conda using the preinstalled GHA Miniconda environment echo $CONDA/bin >> $GITHUB_PATH + conda config --add channels conda-forge + conda config --set channel_priority strict - # Set up (virtual) X11 - sudo apt install -y xvfb + # Install build tools + conda install pip setuptools setuptools-scm - # Install test tools - conda install pip pytest + # Install python-control dependencies and extras + conda install numpy matplotlib scipy + conda install slycot pmw jupyter - # Install python-control dependencies - conda install numpy matplotlib scipy jupyter - conda install -c conda-forge slycot pmw - - - name: Install with setup.py - run: python setup.py install + - name: Install from source + run: pip install . - name: Run examples run: | diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml index 4e287b45a..ae889fd05 100644 --- a/.github/workflows/python-package-conda.yml +++ b/.github/workflows/python-package-conda.yml @@ -8,30 +8,32 @@ jobs: Py${{ matrix.python-version }}; ${{ matrix.slycot || 'no' }} Slycot; ${{ matrix.pandas || 'no' }} Pandas; - ${{ matrix.cvxopt || 'no' }} CVXOPT; + ${{ matrix.cvxopt || 'no' }} CVXOPT ${{ matrix.array-and-matrix == 1 && '; array and matrix' || '' }} + ${{ matrix.mplbackend && format('; {0}', matrix.mplbackend) }} runs-on: ubuntu-latest strategy: max-parallel: 5 + fail-fast: false matrix: - python-version: [3.7, 3.9] + python-version: ['3.7', '3.10'] slycot: ["", "conda"] pandas: [""] cvxopt: ["", "conda"] + mplbackend: [""] array-and-matrix: [0] include: - - python-version: 3.9 + - python-version: '3.10' slycot: conda pandas: conda + cvxopt: conda + mplbackend: QtAgg array-and-matrix: 1 steps: - uses: actions/checkout@v3 - - name: Set up (virtual) X11 - run: sudo apt install -y xvfb - - name: Setup Conda uses: conda-incubator/setup-miniconda@v2 with: @@ -55,15 +57,15 @@ jobs: mamba install pandas fi if [[ '${{matrix.cvxopt}}' == 'conda' ]]; then - mamba install cvxopt + mamba install cvxopt fi - name: Test with pytest shell: bash -l {0} env: PYTHON_CONTROL_ARRAY_AND_MATRIX: ${{ matrix.array-and-matrix }} - run: | - xvfb-run --auto-servernum pytest --cov=control --cov-config=.coveragerc control/tests + MPLBACKEND: ${{ matrix.mplbackend }} + run: pytest -v --cov=control --cov-config=.coveragerc control/tests - name: Coveralls parallel # https://github.com/coverallsapp/github-action diff --git a/README.rst b/README.rst index f1feda7c5..7e2058293 100644 --- a/README.rst +++ b/README.rst @@ -97,17 +97,14 @@ To install using pip:: If you install Slycot using pip you'll need a development environment (e.g., Python development files, C and Fortran compilers). -Distutils ---------- +Installing from source +---------------------- -To install in your home directory, use:: +To install from source, get the source code of the desired branch or release +from the github repository or archive, unpack, and run from within the +toplevel `python-control` directory:: - python setup.py install --user - -To install for all users (on Linux or Mac OS):: - - python setup.py build - sudo python setup.py install + pip install . Development diff --git a/control/tests/config_test.py b/control/tests/config_test.py index 295c68bdd..c36f67280 100644 --- a/control/tests/config_test.py +++ b/control/tests/config_test.py @@ -8,7 +8,6 @@ from math import pi, log10 import matplotlib.pyplot as plt -from matplotlib.testing.decorators import cleanup as mplcleanup import numpy as np import pytest @@ -18,7 +17,6 @@ @pytest.mark.usefixtures("editsdefaults") # makes sure to reset the defaults # to the test configuration class TestConfig: - # Create a simple second order system to use for testing sys = ct.tf([10], [1, 2, 1]) @@ -28,8 +26,7 @@ def test_set_defaults(self): assert ct.config.defaults['freqplot.deg'] == 2 assert ct.config.defaults['freqplot.Hz'] is None - @mplcleanup - def test_get_param(self): + def test_get_param(self, mplcleanup): assert ct.config._get_param('freqplot', 'dB')\ == ct.config.defaults['freqplot.dB'] assert ct.config._get_param('freqplot', 'dB', 1) == 1 @@ -92,8 +89,7 @@ def test_default_deprecation(self): assert ct.config.defaults['bode.Hz'] \ == ct.config.defaults['freqplot.Hz'] - @mplcleanup - def test_fbs_bode(self): + def test_fbs_bode(self, mplcleanup): ct.use_fbs_defaults() # Generate a Bode plot @@ -137,8 +133,7 @@ def test_fbs_bode(self): phase_x, phase_y = (((plt.gcf().axes[1]).get_lines())[0]).get_data() np.testing.assert_almost_equal(phase_y[-1], -pi, decimal=2) - @mplcleanup - def test_matlab_bode(self): + def test_matlab_bode(self, mplcleanup): ct.use_matlab_defaults() # Generate a Bode plot @@ -182,8 +177,7 @@ def test_matlab_bode(self): phase_x, phase_y = (((plt.gcf().axes[1]).get_lines())[0]).get_data() np.testing.assert_almost_equal(phase_y[-1], -pi, decimal=2) - @mplcleanup - def test_custom_bode_default(self): + def test_custom_bode_default(self, mplcleanup): ct.config.defaults['freqplot.dB'] = True ct.config.defaults['freqplot.deg'] = True ct.config.defaults['freqplot.Hz'] = True @@ -204,8 +198,7 @@ def test_custom_bode_default(self): np.testing.assert_almost_equal(mag_y[0], 20*log10(10), decimal=3) np.testing.assert_almost_equal(phase_y[-1], -pi, decimal=2) - @mplcleanup - def test_bode_number_of_samples(self): + def test_bode_number_of_samples(self, mplcleanup): # Set the number of samples (default is 50, from np.logspace) mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys, omega_num=87) assert len(mag_ret) == 87 @@ -219,8 +212,7 @@ def test_bode_number_of_samples(self): mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys, omega_num=87) assert len(mag_ret) == 87 - @mplcleanup - def test_bode_feature_periphery_decade(self): + def test_bode_feature_periphery_decade(self, mplcleanup): # Generate a sample Bode plot to figure out the range it uses ct.reset_defaults() # Make sure starting state is correct mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys, Hz=False) diff --git a/control/tests/conftest.py b/control/tests/conftest.py index 1201b8746..3f798f26c 100644 --- a/control/tests/conftest.py +++ b/control/tests/conftest.py @@ -1,13 +1,11 @@ """conftest.py - pytest local plugins and fixtures""" import os -import sys from contextlib import contextmanager import matplotlib as mpl import numpy as np import pytest -import scipy as sp import control @@ -45,7 +43,7 @@ def control_defaults(): params=[pytest.param("arrayout", marks=matrixerrorfilter), pytest.param("matrixout", marks=matrixfilter)]) def matarrayout(request): - """Switch the config to use np.ndarray and np.matrix as returns""" + """Switch the config to use np.ndarray and np.matrix as returns.""" restore = control.config.defaults['statesp.use_numpy_matrix'] control.use_numpy_matrix(request.param == "matrixout", warn=False) yield @@ -53,7 +51,7 @@ def matarrayout(request): def ismatarrayout(obj): - """Test if the returned object has the correct type as configured + """Test if the returned object has the correct type as configured. note that isinstance(np.matrix(obj), np.ndarray) is True """ @@ -63,7 +61,7 @@ def ismatarrayout(obj): def asmatarrayout(obj): - """Return a object according to the configured default""" + """Return a object according to the configured default.""" use_matrix = control.config.defaults['statesp.use_numpy_matrix'] matarray = np.asmatrix if use_matrix else np.asarray return matarray(obj) @@ -71,7 +69,7 @@ def asmatarrayout(obj): @contextmanager def check_deprecated_matrix(): - """Check that a call produces a deprecation warning because of np.matrix""" + """Check that a call produces a deprecation warning because of np.matrix.""" use_matrix = control.config.defaults['statesp.use_numpy_matrix'] if use_matrix: with pytest.deprecated_call(): @@ -94,13 +92,13 @@ def check_deprecated_matrix(): False)] if usebydefault or TEST_MATRIX_AND_ARRAY]) def matarrayin(request): - """Use array and matrix to construct input data in tests""" + """Use array and matrix to construct input data in tests.""" return request.param @pytest.fixture(scope="function") def editsdefaults(): - """Make sure any changes to the defaults only last during a test""" + """Make sure any changes to the defaults only last during a test.""" restore = control.config.defaults.copy() yield control.config.defaults = restore.copy() diff --git a/control/tests/rlocus_test.py b/control/tests/rlocus_test.py index a0ecebb15..4fbe70c4f 100644 --- a/control/tests/rlocus_test.py +++ b/control/tests/rlocus_test.py @@ -85,6 +85,8 @@ def test_root_locus_neg_false_gain_nonproper(self): # TODO: cover and validate negative false_gain branch in _default_gains() + @pytest.mark.skipif(plt.get_current_fig_manager().toolbar is None, + reason="Requires the zoom toolbar") def test_root_locus_zoom(self): """Check the zooming functionality of the Root locus plot""" system = TransferFunction([1000], [1, 25, 100, 0]) diff --git a/control/tests/sisotool_test.py b/control/tests/sisotool_test.py index d5e9dd013..a1f468eea 100644 --- a/control/tests/sisotool_test.py +++ b/control/tests/sisotool_test.py @@ -46,6 +46,8 @@ def sys221(self): D221 = [[1., -1.]] return StateSpace(A222, B222, C221, D221) + @pytest.mark.skipif(plt.get_current_fig_manager().toolbar is None, + reason="Requires the zoom toolbar") def test_sisotool(self, tsys): sisotool(tsys, Hz=False) fig = plt.gcf() @@ -114,6 +116,8 @@ def test_sisotool(self, tsys): assert_array_almost_equal( ax_step.lines[0].get_data()[1][:10], step_response_moved, 4) + @pytest.mark.skipif(plt.get_current_fig_manager().toolbar is None, + reason="Requires the zoom toolbar") @pytest.mark.parametrize('tsys', [0, True], indirect=True, ids=['ctime', 'dtime']) def test_sisotool_tvect(self, tsys): diff --git a/make_version.py b/make_version.py deleted file mode 100644 index 356f4d747..000000000 --- a/make_version.py +++ /dev/null @@ -1,58 +0,0 @@ -# make_version.py - generate version information -# -# Author: Clancy Rowley -# Date: 2 Apr 2015 -# Modified: Richard M. Murray, 28 Dec 2017 -# -# This script is used to create the version information for the python- -# control package. The version information is now generated directly from -# tags in the git repository. Now, *before* running setup.py, one runs -# -# python make_version.py -# -# and this generates a file with the version information. This is copied -# from binstar (https://github.com/Binstar/binstar) and seems to work well. -# -# The original version of this script also created version information for -# conda, but this stopped working when conda v3 was released. Instead, we -# now use jinja templates in conda-recipe to create the conda information. -# The current version information is used in setup.py, control/__init__.py, -# and doc/conf.py (for sphinx). - -from subprocess import check_output -import os - -def main(): - cmd = 'git describe --always --long' - # describe --long usually outputs "tag-numberofcommits-commitname" - output = check_output(cmd.split()).decode('utf-8').strip().rsplit('-',2) - if len(output) == 3: - version, build, commit = output - else: - # If the clone is shallow, describe's output won't have tag and - # number of commits. This is a particular issue on Travis-CI, - # which by default clones with a depth of 50. - # This behaviour isn't well documented in git-describe docs, - # but see, e.g., https://stackoverflow.com/a/36389573/1008142 - # and https://github.com/travis-ci/travis-ci/issues/3412 - version = 'unknown' - build = 'unknown' - # we don't ever expect just one dash from describe --long, but - # just in case: - commit = '-'.join(output) - - print("Version: %s" % version) - print("Build: %s" % build) - print("Commit: %s\n" % commit) - - filename = "control/_version.py" - print("Writing %s" % filename) - with open(filename, 'w') as fd: - if build == '0': - fd.write('__version__ = "%s"\n' % (version)) - else: - fd.write('__version__ = "%s.post%s"\n' % (version, build)) - fd.write('__commit__ = "%s"\n' % (commit)) - -if __name__ == '__main__': - main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..89690ac8d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = [ + "setuptools", + "setuptools-scm", + "wheel" +] +build-backend = "setuptools.build_meta" + +[project] +name = "control" +description = "Python Control Systems Library" +authors = [{name = "Python Control Developers", email = "python-control-developers@lists.sourceforge.net"}] +license = {text = "BSD-3-Clause"} +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Topic :: Software Development", + "Topic :: Scientific/Engineering", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Operating System :: Unix", + "Operating System :: MacOS", +] +requires-python = ">=3.7" +dependencies = [ + "numpy", + "scipy>=1.3", + "matplotlib", +] +dynamic = ["version"] + +[tool.setuptools] +packages = ["control"] + +[project.optional-dependencies] +test = ["pytest", "pytest-timeout"] +slycot = [ "slycot>=0.4.0" ] +cvxopt = [ "cvxopt>=1.2.0" ] + +[project.urls] +homepage = "https//python-control.org" +source = "https://github.com/python-control/python-control" + +[tool.setuptools_scm] +write_to = "control/_version.py" + +[tool.pytest.ini_options] +addopts = "-ra" +filterwarnings = [ + "error:.*matrix subclass:PendingDeprecationWarning", +] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 5b1ce28a7..000000000 --- a/setup.cfg +++ /dev/null @@ -1,7 +0,0 @@ -[bdist_wheel] -universal=1 - -[tool:pytest] -addopts = -ra -filterwarnings = - error:.*matrix subclass:PendingDeprecationWarning diff --git a/setup.py b/setup.py deleted file mode 100644 index 2021d5eb9..000000000 --- a/setup.py +++ /dev/null @@ -1,52 +0,0 @@ -from setuptools import setup, find_packages - -ver = {} -try: - with open('control/_version.py') as fd: - exec(fd.read(), ver) - version = ver.get('__version__', 'dev') -except IOError: - version = 'dev' - -with open('README.rst') as fp: - long_description = fp.read() - -CLASSIFIERS = """ -Development Status :: 3 - Alpha -Intended Audience :: Science/Research -Intended Audience :: Developers -License :: OSI Approved :: BSD License -Programming Language :: Python :: 3 -Programming Language :: Python :: 3.7 -Programming Language :: Python :: 3.8 -Programming Language :: Python :: 3.9 -Topic :: Software Development -Topic :: Scientific/Engineering -Operating System :: Microsoft :: Windows -Operating System :: POSIX -Operating System :: Unix -Operating System :: MacOS -""" - -setup( - name='control', - version=version, - author='Python Control Developers', - author_email='python-control-developers@lists.sourceforge.net', - url='http://python-control.org', - project_urls={ - 'Source': 'https://github.com/python-control/python-control', - }, - description='Python Control Systems Library', - long_description=long_description, - packages=find_packages(exclude=['benchmarks']), - classifiers=[f for f in CLASSIFIERS.split('\n') if f], - install_requires=['numpy', - 'scipy>=1.3', - 'matplotlib'], - extras_require={ - 'test': ['pytest', 'pytest-timeout'], - 'slycot': [ 'slycot>=0.4.0' ], - 'cvxopt': [ 'cvxopt>=1.2.0' ] - } -)