From 16d14e2f4c58fb41013634c8c97baef6b1197a49 Mon Sep 17 00:00:00 2001 From: Christoph Gohlke Date: Thu, 25 Apr 2024 20:24:56 -0700 Subject: [PATCH 1/4] Support NumPy 2 --- .github/workflows/build_wheels.yml | 1 + .github/workflows/run-tests.yml | 4 +++- pyproject.toml | 7 ++++-- src/phasorpy/components.py | 4 ++-- src/phasorpy/conftest.py | 4 ++++ src/phasorpy/io.py | 2 +- src/phasorpy/phasor.py | 36 ++++++++++++++++-------------- src/phasorpy/plot.py | 5 ++++- src/phasorpy/version.py | 2 +- 9 files changed, 40 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index 1a690ab..d1655da 100644 --- a/.github/workflows/build_wheels.yml +++ b/.github/workflows/build_wheels.yml @@ -25,6 +25,7 @@ jobs: CIBW_ARCHS_LINUX: auto CIBW_ARCHS_MACOS: x86_64 arm64 CIBW_ARCHS_WINDOWS: AMD64 ARM64 + CIBW_BEFORE_BUILD: python -m pip install numpy>=2.0.0rc1 - uses: actions/upload-artifact@v4 with: path: ./wheelhouse/*.whl diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index e3d4feb..b0c8e55 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -76,7 +76,7 @@ jobs: make html build_wheels: - name: Test cibuildwheel + name: Test cibuildwheel and numpy 2 runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -86,6 +86,8 @@ jobs: - uses: actions/checkout@v4 - uses: pypa/cibuildwheel@v2.17.0 env: + CIBW_TEST_REQUIRES: numpy>=2.0.0rc1 + CIBW_BEFORE_BUILD: python -m pip install numpy>=2.0.0rc1 CIBW_BUILD: "cp311-manylinux_x86_64 cp312-win_amd64 cp312-macosx_x86_64" CIBW_SKIP: - uses: actions/upload-artifact@v4 diff --git a/pyproject.toml b/pyproject.toml index 0a12ab2..35f384d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ requires = [ "setuptools>=68", "numpy", - "Cython", + "Cython>=3.0.10", ] build-backend = "setuptools.build_meta" @@ -81,6 +81,9 @@ version = { attr = "phasorpy.version.__version__" } [tool.setuptools.package-data] phasorpy = ["py.typed"] +[tool.ruff.lint] +select = ["NPY201"] + [tool.pylint.format] max-line-length = 79 max-module-lines = 2500 @@ -156,7 +159,7 @@ norecursedirs = [ ".pytest_cache", "adhoc", "build", - "docs", + "docs/_build", "fixture", "htmlcov", "_htmlcov", diff --git a/src/phasorpy/components.py b/src/phasorpy/components.py index be52b44..22960ff 100644 --- a/src/phasorpy/components.py +++ b/src/phasorpy/components.py @@ -107,8 +107,8 @@ def two_fractions_from_phasor( real, imag, real_components, imag_components ) distances_to_first_component = numpy.hypot( - (numpy.array(projected_real) - first_component_phasor[0]), - (numpy.array(projected_imag) - first_component_phasor[1]), + numpy.asarray(projected_real) - first_component_phasor[0], + numpy.asarray(projected_imag) - first_component_phasor[1], ) fraction_of_second_component = ( distances_to_first_component / total_distance_between_components diff --git a/src/phasorpy/conftest.py b/src/phasorpy/conftest.py index 7e9870c..bb2560f 100644 --- a/src/phasorpy/conftest.py +++ b/src/phasorpy/conftest.py @@ -7,6 +7,10 @@ from .datasets import fetch +# numpy 2.0 changed the scalar type representation, +# causing many doctests to fail. +numpy.set_printoptions(legacy='1.21') + @pytest.fixture(autouse=True) def add_doctest_namespace(doctest_namespace): diff --git a/src/phasorpy/io.py b/src/phasorpy/io.py index 33434aa..1a173e9 100644 --- a/src/phasorpy/io.py +++ b/src/phasorpy/io.py @@ -536,7 +536,7 @@ def read_sdt( # TODO: get spatial coordinates from scanner settings? metadata = _metadata('QYXH'[-data.ndim :], data.shape, filename, H=times) - metadata['attrs']['frequency'] = 1e-6 / (times[-1] + times[1]) + metadata['attrs']['frequency'] = 1e-6 / float(times[-1] + times[1]) return DataArray(data, **metadata) diff --git a/src/phasorpy/phasor.py b/src/phasorpy/phasor.py index 95a93c9..a498e40 100644 --- a/src/phasorpy/phasor.py +++ b/src/phasorpy/phasor.py @@ -218,15 +218,15 @@ def phasor_from_signal( (1.1, 0.0, 0.0) """ - signal = numpy.array(signal, order='C', ndmin=1, copy=False) - samples = signal.shape[axis] # this also verifies axis + signal = numpy.asarray(signal, order='C') + samples = numpy.size(signal, axis) # this also verifies axis and ndim >= 1 if sample_phase is not None: if harmonic is not None: raise ValueError('sample_phase cannot be used with harmonic') harmonics = [1] # value not used - sample_phase = numpy.array( - sample_phase, dtype=numpy.float64, copy=False, ndmin=1 + sample_phase = numpy.atleast_1d( + numpy.asarray(sample_phase, dtype=numpy.float64) ) if sample_phase.ndim != 1 or sample_phase.size != samples: raise ValueError(f'{sample_phase.shape=} != ({samples},)') @@ -237,7 +237,7 @@ def phasor_from_signal( elif isinstance(harmonic, int): harmonics = [harmonic] else: - a = numpy.array(harmonic, ndmin=1) + a = numpy.atleast_1d(numpy.asarray(harmonic)) if a.dtype.kind not in 'iu' or a.ndim != 1: raise TypeError(f'invalid {harmonic=} type') harmonics = a.tolist() @@ -358,7 +358,7 @@ def phasor_from_signal_fft( (1.1, array([0.5, 0.0]), array([0.5, -0])) """ - signal = numpy.array(signal, copy=False, ndmin=1) + signal = numpy.asarray(signal) samples = numpy.size(signal, axis) if samples < 3: raise ValueError(f'not enough {samples=} along {axis=}') @@ -372,7 +372,7 @@ def phasor_from_signal_fft( f'harmonic={harmonic} out of range 1..{max_harmonic}' ) else: - a = numpy.array(harmonic) + a = numpy.atleast_1d(numpy.asarray(harmonic)) if a.dtype.kind not in 'iu' or a.ndim != 1: raise TypeError(f'invalid {harmonic=} type') if numpy.any(a < 1) or numpy.any(a > max_harmonic): @@ -986,7 +986,7 @@ def phasor_to_apparent_lifetime( (array([inf, 0]), array([inf, 0])) """ - omega = numpy.array(frequency, dtype=numpy.float64, copy=True) + omega = numpy.array(frequency, dtype=numpy.float64) # makes copy omega *= math.pi * 2.0 * unit_conversion return _phasor_to_apparent_lifetime(real, imag, omega, **kwargs) @@ -1068,7 +1068,7 @@ def phasor_from_apparent_lifetime( (array([1, 0.0]), array([0, 0.0])) """ - omega = numpy.array(frequency, dtype=numpy.float64, copy=True) + omega = numpy.array(frequency, dtype=numpy.float64) # makes copy omega *= math.pi * 2.0 * unit_conversion if modulation_lifetime is None: return _phasor_from_single_lifetime(phase_lifetime, omega, **kwargs) @@ -1211,7 +1211,7 @@ def fraction_to_amplitude( array([0.2, 0.2]) """ - t = numpy.array(fraction, copy=True, dtype=numpy.float64) + t = numpy.array(fraction, dtype=numpy.float64) # makes copy t /= numpy.sum(t, axis=axis, keepdims=True) numpy.true_divide(t, lifetime, out=t) return t @@ -1419,10 +1419,10 @@ def phasor_from_lifetime( """ if unit_conversion < 1e-16: raise ValueError(f'{unit_conversion=} < 1e-16') - frequency = numpy.array(frequency, dtype=numpy.float64, ndmin=1) + frequency = numpy.atleast_1d(numpy.asarray(frequency, dtype=numpy.float64)) if frequency.ndim != 1: raise ValueError('frequency is not one-dimensional array') - lifetime = numpy.array(lifetime, dtype=numpy.float64, ndmin=1) + lifetime = numpy.atleast_1d(numpy.asarray(lifetime, dtype=numpy.float64)) if lifetime.ndim > 2: raise ValueError('lifetime must be one- or two-dimensional array') @@ -1435,7 +1435,9 @@ def phasor_from_lifetime( lifetime = lifetime.reshape(-1, 1) # move components to last axis fraction = numpy.ones_like(lifetime) # not really used else: - fraction = numpy.array(fraction, dtype=numpy.float64, ndmin=1) + fraction = numpy.atleast_1d( + numpy.asarray(fraction, dtype=numpy.float64) + ) if fraction.ndim > 2: raise ValueError('fraction must be one- or two-dimensional array') @@ -1541,7 +1543,7 @@ def polar_to_apparent_lifetime( (array([1.989, 1.989]), array([1.989, 2.411])) """ - omega = numpy.array(frequency, dtype=numpy.float64, copy=True) + omega = numpy.array(frequency, dtype=numpy.float64) # makes copy omega *= math.pi * 2.0 * unit_conversion return _polar_to_apparent_lifetime(phase, modulation, omega, **kwargs) @@ -1611,7 +1613,7 @@ def polar_from_apparent_lifetime( (array([0.7854, 0.7854]), array([0.7071, 0.6364])) """ - omega = numpy.array(frequency, dtype=numpy.float64, copy=True) + omega = numpy.array(frequency, dtype=numpy.float64) # makes copy omega *= math.pi * 2.0 * unit_conversion if modulation_lifetime is None: return _polar_from_single_lifetime(phase_lifetime, omega, **kwargs) @@ -1702,7 +1704,7 @@ def phasor_from_fret_donor( (array([0.1766, 0.2737, 0.1466]), array([0.3626, 0.4134, 0.2534])) """ - omega = numpy.array(frequency, dtype=numpy.float64, copy=True) + omega = numpy.array(frequency, dtype=numpy.float64) # makes copy omega *= math.pi * 2.0 * unit_conversion return _phasor_from_fret_donor( omega, @@ -1820,7 +1822,7 @@ def phasor_from_fret_acceptor( (array([0.1996, 0.05772, 0.2867]), array([0.3225, 0.3103, 0.4292])) """ - omega = numpy.array(frequency, dtype=numpy.float64, copy=True) + omega = numpy.array(frequency, dtype=numpy.float64) # makes copy omega *= math.pi * 2.0 * unit_conversion return _phasor_from_fret_acceptor( omega, diff --git a/src/phasorpy/plot.py b/src/phasorpy/plot.py index 1716784..5a2104f 100644 --- a/src/phasorpy/plot.py +++ b/src/phasorpy/plot.py @@ -237,7 +237,10 @@ def plot( i, (re, im), ) in enumerate( - zip(numpy.array(real, ndmin=2), numpy.array(imag, ndmin=2)) + zip( + numpy.atleast_2d(numpy.asarray(real)), + numpy.atleast_2d(numpy.asarray(imag)), + ) ): lbl = None if label is not None: diff --git a/src/phasorpy/version.py b/src/phasorpy/version.py index 1ba7d33..3d09412 100644 --- a/src/phasorpy/version.py +++ b/src/phasorpy/version.py @@ -18,7 +18,7 @@ def versions(*, sep: str = '\n') -> str: >>> print(versions()) Python 3... phasorpy 0... - numpy 1... + numpy ... ... """ From eaaab6cf45215f2209f346a77e47b6e74907f071 Mon Sep 17 00:00:00 2001 From: Christoph Gohlke Date: Thu, 25 Apr 2024 20:38:25 -0700 Subject: [PATCH 2/4] Update run-tests.yml --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index b0c8e55..01e61f9 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -86,7 +86,7 @@ jobs: - uses: actions/checkout@v4 - uses: pypa/cibuildwheel@v2.17.0 env: - CIBW_TEST_REQUIRES: numpy>=2.0.0rc1 + CIBW_BEFORE_TEST: python -m pip install numpy>=2.0.0rc1 CIBW_BEFORE_BUILD: python -m pip install numpy>=2.0.0rc1 CIBW_BUILD: "cp311-manylinux_x86_64 cp312-win_amd64 cp312-macosx_x86_64" CIBW_SKIP: From 6e45ded11823ba843be2a6e68e8ff191474f2d11 Mon Sep 17 00:00:00 2001 From: Christoph Gohlke Date: Fri, 26 Apr 2024 13:47:48 -0700 Subject: [PATCH 3/4] Print versions in tests and intro tutorial --- pyproject.toml | 2 +- src/phasorpy/cli.py | 11 +++++- src/phasorpy/version.py | 60 +++++++++++++++++------------- tests/conftest.py | 21 +++++++++++ tests/test_cli.py | 4 +- tests/test_phasorpy.py | 8 +++- tutorials/phasorpy_introduction.py | 7 ++++ 7 files changed, 80 insertions(+), 33 deletions(-) create mode 100644 tests/conftest.py diff --git a/pyproject.toml b/pyproject.toml index 35f384d..7b6e918 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -143,7 +143,7 @@ directory = "_htmlcov" minversion = "7" log_cli_level = "INFO" xfail_strict = true -addopts = "-ra --strict-config --strict-markers --cov=phasorpy --cov-report html --doctest-modules --doctest-glob=*.py --doctest-glob=*.rst" +addopts = "-rfEXs --strict-config --strict-markers --cov=phasorpy --cov-report html --doctest-modules --doctest-glob=*.py --doctest-glob=*.rst" doctest_optionflags = [ "NORMALIZE_WHITESPACE", "ELLIPSIS", diff --git a/src/phasorpy/cli.py b/src/phasorpy/cli.py index c6e9d70..c3e5343 100644 --- a/src/phasorpy/cli.py +++ b/src/phasorpy/cli.py @@ -23,9 +23,16 @@ def main() -> int: @main.command(help='Show runtime versions.') -def versions(): +@click.option( + '--verbose', + default=False, + is_flag=True, + type=click.BOOL, + help='Show module paths.', +) +def versions(verbose): """Versions command group.""" - click.echo(version.versions()) + click.echo(version.versions(verbose=verbose)) @main.command(help='Fetch sample files from remote repositories.') diff --git a/src/phasorpy/version.py b/src/phasorpy/version.py index 3d09412..49f35fe 100644 --- a/src/phasorpy/version.py +++ b/src/phasorpy/version.py @@ -5,59 +5,67 @@ __version__ = '0.1.dev' -def versions(*, sep: str = '\n') -> str: +def versions( + *, sep: str = '\n', dash: str = '-', verbose: bool = False +) -> str: """Return versions of installed packages that phasorpy depends on. Parameters ---------- sep : str, optional Separator between version items. The default is a newline character. + dash : str, optional + Separator between module name and version. + verbose : bool, optional + Include paths to Python interpreter and modules. Example ------- >>> print(versions()) - Python 3... - phasorpy 0... - numpy ... + Python-3... + phasorpy-0... + numpy-... ... """ import os import sys - try: - path = os.path.dirname(__file__) - except NameError: - path = '' - - version_strings = [ - f'Python {sys.version} ({sys.executable})', - f'phasorpy {__version__} ({path})', - ] + if verbose: + version_strings = [f'Python{dash}{sys.version} ({sys.executable})'] + else: + version_strings = [f'Python{dash}{sys.version.split()[0]}'] for module in ( + 'phasorpy', 'numpy', - 'matplotlib', - 'click', - 'pooch', - 'tqdm', - 'xarray', 'tifffile', + 'imagecodecs', 'lfdfiles', 'sdtfile', 'ptufile', - # 'scipy', - # 'skimage', - # 'sklearn', - # 'aicsimageio', + 'matplotlib', + 'scipy', + 'skimage', + 'sklearn', + 'pandas', + 'xarray', + 'click', + 'pooch', ): try: __import__(module) except ModuleNotFoundError: - version_strings.append(f'{module.lower()} N/A') + version_strings.append(f'{module.lower()}{dash}n/a') continue lib = sys.modules[module] - version_strings.append( - f"{module.lower()} {getattr(lib, '__version__', 'unknown')}" - ) + ver = f"{module.lower()}{dash}{getattr(lib, '__version__', 'unknown')}" + if verbose: + try: + path = getattr(lib, '__file__') + except NameError: + pass + else: + ver += f' ({os.path.dirname(path)})' + version_strings.append(ver) return sep.join(version_strings) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1bc7c63 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +"""Pytest configuration.""" + +import os + +import phasorpy +from phasorpy import versions +from phasorpy.utils import number_threads + + +def pytest_report_header(config, start_path, startdir): + """Return versions of relevant installed packages.""" + return '\n'.join( + ( + f'versions: {versions(sep=', ')}', + f'number_threads: {number_threads(0)}', + f'path: {os.path.dirname(phasorpy.__file__)}', + ) + ) + + +collect_ignore = ['data'] diff --git a/tests/test_cli.py b/tests/test_cli.py index b5ca01f..fb64965 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -17,9 +17,9 @@ def test_version(): def test_versions(): """Test ``python -m phasorpy versions``.""" runner = CliRunner() - result = runner.invoke(main, ['versions']) + result = runner.invoke(main, ['versions', '--verbose']) assert result.exit_code == 0 - assert result.output.strip() == versions() + assert result.output.strip() == versions(verbose=True) def test_fetch(): diff --git a/tests/test_phasorpy.py b/tests/test_phasorpy.py index 46153f2..cfebd7d 100644 --- a/tests/test_phasorpy.py +++ b/tests/test_phasorpy.py @@ -6,5 +6,9 @@ def test_versions(): """Test phasorpy.versions function.""" ver = versions() - assert f'phasorpy {__version__}' in ver - assert 'numpy' in ver + assert 'Python-' in ver + assert f'phasorpy-{__version__}\nnumpy-' in ver + assert '(' not in ver + + ver = versions(sep=', ', dash=' ', verbose=True) + assert f', phasorpy {__version__} (' in ver diff --git a/tutorials/phasorpy_introduction.py b/tutorials/phasorpy_introduction.py index 31837ee..9101b6f 100644 --- a/tutorials/phasorpy_introduction.py +++ b/tutorials/phasorpy_introduction.py @@ -191,7 +191,14 @@ # %% # To be continued # --------------- + +# %% +# Appendix +# -------- # +# Print information about Python interpreter and installed packages: + +print(phasorpy.versions()) # %% # sphinx_gallery_thumbnail_number = 3 From 088e11bed7e39e5ee9cd67b54f39352f8542b23c Mon Sep 17 00:00:00 2001 From: Christoph Gohlke Date: Fri, 26 Apr 2024 13:51:49 -0700 Subject: [PATCH 4/4] Update conftest.py --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 1bc7c63..0e4688c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ def pytest_report_header(config, start_path, startdir): """Return versions of relevant installed packages.""" return '\n'.join( ( - f'versions: {versions(sep=', ')}', + f'versions: {versions(sep=", ")}', f'number_threads: {number_threads(0)}', f'path: {os.path.dirname(phasorpy.__file__)}', )