Repository: sunpy/sunpy

Issue:
XRT Maps do not include unit information
XRT maps, from any level (as far as I can tell), do not include a `BUNIT` keyword or any equivalent keyword to encode the unit information.

However, according to Table 1.1 in [the XRT analysis guide](https://xrt.cfa.harvard.edu/resources/documents/XAG/XAG.pdf), the units for the different levels are DN for level 0 and DN/s for level 1. However, there is also a note that level 1 can be DN or DN/s, depending on which options are passed to `xrt_prep`. When level 1 data is divided by the exposure time (to be put in units of DN/s), there is a tag added to the `HISTORY` that indicates that the data have been renormalized. When pulling in the data, the source could check for this tag and set the unit appropriately and otherwise fall back to DN.

Discussion:
exitflynn: Is an outcome like this what we're aiming for?
![image](https://user-images.githubusercontent.com/37214399/214324087-58e4db03-7977-4a8c-9091-0f7f556e6b1d.png)
Looks alright to me just that the unit doesn't show up among other properties...
nabobalis: We should add the reference information as to how we know what the unit is in the unit code.
exitflynn: does the message work? The XRT Analysis Guide is already (indirectly) linked to in the main doc string
nabobalis: Reads fine to me. It will need a bugfix changelog. 

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1c823e7ac..3230f8e60 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,7 +31,7 @@ jobs:
         apt:
           - libopenjp2-7
       envs: |
-        - linux: py310
+        - linux: py311
 
   test:
     needs: [core]
@@ -42,23 +42,18 @@ jobs:
       toxdeps: "'tox<4' tox-pypi-filter"
       posargs: -n auto
       libraries: |
-        apt:
-          - libopenjp2-7
-          # This can be removed when there are binaries for h5py on 3.11
-          - libhdf5-dev
         brew:
           - openjpeg
       envs: |
-        - linux: py311
-        - windows: py39
-        - macos: py38
-        - linux: py38-oldestdeps
+        - windows: py310
+        - macos: py39
+        - linux: py39-oldestdeps
 
   docs:
     needs: [core]
     uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@main
     with:
-      default_python: '3.8'
+      default_python: '3.9'
       submodules: false
       pytest: false
       toxdeps: "'tox<4' tox-pypi-filter"
@@ -79,7 +74,7 @@ jobs:
     needs: [docs]
     uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@main
     with:
-      default_python: '3.8'
+      default_python: '3.9'
       submodules: false
       coverage: codecov
       toxdeps: "'tox<4' tox-pypi-filter"
@@ -110,7 +105,7 @@ jobs:
       )
     uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@main
     with:
-      default_python: '3.8'
+      default_python: '3.9'
       submodules: false
       coverage: codecov
       toxdeps: "'tox<4' tox-pypi-filter"
@@ -118,8 +113,6 @@ jobs:
       libraries: |
         apt:
           - libopenjp2-7
-          # This can be removed when there are binaries for h5py on 3.11
-          - libhdf5-dev
       envs: |
         - linux: base_deps
         - linux: py311-devdeps
@@ -146,15 +139,9 @@ jobs:
       test_command: 'pytest -p no:warnings --doctest-rst -m "not mpl_image_compare" --pyargs sunpy'
       submodules: false
       targets: |
-        - cp3{8,9,10}-manylinux*_x86_64
-        - cp3{8,9,10}-macosx_x86_64
-        - cp3{8,9,10}-macosx_arm64
-        # Split these out for more rapid debugging
-        - cp311-manylinux*_x86_64
-        - cp311-macosx_x86_64
-        - cp311-macosx_arm64
-      # This can be removed when there are binaries for h5py on 3.11
-      libraries: libhdf5-dev
+        - cp3{9,10,11}-manylinux*_x86_64
+        - cp3{9,10,11}-macosx_x86_64
+        - cp3{9,10,11}-macosx_arm64
     secrets:
       pypi_token: ${{ secrets.pypi_token }}
 
diff --git a/.gitignore b/.gitignore
index cbc5f8657..784b0f3a6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -218,6 +218,7 @@ baseline
 docs/guide/data_types/figure.png
 october_M1_flares.csv
 aia_map.fits
+docs/intro/figure.png
 
 ### Pycharm(?)
 .idea
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index d93f0e4c4..da4288211 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -28,7 +28,7 @@ repos:
           ]
         exclude: ".*(.fits|.fts|.fit|.header|.txt|tca.*|extern.*|.rst|.md|__init__.py|sunpy/extern|docs/conf.py)$"
   - repo: https://github.com/PyCQA/isort
