diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d996605..24cf16c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: toxdeps: tox-pypi-filter posargs: -n auto envs: | - - linux: py311 + - linux: py312 secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -42,7 +42,7 @@ jobs: posargs: -n auto envs: | - windows: py310 - - macos: py39 + - macos: py311 secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -50,7 +50,7 @@ jobs: needs: [core] uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@main with: - default_python: "3.9" + default_python: "3.12" submodules: false pytest: false toxdeps: tox-pypi-filter @@ -69,13 +69,12 @@ jobs: needs: [docs] uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@main with: - default_python: "3.9" submodules: false coverage: codecov toxdeps: tox-pypi-filter posargs: -n auto --dist loadgroup envs: | - - linux: py39-online + - linux: py312-online secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -95,7 +94,7 @@ jobs: needs: [test] uses: OpenAstronomy/github-actions-workflows/.github/workflows/publish_pure_python.yml@main with: - python-version: "3.10" + python-version: "3.12" test_extras: "all,tests" test_command: 'pytest -p no:warnings --doctest-rst -m "not mpl_image_compare" --pyargs sunpy' submodules: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2c114a2..4152678 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,9 @@ -exclude: ".*(.fits|.fts|.fit|.txt|.csv)$" repos: - repo: https://github.com/myint/docformatter rev: v1.7.5 hooks: - id: docformatter - args: [--in-place, --pre-summary-newline, --make-summary-multi] + args: ["--in-place", "--pre-summary-newline", "--make-summary-multi"] - repo: https://github.com/myint/autoflake rev: v2.3.1 hooks: @@ -21,25 +20,27 @@ repos: hooks: - id: ruff args: ["--fix", "--unsafe-fixes"] - - repo: https://github.com/psf/black - rev: 24.4.2 - hooks: - - id: black - - repo: https://github.com/PyCQA/isort - rev: 5.13.2 - hooks: - - id: isort - - repo: https://github.com/pre-commit/mirrors-prettier - rev: v4.0.0-alpha.8 - hooks: - - id: prettier + - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: check-ast - id: check-case-conflict - id: trailing-whitespace + exclude: ".*(.fits|.fts|.fit|.txt|.csv)$" - id: mixed-line-ending + exclude: ".*(.fits|.fts|.fit|.txt|.csv)$" - id: end-of-file-fixer + exclude: ".*(.fits|.fts|.fit|.txt|.csv)$" - id: check-yaml - id: debug-statements + - repo: https://github.com/codespell-project/codespell + rev: v2.2.6 + hooks: + - id: codespell + additional_dependencies: + - tomli + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v4.0.0-alpha.8 + hooks: + - id: prettier diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0d0e5bc..8b749dd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -235,7 +235,7 @@ Features - :func:`aiapy.calibrate.degradation` can now accept `~astropy.time.Time` objects with length greater than 1. This makes it easier to compute the channel degradation over long intervals. (`#80 `__) - Citation information for `aiapy` is now available from ``aiapy.__citation__``. (`#82 `__) -- The pointing table can now be passsed in as a keyword argument to :func:`aiapy.calibrate.update_pointing`. +- The pointing table can now be passed in as a keyword argument to :func:`aiapy.calibrate.update_pointing`. Added a :func:`aiapy.calibrate.util.get_pointing_table` to retrieve the 3-hour pointing table from JSOC over a given time interval. (`#84 `__) Bug Fixes diff --git a/aiapy/__init__.py b/aiapy/__init__.py index 86e5839..7fb005a 100644 --- a/aiapy/__init__.py +++ b/aiapy/__init__.py @@ -1,5 +1,5 @@ -from pathlib import Path from itertools import compress +from pathlib import Path from .version import version as __version__ diff --git a/aiapy/calibrate/__init__.py b/aiapy/calibrate/__init__.py index d44db43..b6d071d 100644 --- a/aiapy/calibrate/__init__.py +++ b/aiapy/calibrate/__init__.py @@ -2,8 +2,8 @@ Subpackage for calibrating AIA imaging data. """ -from .meta import * # NOQA -from .prep import * # NOQA -from .spikes import * # NOQA -from .transform import * # NOQA -from .uncertainty import * # NOQA +from .meta import * # NOQA: F403 +from .prep import * # NOQA: F403 +from .spikes import * # NOQA: F403 +from .transform import * # NOQA: F403 +from .uncertainty import * # NOQA: F403 diff --git a/aiapy/calibrate/meta.py b/aiapy/calibrate/meta.py index b80ff99..d2b0d66 100644 --- a/aiapy/calibrate/meta.py +++ b/aiapy/calibrate/meta.py @@ -56,7 +56,7 @@ def fix_observer_location(smap): new_meta["hglt_obs"] = coord.lat.to(u.degree).value new_meta["dsun_obs"] = coord.radius.to(u.m).value - return smap._new_instance(smap.data, new_meta, plot_settings=smap.plot_settings, mask=smap.mask) + return smap._new_instance(smap.data, new_meta, plot_settings=smap.plot_settings, mask=smap.mask) # NOQA: SLF001 def update_pointing(smap, *, pointing_table=None): @@ -104,10 +104,12 @@ def update_pointing(smap, *, pointing_table=None): `aiapy.calibrate.util.get_pointing_table` """ if not contains_full_disk(smap): - raise ValueError("Input must be a full disk image.") + msg = "Input must be a full disk image." + raise ValueError(msg) shape_full_frame = (4096, 4096) - if not all(d == (s * u.pixel) for d, s in zip(smap.dimensions, shape_full_frame)): - raise ValueError(f"Input must be at the full resolution of {shape_full_frame}") + if not all(d == (s * u.pixel) for d, s in zip(smap.dimensions, shape_full_frame, strict=True)): + msg = f"Input must be at the full resolution of {shape_full_frame}" + raise ValueError(msg) if pointing_table is None: # Make range wide enough to get closest 3-hour pointing pointing_table = get_pointing_table(smap.date - 12 * u.h, smap.date + 12 * u.h) @@ -134,10 +136,13 @@ def update_pointing(smap, *, pointing_table=None): t_obs = astropy.time.Time(t_obs) t_obs_in_interval = np.logical_and(t_obs >= pointing_table["T_START"], t_obs < pointing_table["T_STOP"]) if not t_obs_in_interval.any(): - raise IndexError( + msg = ( f"No valid entries for {t_obs} in pointing table " f'with first T_START date of {pointing_table[0]["T_START"]} ' - f'and a last T_STOP date of {pointing_table[-1]["T_STOP"]}.', + f'and a last T_STOP date of {pointing_table[-1]["T_STOP"]}.' + ) + raise IndexError( + msg, ) i_nearest = np.where(t_obs_in_interval)[0][0] w_str = f"{smap.wavelength.to(u.angstrom).value:03.0f}" @@ -185,4 +190,4 @@ def update_pointing(smap, *, pointing_table=None): new_meta.pop("PC1_2") new_meta.pop("PC2_1") new_meta.pop("PC2_2") - return smap._new_instance(smap.data, new_meta, plot_settings=smap.plot_settings, mask=smap.mask) + return smap._new_instance(smap.data, new_meta, plot_settings=smap.plot_settings, mask=smap.mask) # NOQA: SLF001 diff --git a/aiapy/calibrate/prep.py b/aiapy/calibrate/prep.py index c9d78f9..2d130f5 100644 --- a/aiapy/calibrate/prep.py +++ b/aiapy/calibrate/prep.py @@ -69,10 +69,12 @@ def register(smap, *, missing=None, order=3, method="scipy"): # This implementation is taken directly from the `aiaprep` method in # sunpy.instr.aia.aiaprep under the terms of the BSD 2 Clause license. # See licenses/SUNPY.rst. - if not isinstance(smap, (AIAMap, HMIMap)): - raise ValueError("Input must be an AIAMap or HMIMap.") + if not isinstance(smap, AIAMap | HMIMap): + msg = "Input must be an AIAMap or HMIMap." + raise TypeError(msg) if not contains_full_disk(smap): - raise ValueError("Input must be a full disk image.") + msg = "Input must be a full disk image." + raise ValueError(msg) if smap.processing_level is None or smap.processing_level > 1: warnings.warn( "Image registration should only be applied to level 1 data", diff --git a/aiapy/calibrate/spikes.py b/aiapy/calibrate/spikes.py index 09bb84f..b63c07d 100644 --- a/aiapy/calibrate/spikes.py +++ b/aiapy/calibrate/spikes.py @@ -63,9 +63,11 @@ def respike(smap, *, spikes=None): `fetch_spikes` """ if not isinstance(smap, AIAMap): - raise ValueError("Input must be an AIAMap.") + msg = "Input must be an AIAMap." + raise TypeError(msg) if smap.meta["lvl_num"] != 1.0: - raise ValueError("Can only apply respike procedure to level 1 data") + msg = "Can only apply respike procedure to level 1 data" + raise ValueError(msg) # Approximate check to make sure the input map has not been interpolated # in any way. Note that the level 1 plate scales are not exactly 0.6 # ''/pixel, but should not differ by more than 0.1%. This is only a @@ -88,7 +90,8 @@ def respike(smap, *, spikes=None): # Or better yet, why can't the logic below just handle the case of # no spikes? if smap.meta["nspikes"] == 0: - raise ValueError("No spikes were present in the level 0 data.") + msg = "No spikes were present in the level 0 data." + raise ValueError(msg) if spikes is None: coords, values = fetch_spikes(smap, as_coords=False) else: @@ -102,7 +105,7 @@ def respike(smap, *, spikes=None): new_meta["lvl_num"] = 0.5 new_meta["comments"] = f"Respike applied; {values.shape[0]} hot pixels reinserted." new_meta["nspikes"] = 0 - return smap._new_instance( + return smap._new_instance( # NOQA: SLF001 new_data, new_meta, plot_settings=smap.plot_settings, @@ -151,7 +154,7 @@ def fetch_spikes(smap, *, as_coords=False): # If this is a cutout, need to transform the full-frame pixel # coordinates into the cutout pixel coordinates and then only select # those in the FOV of the cutout - if not all(d == (s * u.pixel) for d, s in zip(smap.dimensions, shape_full_frame)): + if not all(d == (s * u.pixel) for d, s in zip(smap.dimensions, shape_full_frame, strict=True)): # Construct WCS for full frame wcs_full_frame = copy.deepcopy(smap.wcs) wcs_full_frame.wcs.crval = np.array([0.0, 0.0]) diff --git a/aiapy/calibrate/tests/test_prep.py b/aiapy/calibrate/tests/test_prep.py index b397e99..6a13fb7 100644 --- a/aiapy/calibrate/tests/test_prep.py +++ b/aiapy/calibrate/tests/test_prep.py @@ -78,7 +78,7 @@ def test_register_unsupported_maps(aia_171_map, non_sdo_map): with pytest.raises(ValueError, match="Input must be a full disk image."): register(original_cutout) # A Map besides AIA or HMI - with pytest.raises(ValueError, match="Input must be an AIAMap"): + with pytest.raises(TypeError, match="Input must be an AIAMap"): register(non_sdo_map) @@ -95,7 +95,7 @@ def test_register_level_15(lvl_15_map): AiapyUserWarning, match="Image registration should only be applied to level 1 data", ): - register(lvl_15_map._new_instance(lvl_15_map.data, new_meta)) + register(lvl_15_map._new_instance(lvl_15_map.data, new_meta)) # NOQA: SLF001 @pytest.mark.parametrize( @@ -256,7 +256,7 @@ def test_degradation_time_array(): calibration_version=8, ) assert time_correction.shape == obstime.shape - for o, tc in zip(obstime, time_correction): + for o, tc in zip(obstime, time_correction, strict=True): assert tc == degradation(94 * u.angstrom, o, correction_table=correction_table, calibration_version=8) diff --git a/aiapy/calibrate/tests/test_spikes.py b/aiapy/calibrate/tests/test_spikes.py index d95205c..d8b9c0a 100644 --- a/aiapy/calibrate/tests/test_spikes.py +++ b/aiapy/calibrate/tests/test_spikes.py @@ -11,7 +11,6 @@ from aiapy.util import AiapyUserWarning -@pytest.mark.remote_data() @pytest.fixture() def despiked_map(): # Need an actual 4K-by-4K map to do the spike replacement @@ -20,13 +19,11 @@ def despiked_map(): ) -@pytest.mark.remote_data() @pytest.fixture() def respiked_map(despiked_map): return respike(despiked_map) -@pytest.mark.remote_data() @pytest.fixture() def spikes(despiked_map): return fetch_spikes(despiked_map) @@ -35,7 +32,7 @@ def spikes(despiked_map): @pytest.mark.remote_data() def test_respike(respiked_map, spikes): coords, values = spikes - for x, y, v in zip(coords.x.value, coords.y.value, values): + for x, y, v in zip(coords.x.value, coords.y.value, values, strict=True): assert v == respiked_map.data[int(y), int(x)] @@ -70,24 +67,27 @@ def test_cutout(respiked_map, despiked_map): @pytest.mark.remote_data() @pytest.mark.parametrize( - ("key", "value", "match"), + ("key", "value", "error", "match"), [ - ("lvl_num", 1.5, "Can only apply respike procedure to level 1 data"), - ("nspikes", 0, "No spikes were present in the level 0 data."), - ("instrume", "not AIA", "Input must be an AIAMap."), + ("lvl_num", 1.5, ValueError, "Can only apply respike procedure to level 1 data"), + ("nspikes", 0, ValueError, "No spikes were present in the level 0 data."), + ("instrume", "not AIA", TypeError, "Input must be an AIAMap."), ], ) -def test_exceptions(despiked_map, key, value, match): +def test_exceptions(despiked_map, key, value, error, match): new_meta = copy.deepcopy(despiked_map.meta) new_meta[key] = value - with pytest.raises(ValueError, match=match): + with pytest.raises(error, match=match): respike(sunpy.map.Map(despiked_map.data, new_meta)) @pytest.mark.remote_data() def test_resample_warning(despiked_map): despiked_map_resample = despiked_map.resample((512, 512) * u.pixel) - with pytest.warns(AiapyUserWarning): + with ( + pytest.warns(AiapyUserWarning, match="is significantly different from the expected level 1 plate scale"), + pytest.warns(ResourceWarning), + ): respike(despiked_map_resample) diff --git a/aiapy/calibrate/tests/test_util.py b/aiapy/calibrate/tests/test_util.py index 77ecd86..4538df4 100644 --- a/aiapy/calibrate/tests/test_util.py +++ b/aiapy/calibrate/tests/test_util.py @@ -134,5 +134,5 @@ def test_error_table(error_table): def test_invalid_error_table_input(): - with pytest.raises(ValueError, match="error_table must be a file path, an existing table, or None."): + with pytest.raises(TypeError, match="error_table must be a file path, an existing table, or None"): get_error_table(error_table=-1) diff --git a/aiapy/calibrate/transform.py b/aiapy/calibrate/transform.py index 4e44977..d14aafe 100644 --- a/aiapy/calibrate/transform.py +++ b/aiapy/calibrate/transform.py @@ -10,10 +10,10 @@ handles_image_nans=False, handles_nan_missing=True, ) -def _rotation_cupy(image, matrix, shift, order, missing, clip): +def _rotation_cupy(image, matrix, shift, order, missing, clip): # NOQA: ARG001 """ * Rotates using `cupyx.scipy.ndimage.affine_transform` from `cupy `__ - * Coverts from a numpy array to a cupy array and then back again. + * Converts from a numpy array to a cupy array and then back again. * The ``order`` parameter is the order of the spline interpolation, and ranges from 0 to 5. * The ``mode`` parameter for :func:`~cupyx.scipy.ndimage.affine_transform` is fixed to @@ -23,8 +23,9 @@ def _rotation_cupy(image, matrix, shift, order, missing, clip): import cupy import cupyx.scipy.ndimage except ImportError as e: + msg = "cupy or cupy-cuda* (pre-compiled for each cuda version) is required to use this rotation method." raise ImportError( - "cupy or cupy-cuda* (pre-compiled for each cuda version) is required to use this rotation method.", + msg, ) from e rotated_image = cupyx.scipy.ndimage.affine_transform( cupy.array(image).T, diff --git a/aiapy/calibrate/uncertainty.py b/aiapy/calibrate/uncertainty.py index bec2d79..5f67d01 100644 --- a/aiapy/calibrate/uncertainty.py +++ b/aiapy/calibrate/uncertainty.py @@ -119,7 +119,8 @@ def estimate_error( # Photometric calibration if include_eve and include_preflight: - raise ValueError("Cannot include both EVE and pre-flight correction.") + msg = "Cannot include both EVE and pre-flight correction." + raise ValueError(msg) calib = 0 if include_eve: calib = error_table["EVEERR"] diff --git a/aiapy/calibrate/util.py b/aiapy/calibrate/util.py index 0d9f868..5d7ee9b 100644 --- a/aiapy/calibrate/util.py +++ b/aiapy/calibrate/util.py @@ -72,10 +72,11 @@ def get_correction_table(*, correction_table=None): if isinstance(correction_table, astropy.table.QTable): return correction_table if correction_table is not None: - if isinstance(correction_table, (str, pathlib.Path)): + if isinstance(correction_table, str | pathlib.Path): table = QTable(astropy.io.ascii.read(correction_table)) else: - raise ValueError("correction_table must be a file path, an existing table, or None.") + msg = "correction_table must be a file path, an existing table, or None." + raise ValueError(msg) else: # NOTE: the [!1=1!] disables the drms PrimeKey logic and enables # the query to find records that are ordinarily considered @@ -143,7 +144,8 @@ def _select_epoch_from_correction_table(channel: u.angstrom, obstime, table, *, # Select the epoch for the given observation time obstime_in_epoch = np.logical_and(obstime >= table["T_START"], obstime < table["T_STOP"]) if not obstime_in_epoch.any(): - raise ValueError(f"No valid calibration epoch for {obstime}") + msg = f"No valid calibration epoch for {obstime}" + raise ValueError(msg) # NOTE: In some cases, there may be multiple entries for a single epoch. We want to # use the most up-to-date one. i_epoch = np.where(obstime_in_epoch)[0] @@ -199,7 +201,8 @@ def get_pointing_table(start, end): # If there's no pointing information available between these times, # JSOC will raise a cryptic KeyError # (see https://github.com/LM-SAL/aiapy/issues/71) - raise RuntimeError(f"Could not find any pointing information between {start} and {end}") + msg = f"Could not find any pointing information between {start} and {end}" + raise RuntimeError(msg) table["T_START"] = Time(table["T_START"], scale="utc") table["T_STOP"] = Time(table["T_STOP"], scale="utc") for c in table.colnames: @@ -211,9 +214,8 @@ def get_pointing_table(start, end): table[c].unit = "degree" # Remove masking on columns with pointing parameters for c in table.colnames: - if any(n in c for n in ["X0", "Y0", "IMSCALE", "INSTROT"]): - if hasattr(table[c], "mask"): - table[c] = table[c].filled(np.nan) + if any(n in c for n in ["X0", "Y0", "IMSCALE", "INSTROT"]) and hasattr(table[c], "mask"): + table[c] = table[c].filled(np.nan) return table @@ -224,12 +226,13 @@ def get_error_table(error_table=None): os.environ["PARFIVE_DISABLE_RANGE"] = "1" error_table = fetch_error_table() os.environ.pop("PARFIVE_DISABLE_RANGE") - if isinstance(error_table, (str, pathlib.Path)): + if isinstance(error_table, str | pathlib.Path): table = astropy.io.ascii.read(error_table) elif isinstance(error_table, QTable): table = error_table else: - raise ValueError("error_table must be a file path, an existing table, or None.") + msg = f"error_table must be a file path, an existing table, or None, not {type(error_table)}" + raise TypeError(msg) table = QTable(table) table["DATE"] = Time(table["DATE"], scale="utc") table["T_START"] = Time(table["T_START"], scale="utc") diff --git a/aiapy/conftest.py b/aiapy/conftest.py index 13e37e8..d95027f 100644 --- a/aiapy/conftest.py +++ b/aiapy/conftest.py @@ -11,9 +11,9 @@ # Force MPL to use non-gui backends for testing. with contextlib.suppress(ImportError): - import matplotlib + import matplotlib as mpl - matplotlib.use("Agg") + mpl.use("Agg") @pytest.fixture() @@ -45,8 +45,8 @@ def idl_available(): import hissw hissw.Environment().run("") - return True - except Exception as e: # NOQA + return True # NOQA: TRY300 + except Exception as e: # NOQA: BLE001 log.warning(e) return False diff --git a/aiapy/data/_sample.py b/aiapy/data/_sample.py index 5b1a300..5fae548 100644 --- a/aiapy/data/_sample.py +++ b/aiapy/data/_sample.py @@ -1,9 +1,10 @@ +import os from pathlib import Path from urllib.parse import urljoin from parfive import SessionConfig from sunpy import log -from sunpy.util.config import get_and_create_sample_dir +from sunpy.util.config import _is_writable_dir, get_and_create_sample_dir from sunpy.util.parfive_helpers import Downloader _BASE_URLS = ( @@ -45,54 +46,91 @@ def _download_sample_data(base_url, sample_files, overwrite): def _retry_sample_data(results, new_url_base): + # In case we have a broken file on disk, overwrite it. dl = Downloader(overwrite=True, progress=True, config=DOWNLOAD_CONFIG) for err in results.errors: file_name = err.url.split("/")[-1] log.debug(f"Failed to download {_SAMPLE_FILES[file_name]} from {err.url}: {err.exception}") + # Update the url to a mirror and requeue the file. new_url = urljoin(new_url_base, file_name) log.debug(f"Attempting redownload of {_SAMPLE_FILES[file_name]} using {new_url}") dl.enqueue_file(new_url, filename=err.filepath_partial) extra_results = dl.download() + # Make a new results object which contains all the successful downloads + # from the previous results object and this retry, and all the errors from + # this retry. new_results = results + extra_results - new_results._errors = extra_results._errors + new_results._errors = extra_results._errors # NOQA: SLF001 return new_results def _handle_final_errors(results): for err in results.errors: file_name = err.url.split("/")[-1] - log.debug(f"Failed to download {_SAMPLE_FILES[file_name]} from {err.url}: {err.exception}") - log.error(f"Failed to download {_SAMPLE_FILES[file_name]} from all mirrors," "the file will not be available.") + log.debug( + f"Failed to download {_SAMPLE_FILES[file_name]} from {err.url}: {err.exception}", + ) + log.error( + f"Failed to download {_SAMPLE_FILES[file_name]} from all mirrors," "the file will not be available.", + ) + + +def _get_sampledata_dir(): + # Workaround for tox only. This is not supported as a user option + sampledata_dir = os.environ.get("SUNPY_SAMPLEDIR", False) + if sampledata_dir: + sampledata_dir = Path(sampledata_dir).expanduser().resolve() + _is_writable_dir(sampledata_dir) + else: + # Creating the directory for sample files to be downloaded + sampledata_dir = Path(get_and_create_sample_dir()) + return sampledata_dir -def download_sample_data(*, overwrite=False): +def _get_sample_files(filename_list, *, no_download=False, force_download=False): """ - Download all sample data at once. This will overwrite any existing files. + Returns a list of disk locations corresponding to a list of filenames for + sample data, downloading the sample data files as necessary. Parameters ---------- - overwrite : `bool` - Overwrite existing sample data. + filename_list : `list` of `str` + List of filenames for sample data + no_download : `bool` + If ``True``, do not download any files, even if they are not present. + Default is ``False``. + force_download : `bool` + If ``True``, download all files, and overwrite any existing ones. + Default is ``False``. + + Returns + ------- + `list` of `pathlib.Path` + List of disk locations corresponding to the list of filenames. An entry + will be ``None`` if ``no_download == True`` and the file is not present. + + Raises + ------ + RuntimeError + Raised if any of the files cannot be downloaded from any of the mirrors. """ - sampledata_dir = Path(get_and_create_sample_dir()).parent / Path("aiapy") - already_downloaded = [] - to_download = [] - for url_file_name in _SAMPLE_FILES.keys(): - fname = sampledata_dir / url_file_name - # We want to avoid calling download if we already have all the files. - if fname.exists() and not overwrite: - already_downloaded.append(fname) - else: - to_download.append((url_file_name, fname)) - if to_download: - results = _download_sample_data(_BASE_URLS[0], to_download, overwrite=overwrite) + sampledata_dir = _get_sampledata_dir() + fullpaths = [sampledata_dir / fn for fn in filename_list] + if no_download: + fullpaths = [fp if fp.exists() else None for fp in fullpaths] else: - return already_downloaded - if results.errors: - for next_url in _BASE_URLS[1:]: - results = _retry_sample_data(results, next_url) - if not results.errors: - break - else: - _handle_final_errors(results) - return results + already_downloaded + to_download = zip(filename_list, fullpaths, strict=True) + if not force_download: + to_download = [(fn, fp) for fn, fp in to_download if not fp.exists()] + if to_download: + results = _download_sample_data(_BASE_URLS[0], to_download, overwrite=force_download) + # Try the other mirrors for any download errors + if results.errors: + for next_url in _BASE_URLS[1:]: + results = _retry_sample_data(results, next_url) + if not results.errors: + break + else: + _handle_final_errors(results) + raise RuntimeError + return fullpaths diff --git a/aiapy/data/sample.py b/aiapy/data/sample.py index 14699bb..1b53a9f 100644 --- a/aiapy/data/sample.py +++ b/aiapy/data/sample.py @@ -1,34 +1,79 @@ """ -This module provides the following sample data files. These files are -downloaded when this module is imported for the first time. +This module provides the following sample data files. +When a sample shortname is accessed, the corresponding file is downloaded if needed. +All files can be downloaded by calling :func:`~irispy.data.sample.download_all`. + +Summary variables +----------------- +.. list-table:: + :widths: auto + + * - ``file_dict`` + - Dictionary of all sample shortnames and, if downloaded, corresponding + file locations on disk (otherwise, ``None``) + * - ``file_list`` + - List of disk locations for sample data files that have been downloaded + +Sample shortnames +----------------- .. list-table:: :widths: auto :header-rows: 1 - * - Variable name + * - Sample shortname - Name of downloaded file """ -import sys -from pathlib import Path +from ._sample import _SAMPLE_DATA, _get_sample_files + +# Add a table row to the module docstring for each sample file +for _keyname, _filename in sorted(_SAMPLE_DATA.items()): + __doc__ += f" * - ``{_keyname}``\n - {_filename}\n" # NOQA: A001 + + +# file_dict and file_list are not normal variables; see __getattr__() below +__all__ = [ # NOQA: PLE0604, F822 + "download_all", + "file_dict", + "file_list", + *sorted(_SAMPLE_DATA.keys()), +] + + +# See PEP 562 (https://peps.python.org/pep-0562/) for module-level __dir__() +def __dir__(): + return __all__ -from ._sample import _SAMPLE_FILES, download_sample_data -files = download_sample_data() -file_dict = {} -for f in files: - name = Path(f).name - _key = _SAMPLE_FILES.get(name, None) - if _key: - setattr(sys.modules[__name__], _key, str(f)) - file_dict.update({_key: f}) +# See PEP 562 (https://peps.python.org/pep-0562/) for module-level __getattr__() +def __getattr__(name): + if name in _SAMPLE_DATA: + return _get_sample_files([_SAMPLE_DATA[name]])[0] + if name == "file_dict": + return dict( + sorted( + zip( + _SAMPLE_DATA.keys(), + _get_sample_files(_SAMPLE_DATA.values(), no_download=True), + strict=False, + ) + ) + ) + if name == "file_list": + return [v for v in __getattr__("file_dict").values() if v] + msg = f"module '{__name__}' has no attribute '{name}'" + raise AttributeError(msg) -# Sort the entries in the dictionary -file_dict = dict(sorted(file_dict.items())) -file_list = file_dict.values() -for keyname, filename in file_dict.items(): - __doc__ += f" * - ``{keyname}``\n - {Path(filename).name}\n" +def download_all(*, force_download=False): + """ + Download all sample data at once that has not already been downloaded. -__all__ = [*list(_SAMPLE_FILES.values()), "file_dict", "file_list"] + Parameters + ---------- + force_download : `bool` + If ``True``, files are downloaded even if they already exist. Default is + ``False``. + """ + _get_sample_files(_SAMPLE_DATA.values(), force_download=force_download) diff --git a/aiapy/psf/__init__.py b/aiapy/psf/__init__.py index 7e28e8d..6adf45f 100644 --- a/aiapy/psf/__init__.py +++ b/aiapy/psf/__init__.py @@ -1,2 +1,2 @@ -from .deconvolve import * # NOQA -from .psf import * # NOQA +from .deconvolve import * # NOQA: F403 +from .psf import * # NOQA: F403 diff --git a/aiapy/psf/deconvolve.py b/aiapy/psf/deconvolve.py index ac379e1..c7e1838 100644 --- a/aiapy/psf/deconvolve.py +++ b/aiapy/psf/deconvolve.py @@ -93,7 +93,7 @@ def deconvolve(smap, *, psf=None, iterations=25, clip_negative=True, use_gpu=Tru ratio = img / np.fft.irfft2(np.fft.rfft2(img_decon) * psf) img_decon = img_decon * np.fft.irfft2(np.fft.rfft2(ratio) * psf_conj) - return smap._new_instance( + return smap._new_instance( # NOQA: SLF001 cupy.asnumpy(img_decon) if (HAS_CUPY and use_gpu) else img_decon, copy.deepcopy(smap.meta), plot_settings=copy.deepcopy(smap.plot_settings), diff --git a/aiapy/psf/psf.py b/aiapy/psf/psf.py index 80018e5..0936803 100644 --- a/aiapy/psf/psf.py +++ b/aiapy/psf/psf.py @@ -97,8 +97,7 @@ def filter_mesh_parameters(*, use_preflightcore=False): * reference: 'AIA20101016_190905_0335.fits' """ return { - 94 - * u.angstrom: { + 94 * u.angstrom: { "angle_arm": [49.81, 40.16, -40.28, -49.92] * u.deg, "error_angle_arm": [0.02, 0.02, 0.02, 0.02] * u.deg, "spacing_e": 8.99 * u.pixel, @@ -108,8 +107,7 @@ def filter_mesh_parameters(*, use_preflightcore=False): "width": (0.951 if use_preflightcore else 4.5) * u.pixel, "CDELT": [0.600109, 0.600109] * u.arcsec, }, - 131 - * u.angstrom: { + 131 * u.angstrom: { "angle_arm": [50.27, 40.17, -39.70, -49.95] * u.deg, "error_angle_arm": [0.02, 0.02, 0.02, 0.02] * u.deg, "spacing_e": 12.37 * u.pixel, @@ -119,8 +117,7 @@ def filter_mesh_parameters(*, use_preflightcore=False): "width": (1.033 if use_preflightcore else 4.5) * u.pixel, "CDELT": [0.600698, 0.600698] * u.arcsec, }, - 171 - * u.angstrom: { + 171 * u.angstrom: { "angle_arm": [49.81, 39.57, -40.13, -50.38] * u.deg, "error_angle_arm": [0.02, 0.02, 0.02, 0.02] * u.deg, "spacing_e": 16.26 * u.pixel, @@ -130,8 +127,7 @@ def filter_mesh_parameters(*, use_preflightcore=False): "width": (0.962 if use_preflightcore else 4.5) * u.pixel, "CDELT": [0.599489, 0.599489] * u.arcsec, }, - 193 - * u.angstrom: { + 193 * u.angstrom: { "angle_arm": [49.82, 39.57, -40.12, -50.37] * u.deg, "error_angle_arm": [0.02, 0.02, 0.03, 0.04] * u.deg, "spacing_e": 18.39 * u.pixel, @@ -141,8 +137,7 @@ def filter_mesh_parameters(*, use_preflightcore=False): "width": (1.512 if use_preflightcore else 4.5) * u.pixel, "CDELT": [0.600758, 0.600758] * u.arcsec, }, - 211 - * u.angstrom: { + 211 * u.angstrom: { "angle_arm": [49.78, 40.08, -40.34, -49.95] * u.deg, "error_angle_arm": [0.02, 0.02, 0.02, 0.02] * u.deg, "spacing_e": 19.97 * u.pixel, @@ -152,8 +147,7 @@ def filter_mesh_parameters(*, use_preflightcore=False): "width": (1.199 if use_preflightcore else 4.5) * u.pixel, "CDELT": [0.600758, 0.600758] * u.arcsec, }, - 304 - * u.angstrom: { + 304 * u.angstrom: { "angle_arm": [49.76, 40.18, -40.14, -49.90] * u.degree, "error_angle_arm": [0.02, 0.02, 0.02, 0.02] * u.deg, "spacing_e": 28.87 * u.pixel, @@ -163,8 +157,7 @@ def filter_mesh_parameters(*, use_preflightcore=False): "width": (1.247 if use_preflightcore else 4.5) * u.pixel, "CDELT": [0.600165, 0.600165] * u.arcsec, }, - 335 - * u.angstrom: { + 335 * u.angstrom: { "angle_arm": [50.40, 39.80, -39.64, -50.25] * u.degree, "error_angle_arm": [0.02, 0.02, 0.02, 0.02] * u.deg, "spacing_e": 31.83 * u.pixel, @@ -325,7 +318,7 @@ def _psf(meshinfo, angles, diffraction_orders, *, focal_plane=False, use_gpu=Tru if order == 0: continue intensity = np.sinc(order / mesh_ratio) ** 2 # I_0 - for dx, dy in zip(spacing_x.value, spacing_y.value): + for dx, dy in zip(spacing_x.value, spacing_y.value, strict=True): x_centered = x - (0.5 * Nx + dx * order + 0.5) y_centered = y - (0.5 * Ny + dy * order + 0.5) # NOTE: this step is the bottleneck and is VERY slow on a CPU diff --git a/aiapy/psf/tests/test_deconvolve.py b/aiapy/psf/tests/test_deconvolve.py index eeefedc..f6650b1 100644 --- a/aiapy/psf/tests/test_deconvolve.py +++ b/aiapy/psf/tests/test_deconvolve.py @@ -8,12 +8,7 @@ def test_deconvolve(aia_171_map): - # Skip this test if cupy is not installed because it is too - # slow. This is mostly for the benefit of the CI. - try: - import cupy # NOQA - except ImportError: - pytest.skip("Cannot import cupy. Skipping deconvolution test with full PSF") + pytest.importorskip(modname="cupy", reason="Cannot import cupy. Skipping deconvolution test with full PSF") map_decon = aiapy.psf.deconvolve(aia_171_map) assert isinstance(map_decon, sunpy.map.GenericMap) assert map_decon.data.shape == aia_171_map.data.shape @@ -26,11 +21,11 @@ def test_deconvolve_specify_psf(aia_171_map, psf): def test_deconvolve_negative_pixels(aia_171_map, psf): - aia_171_map_neg = aia_171_map._new_instance( + aia_171_map_neg = aia_171_map._new_instance( # NOQA: SLF001 np.where(aia_171_map.data < 1, -1, aia_171_map.data), aia_171_map.meta, ) - with pytest.warns(AiapyUserWarning): + with pytest.warns(AiapyUserWarning, match="Image contains negative intensity values."): aiapy.psf.deconvolve( aia_171_map_neg, psf=psf, diff --git a/aiapy/response/__init__.py b/aiapy/response/__init__.py index 8495f8e..c8da563 100644 --- a/aiapy/response/__init__.py +++ b/aiapy/response/__init__.py @@ -2,4 +2,4 @@ Subpackage for AIA response functions. """ -from .channel import * # NOQA +from .channel import * # NOQA: F403 diff --git a/aiapy/response/channel.py b/aiapy/response/channel.py index 2f9f2d6..34fde44 100644 --- a/aiapy/response/channel.py +++ b/aiapy/response/channel.py @@ -58,7 +58,7 @@ class Channel: -------- >>> import astropy.units as u >>> from aiapy.response import Channel - >>> c = Channel(171*u.angstrom) # doctest: +REMOTE_DATA + >>> c = Channel(171 * u.angstrom) # doctest: +REMOTE_DATA >>> c.telescope_number # doctest: +REMOTE_DATA 3 >>> c.name # doctest: +REMOTE_DATA @@ -88,10 +88,7 @@ def _get_instrument_data(self, instrument_file): if isinstance(instrument_file, collections.OrderedDict): return instrument_file if instrument_file is None: - if self.is_fuv: - instrument_file = self._get_fuv_instrument_file() - else: - instrument_file = self._get_euv_instrument_file() + instrument_file = self._get_fuv_instrument_file() if self.is_fuv else self._get_euv_instrument_file() return read_genx(instrument_file) @manager.require("instrument_file_euv", *URL_HASH[VERSION_NUMBER]["euv"]) diff --git a/aiapy/response/tests/test_channel.py b/aiapy/response/tests/test_channel.py index f533c5b..5fa552f 100644 --- a/aiapy/response/tests/test_channel.py +++ b/aiapy/response/tests/test_channel.py @@ -14,7 +14,7 @@ # Mark all tests which use this fixture as online @pytest.fixture(params=[pytest.param(None, marks=pytest.mark.remote_data)]) -def channel(request, ssw_home): +def channel(request, ssw_home): # NOQA: ARG001 if ssw_home is not None: instrument_file = Path(ssw_home) / "sdo" / "aia" / "response" / f"aia_V{VERSION_NUMBER}_all_fullinst.genx" else: @@ -58,16 +58,16 @@ def required_keys(): def test_has_instrument_data(channel): assert hasattr(channel, "_instrument_data") - assert isinstance(channel._instrument_data, collections.OrderedDict) + assert isinstance(channel._instrument_data, collections.OrderedDict) # NOQA: SLF001 def test_has_channel_data(channel): assert hasattr(channel, "_data") - assert isinstance(channel._data, MetaDict) + assert isinstance(channel._data, MetaDict) # NOQA: SLF001 def test_channel_data_has_keys(channel, required_keys): - assert all(k in channel._data for k in required_keys) + assert all(k in channel._data for k in required_keys) # NOQA: SLF001 def test_has_wavelength(channel): @@ -226,7 +226,7 @@ def test_wavelength_response_time(channel, idl_environment, include_eve_correcti def test_fuv_channel(channel_wavelength, channel_properties, required_keys): # There are a few corner cases for the 1600, 1700, and 4500 channels channel = Channel(channel_wavelength) - assert all(k in channel._data for k in required_keys) + assert all(k in channel._data for k in required_keys) # NOQA: SLF001 for p in channel_properties: assert isinstance(getattr(channel, p), u.Quantity) assert channel.contamination == u.Quantity( diff --git a/aiapy/tests/test_idl.py b/aiapy/tests/test_idl.py index afebf1e..9a0f82f 100644 --- a/aiapy/tests/test_idl.py +++ b/aiapy/tests/test_idl.py @@ -58,7 +58,6 @@ def test_error_consistent(idl_environment, channel, counts, include_eve, include assert u.allclose(error, error_ssw, rtol=1e-4) -@pytest.fixture(scope="module") @pytest.mark.parametrize("channel", CHANNELS) def psf_idl(idl_environment, channels): """ diff --git a/aiapy/util/__init__.py b/aiapy/util/__init__.py index 8874218..218acab 100644 --- a/aiapy/util/__init__.py +++ b/aiapy/util/__init__.py @@ -2,5 +2,5 @@ Subpackage with miscellaneous utility functions. """ -from .exceptions import * # NOQA -from .util import * # NOQA +from .exceptions import * # NOQA: F403 +from .util import * # NOQA: F403 diff --git a/aiapy/util/decorators.py b/aiapy/util/decorators.py index 8a09246..8c49364 100644 --- a/aiapy/util/decorators.py +++ b/aiapy/util/decorators.py @@ -1,5 +1,5 @@ -import inspect import functools +import inspect import astropy.units as u @@ -32,14 +32,16 @@ def validate_channel(argument, *, valid_channels="all"): def outer(function): sig = inspect.signature(function) if argument not in sig.parameters: - raise ValueError(f"Did not find {argument} in function signature ({sig}).") + msg = f"Did not find {argument} in function signature ({sig})." + raise ValueError(msg) @functools.wraps(function) def inner(*args, **kwargs): all_args = sig.bind(*args, **kwargs) channel = all_args.arguments[argument] if channel not in valid_channels: - raise ValueError(f'channel "{channel}" not in ' f"list of valid channels: {valid_channels}.") + msg = f'channel "{channel}" not in ' f"list of valid channels: {valid_channels}." + raise ValueError(msg) return function(*args, **kwargs) return inner diff --git a/aiapy/util/util.py b/aiapy/util/util.py index 521ce16..547ad57 100644 --- a/aiapy/util/util.py +++ b/aiapy/util/util.py @@ -39,7 +39,8 @@ def sdo_location(time): key="T_OBS, HAEX_OBS, HAEY_OBS, HAEZ_OBS", ) if keys is None or len(keys) == 0: - raise ValueError("No DRMS records near this time") + msg = "No DRMS records near this time" + raise ValueError(msg) # Linear interpolation between the nearest records within the returned set times = Time(list(keys["T_OBS"]), scale="utc") x = np.interp(t.mjd, times.mjd, keys["HAEX_OBS"]) diff --git a/aiapy/version.py b/aiapy/version.py index b90b60f..eafe378 100644 --- a/aiapy/version.py +++ b/aiapy/version.py @@ -1,12 +1,12 @@ # NOTE: First try _dev.scm_version if it exists and setuptools_scm is installed -# This file is not included in wheels/tarballs, so otherwise it will +# This file is not included in irispy-lmsal wheels/tarballs, so otherwise it will # fall back on the generated _version module. try: try: from ._dev.scm_version import version except ImportError: from ._version import version -except Exception: # NOQA +except Exception: # NOQA: BLE001 import warnings warnings.warn( @@ -14,5 +14,10 @@ stacklevel=3, ) del warnings - version = "0.0.0" + +from packaging.version import parse as _parse + +_version = _parse(version) +major, minor, bugfix = [*_version.release, 0][:3] +release = not _version.is_devrelease diff --git a/changelog/313.breaking.rst b/changelog/313.breaking.rst index ba45935..fbd6815 100644 --- a/changelog/313.breaking.rst +++ b/changelog/313.breaking.rst @@ -1 +1 @@ -Increased the minimum version of Python to 3.9 +Increased the minimum version of Python to 3.10 diff --git a/docs/conf.py b/docs/conf.py index 4479a0d..9207a3c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -3,37 +3,13 @@ """ import os -import sys -import datetime -import warnings -from pathlib import Path -from packaging.version import Version - -# -- Read the Docs Specific Configuration -------------------------------------- # This needs to be done before aiapy or sunpy is imported os.environ["PARFIVE_HIDE_PROGRESS"] = "True" -# -- Check for dependencies ---------------------------------------------------- -from sunpy.util import missing_dependencies_by_extra # NOQA: E402 - -missing_requirements = missing_dependencies_by_extra("aiapy")["docs"] -if missing_requirements: - print( # NOQA: T201 - f"The {' '.join(missing_requirements.keys())} package(s) could not be found and " - "is needed to build the documentation, please install the 'docs' requirements.", - ) - sys.exit(1) - -# -- Project information ------------------------------------------------------- -project = "aiapy" -author = "AIA Instrument Team" -copyright = f"{datetime.datetime.now().year}, {author}" - -# Register remote data option with doctest -import doctest # NOQA: E402 - -REMOTE_DATA = doctest.register_optionflag("REMOTE_DATA") +import datetime # NOQA: E402 +import warnings # NOQA: E402 +from pathlib import Path # NOQA: E402 from astropy.utils.exceptions import AstropyDeprecationWarning # NOQA: E402 from matplotlib import MatplotlibDeprecationWarning # NOQA: E402 @@ -42,17 +18,19 @@ from aiapy import __version__ # NOQA: E402 -# The full version, including alpha/beta/rc tags +# -- Project information ------------------------------------------------------- +project = "aiapy" +author = "AIA Instrument Team" +copyright = f"{datetime.datetime.now(datetime.timezone.utc).year}, {author}" # NOQA: A001 release = __version__ -aiapy_version = Version(__version__) -is_release = not (aiapy_version.is_prerelease or aiapy_version.is_devrelease) +is_development = ".dev" in __version__ +# Need to make sure that our documentation does not raise any of these warnings.filterwarnings("error", category=SunpyDeprecationWarning) warnings.filterwarnings("error", category=SunpyPendingDeprecationWarning) warnings.filterwarnings("error", category=MatplotlibDeprecationWarning) warnings.filterwarnings("error", category=AstropyDeprecationWarning) -# For the linkcheck linkcheck_ignore = [ r"https://doi.org/\d+", r"https://element.io/\d+", @@ -62,14 +40,6 @@ linkcheck_anchors = False # -- General configuration ----------------------------------------------------- -# sphinxext-opengraph -ogp_image = "https://raw.githubusercontent.com/sunpy/sunpy-logo/master/generated/sunpy_logo_word.png" -ogp_use_first_image = True -ogp_description_length = 160 -ogp_custom_meta_tags = [ - '', -] -suppress_warnings = ["app.add_directive"] extensions = [ "matplotlib.sphinxext.plot_directive", "sphinx_automodapi.automodapi", @@ -101,6 +71,7 @@ napoleon_use_rtype = False napoleon_google_docstring = False napoleon_use_param = False +suppress_warnings = ["app.add_directive"] nitpicky = True # This is not used. See docs/nitpick-exceptions file for the actual listing. nitpick_ignore = [] @@ -112,42 +83,34 @@ target = target.strip() nitpick_ignore.append((dtype, target)) -# -- Options for intersphinx extension ----------------------------------------- -intersphinx_mapping = { - "astropy": ("https://docs.astropy.org/en/stable/", None), - "cupy": ("https://docs.cupy.dev/en/stable/", None), - "drms": ("https://docs.sunpy.org/projects/drms/en/stable/", None), - "matplotlib": ("https://matplotlib.org/stable", None), - "numpy": ("https://numpy.org/doc/stable/", (None, "http://www.astropy.org/astropy-data/intersphinx/numpy.inv")), - "parfive": ("https://parfive.readthedocs.io/en/stable/", None), - "pyerfa": ("https://pyerfa.readthedocs.io/en/stable/", None), - "python": ("https://docs.python.org/3/", (None, "http://www.astropy.org/astropy-data/intersphinx/python3.inv")), - "reproject": ("https://reproject.readthedocs.io/en/stable/", None), - "scipy": ( - "https://docs.scipy.org/doc/scipy/reference/", - (None, "http://www.astropy.org/astropy-data/intersphinx/scipy.inv"), - ), - "skimage": ("https://scikit-image.org/docs/stable/", None), - "sunpy": ("https://docs.sunpy.org/en/stable/", None), -} +# -- Options for sphinxext-opengraph ------------------------------------------ +ogp_image = "https://raw.githubusercontent.com/sunpy/sunpy-logo/master/generated/sunpy_logo_word.png" +ogp_use_first_image = True +ogp_description_length = 160 +ogp_custom_meta_tags = [ + '', +] + +# -- Options for sphinx-copybutton --------------------------------------------- +# Python Repl + continuation, Bash, ipython and qtconsole + continuation, jupyter-console + continuation +copybutton_prompt_text = r">>> |\.\.\. |\$ |In \[\d*\]: | {2,5}\.\.\.: | {5,8}: " +copybutton_prompt_is_regexp = True # -- Options for hoverxref ----------------------------------------------------- if os.environ.get("READTHEDOCS"): hoverxref_api_host = "https://readthedocs.org" - if os.environ.get("PROXIED_API_ENDPOINT"): # Use the proxied API endpoint - # A RTD thing to avoid a CSRF block when docs are using a custom domain + # - A RTD thing to avoid a CSRF block when docs are using a + # custom domain hoverxref_api_host = "/_" - -hoverxref_auto_ref = False -hoverxref_domains = ["py"] -hoverxref_mathjax = True -hoverxref_modal_hover_delay = 500 hoverxref_tooltip_maxwidth = 600 # RTD main window is 696px -hoverxref_intersphinx = list(intersphinx_mapping.keys()) +hoverxref_auto_ref = True +hoverxref_mathjax = True +# hoverxref has to be applied to these +hoverxref_domains = ["py"] hoverxref_role_types = { - # Roles within the py domain + # roles with py domain "attr": "tooltip", "class": "tooltip", "const": "tooltip", @@ -157,13 +120,32 @@ "meth": "tooltip", "mod": "tooltip", "obj": "tooltip", - # Roles within the std domain + # roles with std domain "confval": "tooltip", "hoverxref": "tooltip", - "ref": "tooltip", # Would be used by hoverxref_auto_ref if we set it to True + "ref": "tooltip", "term": "tooltip", } +# -- Options for intersphinx extension ----------------------------------------- +intersphinx_mapping = { + "astropy": ("https://docs.astropy.org/en/stable/", None), + "cupy": ("https://docs.cupy.dev/en/stable/", None), + "drms": ("https://docs.sunpy.org/projects/drms/en/stable/", None), + "matplotlib": ("https://matplotlib.org/stable", None), + "numpy": ("https://numpy.org/doc/stable/", (None, "http://www.astropy.org/astropy-data/intersphinx/numpy.inv")), + "parfive": ("https://parfive.readthedocs.io/en/stable/", None), + "pyerfa": ("https://pyerfa.readthedocs.io/en/stable/", None), + "python": ("https://docs.python.org/3/", (None, "http://www.astropy.org/astropy-data/intersphinx/python3.inv")), + "reproject": ("https://reproject.readthedocs.io/en/stable/", None), + "scipy": ( + "https://docs.scipy.org/doc/scipy/reference/", + (None, "http://www.astropy.org/astropy-data/intersphinx/scipy.inv"), + ), + "skimage": ("https://scikit-image.org/docs/stable/", None), + "sunpy": ("https://docs.sunpy.org/en/stable/", None), +} + # -- Options for HTML output --------------------------------------------------- html_theme = "sunpy" graphviz_output_format = "svg" @@ -186,7 +168,6 @@ "examples_dirs": Path("..") / "examples", "gallery_dirs": Path("generated") / "gallery", "matplotlib_animations": True, - # Comes from the theme. "default_thumb_file": PNG_ICON, "abort_on_example_error": False, "plot_gallery": "True", @@ -194,8 +175,3 @@ "doc_module": ("aiapy"), "only_warn_on_example_error": True, } - -# -- Options for sphinx-copybutton --------------------------------------------- -# Python Repl + continuation, Bash, ipython and qtconsole + continuation, jupyter-console + continuation -copybutton_prompt_text = r">>> |\.\.\. |\$ |In \[\d*\]: | {2,5}\.\.\.: | {5,8}: " -copybutton_prompt_is_regexp = True diff --git a/examples/skip_psf_deconvolution.py b/examples/skip_psf_deconvolution.py index 7f7809a..800b807 100644 --- a/examples/skip_psf_deconvolution.py +++ b/examples/skip_psf_deconvolution.py @@ -20,9 +20,9 @@ # AIA images are subject to convolution with the instrument point-spread # function (PSF) due to effects introduced by the filter mesh of the telescope # and the CCD, among others. This has the effect of "blurring" the image. -# The PSF diffraction pattern may also be particularly noticable during the +# The PSF diffraction pattern may also be particularly noticeable during the # impulsive phase of a flare where the intensity enhancement is very localized. -# To remove these artifacts, the PSF must be deconvolved from the image. +# To remove these artifacts, the PSF must be de-convolved from the image. # # First, we'll use a single level 1 image from the 171 Å channel from # 15 March 2019. Note that deconvolution should be performed on level 1 images diff --git a/pyproject.toml b/pyproject.toml index 53e1ffb..c2e2ca4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ name = "aiapy" dynamic = ["version"] description = "Python library for AIA data analysis." readme = "README.rst" -requires-python = ">=3.9" +requires-python = ">=3.10" license = {file = "LICENSE.txt"} keywords = [ "solar physics", @@ -25,7 +25,7 @@ keywords = [ ] authors = [ {email = "freij@baeri.org"}, - {name = "AIA Instrument Team"} + {name = "AIA Instrument Team @ LMSAL"} ] classifiers = [ "Development Status :: 4 - Beta", @@ -35,7 +35,6 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -54,13 +53,15 @@ changelog = "https://aiapy.readthedocs.io/en/stable/changelog.html" [project.optional-dependencies] all = ["aiapy"] cupy = [ - 'cupy', + "cupy", ] tests = [ "aiapy[all]", "hissw", "pytest", "pytest-astropy", + "pytest-cov", + "pytest-xdist", ] docs = [ "aiapy[all]", @@ -87,118 +88,12 @@ write_to = "aiapy/_version.py" [tool.setuptools.exclude-package-data] aiapy = ["aiapy._dev"] -[tool.pytest.ini_options] -testpaths = [ - "aiapy", - "docs", -] -norecursedirs = [ - ".tox", - "build", - '''docs[\/]_build''', - '''docs[\/]generated''', - "*.egg-info", - "examples", - '''aiapy[/\]_dev''', - ".jupyter", - ".history", -] -doctest_plus = "enabled" -doctest_optionflags = "NORMALIZE_WHITESPACE FLOAT_CMP ELLIPSIS" -addopts = "--doctest-rst --doctest-ignore-import-errors -p no:unraisableexception -p no:threadexception" -markers = [ - "remote_data: marks this test function as needing remote data.", -] -remote_data_strict = "True" -filterwarnings = [ - "error", - "always::pytest.PytestConfigWarning", - "ignore:.*deprecated and slated for removal in Python 3.13", - "ignore:numpy.ufunc size changed:RuntimeWarning", - "ignore:numpy.ndarray size changed:RuntimeWarning", - "ignore:.*unitfix.*", - "ignore:invalid value encountered in sqrt", -] - [tool.coverage.run] branch = true omit = [ "*test_idl.py", ] -[tool.black] -line-length = 120 -target-version = ['py39'] - -[tool.isort] -profile = "black" -line_length = 120 -length_sort = "False" -length_sort_sections = "stdlib" - -[tool.ruff] -# Allow unused variables when underscore-prefixed. -dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" -target-version = "py39" -line-length = 120 -exclude = [ - ".eggs", - ".git", - ".mypy_cache", - ".ruff_cache", - ".tox", - ".venv", - "__pypackages__", - "_build", - "build", - "dist", - "node_modules", - "venv", -] -select = [ - "E", - "F", - "W", - "UP", - "PT", - "RET", - "TID", - "PLE", - "NPY", - "RUF", - "PGH", - "PTH", - "BLE", - "FBT", - "B", - "A", - "COM", - "C4", - "T20", - "RSE", - "ERA", -] -ignore = ["E501"] -extend-ignore = [ - "PGH004", # NOQA IS THE BEST OF ALL TIME -] - -[tool.ruff.per-file-ignores] -"examples/*.py" = [ - "T201", # We need print in our examples -] -"docs/*.py" = [ - "INP001", # implicit-namespace-package. The examples are not a package. - "A001", # Variable `copyright` is shadowing a python builtin -] -"aiapy/data/sample.py" = [ -"A001", # Variable `__doc__` is shadowing a python builtin -"PLE0604", # Invalid object in `__all__`, must contain only strings -] - -[tool.ruff.pydocstyle] -convention = "numpy" - [tool.codespell] ignore-words-list = "emiss" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..971523a --- /dev/null +++ b/pytest.ini @@ -0,0 +1,48 @@ +[pytest] +minversion = 7.0 +testpaths = + aiapy + docs +norecursedirs = + .tox + build + docs/_build + docs/generated + *.egg-info + examples + aiapy/_dev + .history +doctest_plus = enabled +doctest_optionflags = NORMALIZE_WHITESPACE FLOAT_CMP ELLIPSIS +addopts = --arraydiff --doctest-rst --doctest-ignore-import-errors -p no:unraisableexception -p no:threadexception +remote_data_strict = true +junit_family = xunit1 +filterwarnings = + error + # Do not fail on pytest config issues (i.e. missing plugins) but do show them + always::pytest.PytestConfigWarning + # A list of warnings to ignore follows. If you add to this list, you MUST + # add a comment or ideally a link to an issue that explains why the warning + # is being ignored + # https://github.com/pytest-dev/pytest-cov/issues/557 + # It was fixed and released but it does not seem to be fixed + ignore:The --rsyncdir command line argument and rsyncdirs config variable are deprecated.:DeprecationWarning + # Raised by sunpy.coordinates.transformations and will be removed in sunpy 6.1 + ignore:.*module is deprecated, as it was designed for internal use + # https://github.com/pandas-dev/pandas/issues/54466 + # Should stop when pandas 3.0.0 is released + ignore:(?s).*Pyarrow will become a required dependency of pandas:DeprecationWarning + # Zeep relies on deprecated cgi in Python 3.11 + # Needs a release of zeep 4.2.2 or higher + # https://github.com/mvantellingen/python-zeep/pull/1364 + ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning + # Can be removed when https://github.com/dateutil/dateutil/issues/1314 is resolved + # deprecated in Python 3.12, needs a release of dateutil 2.8.3 or higher + ignore:datetime.datetime.utcfromtimestamp():DeprecationWarning + # Raised by changing 'seconds' -> 's' + ignore::astropy.wcs.wcs.FITSFixedWarning + # SOURCE_UNCERTAINTY_DN = np.sqrt(SOURCE_DATA_DN) + ignore:invalid value encountered in sqrt:RuntimeWarning + # The following are raised by the py310-oldestdeps job + ignore:distutils Version classes are deprecated + ignore:ERFA function * diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..a3c7395 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,86 @@ +# Allow unused variables when underscore-prefixed. +lint.dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +target-version = "py310" +line-length = 120 +exclude=[ + ".git,", + "__pycache__", + "build", + "tools/**", +] +lint.select = [ + "A", + "ARG", + "ASYNC", + "B", + "BLE", + "C4", +# "C90", + "COM", +# "D", + "DTZ", + "E", + "EM", + "ERA", + "EXE", + "F", + "FBT", + "FLY", +# "FURB", + "G", + "I", + "ICN", + "INP", + "INT", + "ISC", + "LOG", +# "N", + "NPY", + "PERF", + "PGH", + "PIE", +# "PL", + "PLE", + "PT", + "PTH", + "PYI", + "Q", + "RET", + "RSE", + "RUF", +# "S", + "SIM", + "SLF", + "SLOT", + "T10", + "T20", + "TCH", + "TID", + "TRIO", + "TRY", + "UP", + "W", + "YTT", +] +lint.extend-ignore = [ + "E501", # Line too long + "COM812", # May cause conflicts when used with the formatter + "ISC001", # May cause conflicts when used with the formatter +] + +[lint.per-file-ignores] +"examples/*.py" = [ + "INP001", # examples is part of an implicit namespace package + "T201", # We need print in our examples +] +"docs/conf.py" = [ + "INP001", # conf.py is part of an implicit namespace package +] + +[lint.pydocstyle] +convention = "numpy" + +[format] +docstring-code-format = true +indent-style = "space" +quote-style = "double" diff --git a/tox.ini b/tox.ini index e8f545b..d531f07 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] minversion = 4.0 envlist = - py{39,310,311}{,-online,-devdeps,-rc} + py{310,311,312}{,-online,-devdeps,-rc} build_docs codestyle @@ -13,7 +13,7 @@ allowlist_externals= setenv = MPLBACKEND = agg SUNPY_SAMPLEDIR = {env:SUNPY_SAMPLEDIR:{toxinidir}/.tox/sample_data/} - PYTEST_COMMAND = pytest -vvv -r as --pyargs aiapy --cov-report=xml --cov=aiapy {toxinidir}/docs + PYTEST_COMMAND = pytest -vvv -r as --pyargs aiapy --cov-report=xml --cov=aiapy -n auto --color=yes {toxinidir}/docs devdeps,build_docs,online: HOME = {envtmpdir} PARFIVE_HIDE_PROGRESS = True devdeps: PIP_EXTRA_INDEX_URL = https://pypi.anaconda.org/astropy/simple https://pypi.anaconda.org/scientific-python-nightly-wheels/simple @@ -26,8 +26,6 @@ deps = online: pytest-rerunfailures online: pytest-timeout rc: sunpy>=0.0.dev0 - pytest-cov - pytest-xdist extras = all tests