-    rev: 5.11.4
+    rev: 5.12.0
     hooks:
       - id: isort
         args: ["--sp", "setup.cfg"]
diff --git a/changelog/6501.doc.rst b/changelog/6501.doc.rst
new file mode 100644
index 000000000..651fbb805
--- /dev/null
+++ b/changelog/6501.doc.rst
@@ -0,0 +1,1 @@
+Added an example (:ref:`sphx_glr_generated_gallery_acquiring_data_searching_multiple_wavelengths.py`) of how to search for multiple wavelengths attributes for AIA data using `sunpy.net.attrs.AttrOr`.
diff --git a/changelog/6694.trivial.rst b/changelog/6694.trivial.rst
deleted file mode 100644
index bf3a40d05..000000000
--- a/changelog/6694.trivial.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-The current version of ``SQLAlchemy`` has been fixed to be <2.0 until we have ensured that the current database module works with it.
diff --git a/changelog/6711.bugfix.rst b/changelog/6711.bugfix.rst
new file mode 100644
index 000000000..9dd06118b
--- /dev/null
+++ b/changelog/6711.bugfix.rst
@@ -0,0 +1,2 @@
+The ``data_manager`` was not raising failed downloads correctly and would continue as if the file existed locally.
+Now it will raise any errors from ``parfive``.
diff --git a/changelog/6725.bugfix.rst b/changelog/6725.bugfix.rst
new file mode 100644
index 000000000..912636a22
--- /dev/null
+++ b/changelog/6725.bugfix.rst
@@ -0,0 +1,1 @@
+`~sunpy.map.sources.XRTMap` will now set the unit for XRT files if the ``BUNIT`` key is missing.
diff --git a/changelog/6742.feature.rst b/changelog/6742.feature.rst
new file mode 100644
index 000000000..39d5f642c
--- /dev/null
+++ b/changelog/6742.feature.rst
@@ -0,0 +1,13 @@
+The minimum required versions of several core dependencies have been updated:
+
+- Python 3.9
+- astropy 5.0.0
+- numpy 1.21.0
+
+The minimum required versions of these optional dependencies has also been updated:
+
+- Matplotlib 3.5.0
+- dask 2021.4.0
+- pandas 1.2.0
+- scikit-image 0.18.0
+- scipy 1.7.0
diff --git a/changelog/6749.bugfix.rst b/changelog/6749.bugfix.rst
new file mode 100644
index 000000000..9eea1ccbd
--- /dev/null
+++ b/changelog/6749.bugfix.rst
@@ -0,0 +1,1 @@
+Updated `~sunpy.database` to be compatible with ``SQLAlchemy`` versions >=2.0
diff --git a/docs/whatsnew/5.0.rst b/docs/whatsnew/5.0.rst
new file mode 100644
index 000000000..6cf840a12
--- /dev/null
+++ b/docs/whatsnew/5.0.rst
@@ -0,0 +1,31 @@
+.. _whatsnew-5.0:
+
+************************
+What's New in sunpy 5.0?
+************************
+The SunPy project is pleased to announce the 5.0 release of the ``sunpy`` core package.
+
+On this page, you can read about some of the big changes in this release.
+
+.. contents::
+    :local:
+    :depth: 1
+
+``sunpy`` 5.0 also includes a large number of smaller improvements and bug fixes, which are described in the :ref:`changelog`.
+
+
+Updates to minimum dependencies
+===============================
+The minimum required versions of several core dependencies have been updated:
+
+- Python 3.9
+- astropy 5.0.0
+- numpy 1.21.0
+
+The minimum required versions of these optional dependencies has also been updated:
+
+- Matplotlib 3.5.0
+- dask 2021.4.0
+- pandas 1.2.0
+- scikit-image 0.18.0
+- scipy 1.7.0
diff --git a/docs/whatsnew/index.rst b/docs/whatsnew/index.rst
index 5059bbbe4..ed69c5d73 100644
--- a/docs/whatsnew/index.rst
+++ b/docs/whatsnew/index.rst
@@ -15,6 +15,7 @@ Bugfix releases (versions x.y.1, x.y.2, etc.) are released monthly, with a targe
    :maxdepth: 1
 
    changelog
+   5.0
    4.1
    4.0
    3.1
diff --git a/examples/acquiring_data/searching_multiple_wavelengths.py b/examples/acquiring_data/searching_multiple_wavelengths.py
new file mode 100644
index 000000000..17d0aed9d
--- /dev/null
+++ b/examples/acquiring_data/searching_multiple_wavelengths.py
@@ -0,0 +1,44 @@
+"""
+==============================================
+Searching for multiple wavelengths with Fido
+==============================================
+
+This example shows how you can search for several wavelengths of AIA data with Fido.
+"""
+from astropy import units as u
+
+from sunpy.net import Fido
+from sunpy.net import attrs as a
+
+###############################################################################
+# Here we are demonstrating how you can search for specific wavelengths of
+# AIA data using `Fido <sunpy.net.fido_factory.UnifiedDownloaderFactory>`
+# and the `sunpy.net.attrs.AttrOr` function.
+# For example, you may only want a single wavelength, say 171 Angstrom:
+
+aia_search = Fido.search(a.Time("2022-02-20 00:00", "2022-02-20 00:01"),
+                         a.Instrument("AIA"),
+                         a.Wavelength(171*u.angstrom))
+
+print(aia_search)
+
+###############################################################################
+# But say you actually want to search for several wavelengths, rather than just one.
+# You could use the "|" operator, or instead you can use the `sunpy.net.attrs.AttrOr`
+# function.
+
+wavelengths = [94, 131, 171, 193, 211]*u.angstrom
+aia_search = Fido.search(a.Time("2022-02-20 00:00", "2022-02-20 00:01"),
+                         a.Instrument("AIA"),
+                         a.AttrOr([a.Wavelength(wav) for wav in wavelengths]))
+
+print(aia_search)
+
+# This returns several searches for each of the wavelengths, which can be indexed.
+# Here the first index is that of 94 angstrom.
+print(aia_search[0])
+
+###############################################################################
+# You can then pass the `Fido <sunpy.net.fido_factory.UnifiedDownloaderFactory>`
+# result to :meth:`Fido.fetch <sunpy.net.fido_factory.UnifiedDownloaderFactory.fetch>`
+# to download the data, i.e., ``Fido.fetch(aia_search)``.
diff --git a/setup.cfg b/setup.cfg
index 783624120..6e239344c 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -27,7 +27,6 @@ classifiers =
   Operating System :: OS Independent
   Programming Language :: Python
   Programming Language :: Python :: 3
-  Programming Language :: Python :: 3.8
   Programming Language :: Python :: 3.9
   Programming Language :: Python :: 3.10
   Programming Language :: Python :: 3.11
@@ -35,14 +34,14 @@ classifiers =
 
 [options]
 zip_safe = False
-python_requires = >=3.8
+python_requires = >=3.9
 packages = find:
 include_package_data = True
 setup_requires =
   setuptools_scm
 install_requires =
-  astropy>=4.2.1
-  numpy>=1.17.0
+  astropy>=5.0.0
+  numpy>=1.21.0
   packaging>=19.0
   parfive[ftp]>=1.2.0
 
@@ -52,20 +51,22 @@ asdf =
   asdf-astropy>=0.1.1
   asdf>=2.8.0
 dask =
-  dask[array]>=2.0.0
+  dask[array]>=2021.4.0
 database =
-  sqlalchemy>=1.3.4,<2.0
+  sqlalchemy>=1.3.4
 image =
-  scikit-image>=0.16.0
-  scipy>=1.3.0
+  scikit-image>=0.18.0
+  # !=1.10.0 due to https://github.com/scipy/scipy/issues/17718
+  scipy>=1.7.0,!=1.10.0
 jpeg2000 =
   glymur>=0.8.18,!=0.9.0,!=0.9.5
   lxml>=4.8.0
 map =
-  matplotlib>=3.3.0
+  matplotlib>=3.5.0
   mpl-animators>=1.0.0
   reproject
-  scipy>=1.3.0
+  # !=1.10.0 due to https://github.com/scipy/scipy/issues/17718
+  scipy>=1.7.0,!=1.10.0
 net =
   beautifulsoup4>=4.8.0
   drms>=0.6.1
@@ -78,10 +79,10 @@ timeseries =
   # While a not direct dependency
   # We need to raise this to ensure the goes netcdf files open.
   h5py>=3.1.0
-  matplotlib>=3.3.0
-  pandas>=1.0.0
+  matplotlib>=3.5.0
+  pandas>=1.2.0
 visualization =
-  matplotlib>=3.3.0
+  matplotlib>=3.5.0
   mpl-animators>=1.0.0
 tests =
   hvpy>=1.0.1
@@ -126,7 +127,7 @@ asdf.extensions =
 
 [bdist_wheel]
 # the Py_LIMITED_API version hex in _pyana.c should match the version specified here
-py_limited_api = cp38
+py_limited_api = cp39
 
 [tool:pytest]
 testpaths = "sunpy" "docs"
diff --git a/sunpy/data/data_manager/downloader.py b/sunpy/data/data_manager/downloader.py
index 07a1ceb3c..f0ddcf99f 100644
--- a/sunpy/data/data_manager/downloader.py
+++ b/sunpy/data/data_manager/downloader.py
@@ -48,6 +48,8 @@ class ParfiveDownloader(DownloaderBase):
         directory = path.parent
         downloader.enqueue_file(url, directory, filename)
         try:
-            downloader.download()
+            output = downloader.download()
         except Exception as e:
             raise DownloaderError from e
+        if output.errors:
+            raise DownloaderError(output.errors[0].exception)
diff --git a/sunpy/database/commands.py b/sunpy/database/commands.py
index 85a3d40fe..ab5b0b09a 100644
--- a/sunpy/database/commands.py
+++ b/sunpy/database/commands.py
@@ -4,6 +4,8 @@ from abc import ABC, abstractmethod
 from sqlalchemy.exc import InvalidRequestError
 from sqlalchemy.orm import make_transient
 
+from sunpy.database.tables import DatabaseEntry
+
 __all__ = [
     'EmptyCommandStackError', 'NoSuchEntryError', 'NonRemovableTagError',
     'DatabaseOperation', 'AddEntry', 'RemoveEntry', 'EditEntry',
@@ -94,8 +96,20 @@ class CompositeOperation(DatabaseOperation):
             operation()
 
     def undo(self):
+        # To prevent errors in SQLa 2.0 we have to first add all removed entries back in before we add any ~database.Tags.
+        for operation in self._operations:
+            if isinstance(operation, RemoveEntry):
+                # we also filter out ~database.commands.RemoveEntry where type is not ~database.tables.DatabaseEntry to do later.
+                if isinstance(operation.entry, DatabaseEntry):
+                    operation.undo()
         for operation in self._operations:
-            operation.undo()
+            if isinstance(operation, RemoveEntry):
+                if not isinstance(operation.entry, DatabaseEntry):
+                    # undo all ~database.commands.RemoveEntry where type is not ~database.tables.DatabaseEntry
+                    operation.undo()
+            else:
+                # and we also undo all other commands, e.g. ~database.commands.RemoveTag.
+                operation.undo()
 
     def __len__(self):
         return len(self._operations)
diff --git a/sunpy/database/tables.py b/sunpy/database/tables.py
index 94b7c3bee..0df13e037 100644
--- a/sunpy/database/tables.py
+++ b/sunpy/database/tables.py
@@ -8,8 +8,7 @@ from datetime import datetime
 
 import numpy as np
 from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Table
-from sqlalchemy.ext.declarative import declarative_base
-from sqlalchemy.orm import relationship
+from sqlalchemy.orm import declarative_base, relationship
 
 import astropy.table
 import astropy.units as u
diff --git a/sunpy/io/src/ana/_pyana.c b/sunpy/io/src/ana/_pyana.c
index b04d0b8f5..f3d857ba1 100644
--- a/sunpy/io/src/ana/_pyana.c
+++ b/sunpy/io/src/ana/_pyana.c
@@ -6,7 +6,7 @@ Based on Michiel van Noort's IDL DLM library 'f0' which contains
 a cleaned up version of the original anarw routines.
 */
 
-#define Py_LIMITED_API 0x030800f0
+#define Py_LIMITED_API 0x030900f0
 
 // Needed due to https://github.com/numpy/numpy/issues/16970
 struct _typeobject {
diff --git a/sunpy/map/sources/hinode.py b/sunpy/map/sources/hinode.py
index 8149e8487..89e3eca91 100644
--- a/sunpy/map/sources/hinode.py
+++ b/sunpy/map/sources/hinode.py
@@ -98,6 +98,19 @@ class XRTMap(GenericMap):
             return
         return int(lvl)
 
+    @property
+    def unit(self):
+        # XRT data values are in DN and are converted into DN/s if the data has been normalized.
+        # A tag starting with "XRT_RENORMALIZE" is added to the HISTORY tag in that case.
+        # See Table 1.1 and Section 2.11 of the XRT Analysis Guide.
+        unit = super().unit
+        if not unit:
+            unit = u.ct
+            history = self.meta.get('HISTORY', '')
+            if "xrt_renormalize" in history.lower():
+                unit = u.ct / u.second
+        return unit
+
     @classmethod
     def is_datasource_for(cls, data, header, **kwargs):
         """Determines if header corresponds to an XRT image"""
diff --git a/sunpy/timeseries/timeseriesbase.py b/sunpy/timeseries/timeseriesbase.py
index 4ebb2b35a..84e5c9fbf 100644
--- a/sunpy/timeseries/timeseriesbase.py
+++ b/sunpy/timeseries/timeseriesbase.py
@@ -258,16 +258,16 @@ class GenericTimeSeries:
         return textwrap.dedent(f"""\
                    SunPy TimeSeries
                    ----------------
-                   Observatory:\t\t {obs}
-                   Instrument:\t\t {link}
-                   Channel(s):\t\t {channels}
-                   Start Date:\t\t {dat.index.min().round('s')}
-                   End Date:\t\t {dat.index.max().round('s')}
-                   Center Date:\t\t {center}
-                   Resolution:\t\t {resolution}
-                   Samples per Channel:\t\t {self.shape[0]}
-                   Data Range(s):\t\t {drange}
-                   Units:\t\t {uni}""")
+                   Observatory:\t\t\t{obs}
+                   Instrument:\t\t\t{link}
+                   Channel(s):\t\t\t{channels}
+                   Start Date:\t\t\t{dat.index.min().round('s')}
+                   End Date:\t\t\t{dat.index.max().round('s')}
+                   Center Date:\t\t\t{center}
+                   Resolution:\t\t\t{resolution}
+                   Samples per Channel:\t\t{self.shape[0]}
+                   Data Range(s):\t\t{drange}
+                   Units:\t\t\t{uni}""")
 
     def __str__(self):
         return f"{self._text_summary()}\n{self._data.__repr__()}"
diff --git a/sunpy/visualization/wcsaxes_compat.py b/sunpy/visualization/wcsaxes_compat.py
index 335507b78..3167f1c46 100644
--- a/sunpy/visualization/wcsaxes_compat.py
+++ b/sunpy/visualization/wcsaxes_compat.py
@@ -2,8 +2,10 @@
 This module provides functions to make WCSAxes work in SunPy.
 """
 import matplotlib.pyplot as plt
+from packaging.version import Version
 
 import astropy.units as u
+from astropy import __version__ as astropy_version
 from astropy.visualization import wcsaxes
 
 from sunpy.coordinates import HeliographicCarrington, HeliographicStonyhurst
@@ -172,7 +174,11 @@ def wcsaxes_heliographic_overlay(axes, grid_spacing: u.deg = 10*u.deg, annotate=
     lon = overlay[0]
     lat = overlay[1]
 
-    lon.coord_wrap = 180
+    # TODO: Remove when we depend on astropy 5.3
+    if Version(astropy_version) >= Version("5.3.dev"):
+        lon.coord_wrap = 180 * u.deg
+    else:
+        lon.coord_wrap = 180
     lon.set_major_formatter('dd')
 
     if annotate:
diff --git a/tox.ini b/tox.ini
index 98de3cae2..89afabe2d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,6 @@
 [tox]
 envlist =
-    py{38,39,310,311}{,-oldestdeps,-devdeps,-online,-figure,-hypothesis,-conda}
+    py{39,310,311}{,-oldestdeps,-devdeps,-online,-figure,-conda}
     build_docs{,-gallery}
     codestyle
     base_deps
@@ -19,11 +19,11 @@ allowlist_externals=
 changedir = .tmp/{envname}
 description =
     run tests
+    conda: with the latest conda version of key dependencies
     devdeps: with the latest developer version of key dependencies
+    figure: runs the figure test suite.
     oldestdeps: with the oldest supported version of key dependencies
     online: that require remote data (as well as the offline ones)
-    hypothesis: using hypothesis (both offline and online)
-    figure: runs the figure test suite.
 setenv =
     MPLBACKEND = agg
     SUNPY_SAMPLEDIR = {env:SUNPY_SAMPLEDIR:{toxinidir}/.tox/sample_data/}
@@ -34,40 +34,39 @@ setenv =
     devdeps: PIP_EXTRA_INDEX_URL = https://pypi.anaconda.org/astropy/simple https://pypi.anaconda.org/scipy-wheels-nightly/simple
 deps =
     # devdeps is intended to be used to install the latest developer version of key dependencies.
-    devdeps: numpy>=0.0.dev0
     devdeps: astropy>=0.0.dev0
-    devdeps: matplotlib>=0.0.dev0
     devdeps: git+https://github.com/asdf-format/asdf
-    devdeps: git+https://github.com/sunpy/mpl-animators
-    devdeps: git+https://github.com/Cadair/parfive
     devdeps: git+https://github.com/astropy/reproject
+    devdeps: git+https://github.com/Cadair/parfive
+    devdeps: git+https://github.com/sunpy/mpl-animators
+    devdeps: matplotlib>=0.0.dev0
+    devdeps: numpy>=0.0.dev0
     # Oldest deps we pin against
-    oldestdeps: astropy<4.3.0
-    oldestdeps: numpy<1.18.0
-    oldestdeps: parfive<1.3.0
-    oldestdeps: asdf<2.9.0
     oldestdeps: asdf-astropy<0.1.2
-    oldestdeps: dask[array]<2.1.0
-    oldestdeps: sqlalchemy<1.4.0
-    oldestdeps: matplotlib<3.4.0
-    oldestdeps: pandas<1.1.0
-    oldestdeps: scipy<1.4.0
-    oldestdeps: scikit-image<0.17.0
-    oldestdeps: glymur<0.9.0
+    oldestdeps: asdf<2.9.0
+    oldestdeps: astropy<5.1.0
     oldestdeps: beautifulsoup4<4.9.0
+    oldestdeps: cdflib<0.3.20
+    oldestdeps: dask[array]<2021.5.0
     oldestdeps: drms<0.7
+    oldestdeps: glymur<0.9.0
+    oldestdeps: h5netcdf<0.9
+    oldestdeps: matplotlib<3.6.0
+    oldestdeps: numpy<1.22.0
+    oldestdeps: pandas<1.3.0
+    oldestdeps: parfive<1.3.0
+    oldestdeps: pytest-xdist<2.1
+    oldestdeps: pytest<7.0
     oldestdeps: python-dateutil<2.9.0
+    oldestdeps: scikit-image<0.19.0
+    oldestdeps: scipy<1.8.0
+    oldestdeps: sqlalchemy<2.0.0
     oldestdeps: tqdm<4.33.0
     oldestdeps: zeep<3.5.0
-    oldestdeps: h5netcdf<0.9
-    oldestdeps: cdflib<0.3.20
-    oldestdeps: pytest<7.0
-    oldestdeps: pytest-xdist<2.1
     # These are specific extras we use to run in specific factors
+    online: astroquery
     online: pytest-rerunfailures
     online: pytest-timeout
-    online: astroquery
-    hypothesis: astroquery
     # Figure tests need a tightly controlled environment
     figure-!devdeps: astropy==5.1.0
     figure-!devdeps: matplotlib==3.5.2
@@ -79,9 +78,8 @@ extras =
 commands =
     pip freeze --all --no-input
     oldestdeps: python -c "import astropy.time; astropy.time.update_leap_seconds()"
-    !online-!hypothesis-!figure: {env:PYTEST_COMMAND} {posargs}
-    online: {env:PYTEST_COMMAND} --hypothesis-show-statistics --reruns 2 --reruns-delay 15 --timeout=180 --remote-data=any {posargs}
-    hypothesis: {env:PYTEST_COMMAND} --hypothesis-show-statistics --remote-data=any -m "hypothesis" {posargs}
+    !online-!figure: {env:PYTEST_COMMAND} {posargs}
+    online: {env:PYTEST_COMMAND} --hypothesis-show-statistics --reruns 2 --reruns-delay 15 --timeout=30 --remote-data=any {posargs}
     figure: /bin/bash -c "mkdir -p ./figure_test_images; python -c 'import matplotlib as mpl; print(mpl.ft2font.__file__, mpl.ft2font.__freetype_version__, mpl.ft2font.__freetype_build_type__)' > ./figure_test_images/figure_version_info.txt"
     figure: /bin/bash -c "pip freeze >> ./figure_test_images/figure_version_info.txt"
     figure: /bin/bash -c "cat ./figure_test_images/figure_version_info.txt"
@@ -121,7 +119,7 @@ commands =
     python -c "import sunpy; sunpy.self_test()"
 
 # Requires tox-conda
-[testenv:py{38,39,310}-conda]
+[testenv:py{39,310,311}-conda]
 pypi_filter =
 extras =
 deps =


Test patch:
diff --git a/docs/dev_guide/contents/tests.rst b/docs/dev_guide/contents/tests.rst
index f479d760b..baba936dc 100644
--- a/docs/dev_guide/contents/tests.rst
+++ b/docs/dev_guide/contents/tests.rst
@@ -113,7 +113,7 @@ Remote data
 -----------
 By default, no online tests are selected and so to run the online tests you have to::
 
-    $ tox -e py38-online
+    $ tox -e py311-online
 
 or::
 
@@ -123,7 +123,7 @@ Figure tests
 ------------
 In order to avoid changes in figures due to different package versions, we recommend using tox to run the figure tests::
 
-    $ tox -e py38-figure
+    $ tox -e py311-figure
 
 This will ensure that any figures created are checked using the package versions that were used to create the original figure hashes.
 Running this will create a folder, "figure_test_images", within your work folder ("<local clone location>/figure_test_images"), which is ignored by git.
@@ -299,21 +299,21 @@ If the hashes do not match, the figure has changed, and thus the test is conside
 
 If you are adding a new figure test you will need to generate a new hash library::
 
-    $ tox -e py38-figure -- --mpl-generate-hash-library=sunpy/tests/figure_hashes_mpl_332_ft_261_astropy_42.json
+    $ tox -e py311-figure -- --mpl-generate-hash-library=sunpy/tests/figure_hashes_mpl_332_ft_261_astropy_42.json
 
 The filename changes if the version of astropy or Matplotlib or freetype gets updated.
 So you might need to adjust this command.
 For the development figure tests::
 
-    $ tox -e py38-figure-devdeps -- --mpl-generate-hash-library=sunpy/tests/figure_hashes_mpl_dev_ft_261_astropy_dev.json
+    $ tox -e py311-figure-devdeps -- --mpl-generate-hash-library=sunpy/tests/figure_hashes_mpl_dev_ft_261_astropy_dev.json
 
 This will run the figure test suite and update the hashes stored.
 
 If you want to check what the images look like, you can do::
 
-    $ tox -e py38-figure -- --mpl-generate-path=baseline
+    $ tox -e py311-figure -- --mpl-generate-path=baseline
 
-The images output from the tests will be stored in a folder called ``.tmp/py38-figure/baseline`` or ``baseline`` in the sunpy folder, so you can double check the test works as you expected.
+The images output from the tests will be stored in a folder called ``.tmp/py311-figure/baseline`` or ``baseline`` in the sunpy folder, so you can double check the test works as you expected.
 
 .. _doctests:
 
diff --git a/sunpy/data/data_manager/tests/test_downloader.py b/sunpy/data/data_manager/tests/test_downloader.py
new file mode 100644
index 000000000..c60a715a6
--- /dev/null
+++ b/sunpy/data/data_manager/tests/test_downloader.py
@@ -0,0 +1,28 @@
+from unittest.mock import patch
+
+import parfive
+import pytest
+from packaging.version import Version
+from parfive import Results
+
+from sunpy.data.data_manager.downloader import DownloaderError, ParfiveDownloader
+
+
+def test_ParfiveDownloader_errors():
+    """
+    Test that ParfiveDownloader raises an error when the download fails.
+    """
+
+    downloader = ParfiveDownloader()
+    results = Results()
+
+    # TODO: Remove this when we support parfive 2.0.
+    if Version(parfive.__version__) >= Version("2.0.0"):
+        from parfive.results import Error
+    else:
+        Error = results._error
+    results.errors.append(Error("", "FAKE_URL", ValueError("TEST_ERROR")))
+    with patch('parfive.Downloader.download') as download:
+        download.return_value = results
+        with pytest.raises(DownloaderError, match='TEST_ERROR'):
+            downloader.download('https://www.fakewebsite.com', 'test_file')
diff --git a/sunpy/data/test/__init__.py b/sunpy/data/test/__init__.py
index 568d2fc06..4cb1ecd9b 100644
--- a/sunpy/data/test/__init__.py
+++ b/sunpy/data/test/__init__.py
@@ -13,6 +13,7 @@ import astropy.io.fits
 from astropy.utils.data import get_pkg_data_filename
 
 import sunpy
+import sunpy.io._fits as _fits
 import sunpy.map
 
 __all__ = [
@@ -110,8 +111,8 @@ def get_dummy_map_from_header(filename):
     as specified by the header.
     """
     filepath = get_test_filepath(filename)
-    header = astropy.io.fits.Header.fromtextfile(filepath)
-    data = np.random.rand(header['naxis2'], header['naxis1'])
+    header = _fits.format_comments_and_history(astropy.io.fits.Header.fromtextfile(filepath))
+    data = np.random.rand(header['NAXIS2'], header['NAXIS1'])
     if 'BITPIX' in header:
         data = data.astype(astropy.io.fits.BITPIX2DTYPE[header['BITPIX']])
     # NOTE: by reading straight from the data header pair, we are skipping
diff --git a/sunpy/database/tests/test_database.py b/sunpy/database/tests/test_database.py
index c1585e136..f19ad3899 100644
--- a/sunpy/database/tests/test_database.py
+++ b/sunpy/database/tests/test_database.py
@@ -730,6 +730,7 @@ def test_clear_database(filled_database):
     assert filled_database.session.query(Tag).all() == []
     filled_database.undo()
     assert len(filled_database) == 10
+    assert len(filled_database.session.query(Tag).all()) == 2
     filled_database.redo()
     assert not filled_database
     assert filled_database.session.query(JSONDump).all() == []
diff --git a/sunpy/map/sources/tests/test_xrt_source.py b/sunpy/map/sources/tests/test_xrt_source.py
index d693288da..4d3e9dbd7 100644
--- a/sunpy/map/sources/tests/test_xrt_source.py
+++ b/sunpy/map/sources/tests/test_xrt_source.py
@@ -38,6 +38,11 @@ def test_measurement(xrt_map):
     assert xrt_map.measurement == 'Be thin-Open'
 
 
+def test_unit(xrt_map):
+    """Tests the unit property of the XRTMap object."""
+    assert xrt_map.unit == u.ct / u.second
+
+
 def test_level_number(xrt_map):
     assert xrt_map.processing_level == 1