From 77f57d4e81fb7de3199797517d0c962d9e9250d6 Mon Sep 17 00:00:00 2001 From: Teal Date: Fri, 16 Aug 2024 10:00:40 -0700 Subject: [PATCH] Update API docstrings (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit squashed together 26 commits: * refactor: :wastebasket: Deprecate "add_header_to_table" * docs: ✍️ State deprecation of add_header_to_table in summary * docs: ✍️ Update docstring for descriptor decorator. * docs: ✍️ Update astro_data_tag documentation. * docs: ✍️ Update returns_list documentation. * docs(api): ✍️ Update from_file documentation * docs(api): ✍️ Update open docstring * docs: 👷 Add nox, install help. * docs: ✍️ Unify and expand on docstrings in AstoData * docs: ✍️ Unify and update nddata.py docs * docs: ✍️ Update and unify docstrings for utils. * docs: ✍️ Add examples and clarification to Section * docs: 🧹 Fix docstring lint errors * docs: ✍️ Fix linting errors for fits.py * docs: ✍️ Rename Parameters to Arguments in docstrings * docs: 🧹 Fix docstirng lint errors. * docs: :art: Change Parameters to Arguments in docstring * docs: 🧹 Fix testing docstring lint errors. * ci(lint): :sparkles: Add documentation style rules to linter * docs: 🧹 Fix wcs docstring lint errors. * docs: 🧹 Change Parameter to Arguments in docstrings * docs: 🧹 Fix provenance docstring lint errors. * docs: 🧹 Fix docstring lint errors. * docs: ✍️ Add information to package docstring. * docs: 🧹 Change Parameters to Arguments where lingering. * docs: 🚨 Fix line length problems. --- astrodata/__init__.py | 87 +++++- astrodata/adfactory.py | 54 ++-- astrodata/core.py | 588 ++++++++++++++++++++++++++++++--------- astrodata/fits.py | 133 ++++++--- astrodata/nddata.py | 142 +++++++--- astrodata/provenance.py | 42 +-- astrodata/testing.py | 234 +++++++++++----- astrodata/utils.py | 304 +++++++++++++++++--- astrodata/wcs.py | 160 +++++++---- docs/developer/index.rst | 21 +- pyproject.toml | 2 +- 11 files changed, 1365 insertions(+), 402 deletions(-) diff --git a/astrodata/__init__.py b/astrodata/__init__.py index 02ad31e9..86b13486 100644 --- a/astrodata/__init__.py +++ b/astrodata/__init__.py @@ -1,8 +1,19 @@ -"""This package adds an abstraction layer to astronomical data by parsing the +"""Entry point for the |astrodata| package. + +This package adds an abstraction layer to astronomical data by parsing the information contained in the headers as attributes. To do so, one must subclass :class:`astrodata.AstroData` and add parse methods accordingly to the :class:`~astrodata.TagSet` received. +For more information, you can build the documentation locally by running + +.. code-block:: bash + + nox -s docs + +and opening the file ``_build/html/index.html`` in a browser. Alternatively, +you can check the online documentation at +`the |astrodata| pages site `_. """ import importlib.metadata @@ -64,8 +75,44 @@ def version(): def from_file(*args, **kwargs): """Return an |AstroData| object from a file. + Arguments + --------- + source: str, os.PathLike or HDUList + The path to the file or the HDUList object. If a string is passed, it + will be treated as a path to a file. + + Returns + ------- + AstroData + An instantiated object. It will be a subclass of |AstroData|. + + Notes + ----- For implementation details, see - :meth:`~astrodata.AstroDataFactory.get_astro_data`. + :py:meth:`~astrodata.AstroDataFactory.get_astro_data`. + + This function is a wrapper around the factory method + :py:meth:`~astrodata.AstroDataFactory.get_astro_data`, and uses the + default factory instance at :py:attr:`~astrodata.factory`. If you want to + override the default factory, you can create a new instance of + :py:class:`~astrodata.AstroDataFactory` and use its methods directly, or + assign it to :py:attr:`~astrodata.factory`. + + Example + ------- + + >>> from astrodata import from_file + >>> ad = from_file("path/to/file.fits") + + Alternatively, you can use an :py:class:`~astropy.io.fits.HDUList` object: + + >>> from astropy.io import fits + >>> hdulist = fits.open("path/to/file.fits") + >>> ad = from_file(hdulist) + + Which can be useful for inspecting input before creating the |AstroData| + object. This will not use the normal |AstroData| lazy-loading mechanism, + however. """ return factory.get_astro_data(*args, **kwargs) @@ -73,8 +120,30 @@ def from_file(*args, **kwargs): def create(*args, **kwargs): """Return an |AstroData| object from data. - For implementation details, see - :meth:`~astrodata.AstroDataFactory.create_from_scratch` + Arguments + --------- + phu : `fits.PrimaryHDU` or `fits.Header` or `dict` or `list` + FITS primary HDU or header, or something that can be used to create + a fits.Header (a dict, a list of "cards"). + + extensions : list of HDUs + List of HDU objects. + + Returns + ------- + `astrodata.AstroData` + An AstroData instance. + + Raises + ------ + ValueError + If ``phu`` is not a valid object. + + Example + ------- + + >>> from astrodata import create + >>> ad = create(phu=fits.PrimaryHDU(), extensions=[fits.ImageHDU()]) """ return factory.create_from_scratch(*args, **kwargs) @@ -82,10 +151,14 @@ def create(*args, **kwargs): # Without raising a warning or error. @deprecated( "Use 'astrodata.from_file'. astrodata.open is deprecated, " - "and will be removed in a future version." + "and will be removed in a future version. They take the " + "same arguments and return the same object.", ) def open(*args, **kwargs): # pylint: disable=redefined-builtin - """Return an |AstroData| object from a file (deprecated, use - :func:`~astrodata.from_file`). + """Return an |AstroData| object from a file. + + .. warning:: + This function is deprecated and will be removed in a future version. + Use :py:func:`~astrodata.from_file` instead. """ return from_file(*args, **kwargs) diff --git a/astrodata/adfactory.py b/astrodata/adfactory.py index 18431b85..ffffe790 100644 --- a/astrodata/adfactory.py +++ b/astrodata/adfactory.py @@ -38,13 +38,15 @@ def registry(self): "astrodata.factory.AstroDataFactory._open_file" ) @contextmanager - def _openFile(source): # pylint: disable=invalid-name + def _openFile(source): # noqa return AstroDataFactory._open_file(source) @staticmethod @contextmanager def _open_file(source): - """Internal static method that takes a ``source``, assuming that it is + """Open a file and return an appropriate object. + + Internal static method that takes a ``source``, assuming that it is a string pointing to a file to be opened. If this is the case, it will try to open the file and return an @@ -81,7 +83,7 @@ def _open_file(source): except KeyboardInterrupt: raise - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa LOGGER.error( "Failed to open %s with %s, got error: %s", source, @@ -109,15 +111,18 @@ def _open_file(source): "Renamed to add_class, please use that method instead: " "astrodata.factory.AstroDataFactory.add_class" ) - def addClass(self, cls): # pylint: disable=invalid-name - """Add a new class to the AstroDataFactory registry. It will be used - when instantiating an AstroData class for a FITS file. + def addClass(self, cls): # noqa + """Add a new class to the |AstroDataFactory| registry. + + It will be used when instantiating an AstroData class for a FITS file. """ self.add_class(cls) def add_class(self, cls): - """Add a new class to the AstroDataFactory registry. It will be used - when instantiating an AstroData class for a FITS file. + """Add a new class to the |AstroDataFactory|'s registry. + + Add a new class to the |AstroDataFactory| registry. It will be used + when instantiating an |AstroData| class for a FITS file. """ if not hasattr(cls, "_matches_data"): raise AttributeError( @@ -137,12 +142,17 @@ def remove_class(self, cls: type | str): "Renamed to get_astro_data, please use that method instead: " "astrodata.factory.AstroDataFactory.get_astro_data" ) - def getAstroData(self, source): # pylint: disable=invalid-name - """Deprecated, see |get_astro_data|.""" + def getAstroData(self, source): # noqa + """Return an |AstroData| instance from a file or HDUList. + + Deprecated, see |get_astro_data|. + """ self.get_astro_data(source) def get_astro_data(self, source): - """Takes either a string (with the path to a file) or an HDUList as + """Return an |AstroData| instance from a file or HDUList. + + Takes either a string (with the path to a file) or an HDUList as input, and tries to return an AstroData instance. It will raise exceptions if the file is not found, or if there is no @@ -151,10 +161,15 @@ def get_astro_data(self, source): Returns an instantiated object, or raises AstroDataError if it was not possible to find a match - Parameters - ---------- + Arguments + --------- source : `str` or `pathlib.Path` or `fits.HDUList` The file path or HDUList to read. + + Returns + ------- + `astrodata.AstroData` + An AstroData instance. """ candidates = [] with self._open_file(source) as opened: @@ -204,15 +219,18 @@ def createFromScratch( self, phu, extensions=None, - ): # pylint: disable=invalid-name - """Deprecated, see |create_from_scratch|.""" + ): # noqa + """Create an AstroData object from a collection of objects. + + Deprecated, see |create_from_scratch|. + """ self.create_from_scratch(phu=phu, extensions=extensions) def create_from_scratch(self, phu, extensions=None): - """Creates an AstroData object from a collection of objects. + """Create an AstroData object from a collection of objects. - Parameters - ---------- + Arguments + --------- phu : `fits.PrimaryHDU` or `fits.Header` or `dict` or `list` FITS primary HDU or header, or something that can be used to create a fits.Header (a dict, a list of "cards"). diff --git a/astrodata/core.py b/astrodata/core.py index 1741f1e8..37cae4de 100644 --- a/astrodata/core.py +++ b/astrodata/core.py @@ -1,7 +1,4 @@ -"""This is the core module of the AstroData package. It provides the -`AstroData` class, which is the main interface to manipulate astronomical -data sets. -""" +"""Core module of the AstroData package, containing the |AstroData| class.""" import inspect import logging @@ -42,8 +39,8 @@ _ARIT_DOC = """ Performs {name} by evaluating ``self {op} operand``. - Parameters - ---------- + Arguments + --------- oper : number or object The operand to perform the operation ``self {op} operand``. @@ -54,11 +51,12 @@ class AstroData: - """Base class for the AstroData software package. It provides an interface - to manipulate astronomical data sets. + """Base class for the AstroData software package. + + It provides an interface to manipulate astronomical data sets. - Parameters - ---------- + Arguments + --------- nddata : `astrodata.NDAstroData` or list of `astrodata.NDAstroData` List of NDAstroData objects. @@ -73,8 +71,53 @@ class AstroData: object will access to. This is used when slicing an object, then the sliced AstroData will have the ``.nddata`` list from its parent and access the sliced NDAstroData through this list of indices. + + + .. warning:: + + This class is not meant to be instantiated directly. Instead, use the + factory method :py:func:`astrodata.from_file` to create an instance of + this class using a file. Alternatively, use the + :py:meth:`astrodata.create` function to create a new instance from + scratch. + + The documentation here is meant for developers who want to extend the + functionality of this class. + + Registering an |AstroData| subclass + ----------------------------------- + + To create a new subclass of |AstroData|, you need to register it with the + factory. This is done by creating a new subclass and using + :py:meth:`AstroDataFactory.add_class`: + + .. code-block:: python + + from astrodata import AstroData, factory + + class MyAstroData(AstroData): + @classmethod + def _matches_data(cls): + '''Trivial match for now.''' + return True + + factory.add_class(MyAstroData) + + Once the class is registered, the factory will be able to create instances + of it when reading files. It will also be able to create instances of it + when using the :py:meth:`astrodata.create` function. It uses the special, + required method :py:meth:`~astrodata.AstroData._matches_data` to determine + if the class matches the data in the file. If there are multiple matches, + the factory will try to find one that has the most specific match and is + a subclass of the other candidates. + + If There is no match or multiple matches, the factory will raise an + exception. See :py:meth:`AstroDataFactory.get_astro_data` for more + information. """ + # TODO(teald): Docstring for AstroData has a bad lin to AstroDataFactory + # Derived classes may provide their own __keyword_dict. Being a private # variable, each class will preserve its own, and there's no risk of # overriding the whole thing @@ -139,10 +182,10 @@ def __init__( self._path = None def __deepcopy__(self, memo): - """Returns a new instance of this class. + """Return a new instance of this class. - Parameters - ---------- + Arguments + --------- memo : dict See the documentation on `deepcopy` for an explanation on how this works. @@ -157,10 +200,10 @@ def __deepcopy__(self, memo): return obj def _keyword_for(self, name): - """Returns the FITS keyword name associated to ``name``. + """Return the FITS keyword name associated to ``name``. - Parameters - ---------- + Arguments + --------- name : str The common "key" name for which we want to know the associated FITS keyword. @@ -232,10 +275,10 @@ def _process_tags(self): @classmethod def matches_data(cls, source) -> bool: - """Returns True if the class can handle the data in the source. + """Return True if the class can handle the data in the source. - Parameters - ---------- + Arguments + --------- source : list of `astropy.io.fits.HDUList` The FITS file to be read. @@ -264,13 +307,39 @@ def matches_data(cls, source) -> bool: @staticmethod def _matches_data(source): + """Return True if the source matches conditions for this class. + + .. warning:: + + The default implementation for the base |AstroData| class is a + trivial match (always return True). + + Example + ------- + + .. code-block:: python + + class MyAstroData(AstroData): + @staticmethod + def _matches_data(source): + if source[0].header["INSTRUME"] == "MY_INST": + return True + + return False + + """ # This one is trivial. Will be more specific for subclasses. logging.debug("Using default _matches_data with %s", source) return True @property def path(self): - """Return the file path.""" + """Return the file path, if generated from or saved to a file. + + If this is set to a file path, the filename will be updated + automatically. The original filename will be stored in the + `orig_filename` property. + """ return self._path @path.setter @@ -281,7 +350,13 @@ def path(self, value): @property def filename(self): - """Return the file name.""" + """Return the filename. This is the basename of the path, or None. + + If the filename is set, the path will be updated automatically. + + If the filename is set to an absolute path, a ValueError will be + raised. + """ if self.path is not None: return os.path.basename(self.path) @@ -319,9 +394,18 @@ def phu(self, phu): @property def hdr(self): - """Return all headers, as a `astrodata.fits.FitsHeaderCollection`.""" + """Return all headers, as a |fitsheaderc|. + + If this is a single-slice object, the header will be returned as a + single :py:class:`~astropy.io.fits.Header` object. Otherwise, it will + be returned as a |fitsheaderc| object. + + .. |fitsheaderc| replace:: :class:`astrodata.fits.FitsHeaderCollection` + """ if not self.nddata: return None + + # TODO(teald): Inconsistent type with is_single special case. headers = [nd.meta["header"] for nd in self._nddata] return headers[0] if self.is_single else FitsHeaderCollection(headers) @@ -331,21 +415,36 @@ def hdr(self): "will be removed in the future. Use '.hdr' instead." ) def header(self): - """Deprecated header access. Use ``.hdr`` instead.""" + """Return the headers for the PHU and each extension. + + .. warning:: + + This property is deprecated and will be removed in the future. + """ return [self.phu] + [ndd.meta["header"] for ndd in self._nddata] @property def tags(self): - """A set of strings that represent the tags defining this instance.""" + """Return a set of strings that represent the class' tags. + + It collects the tags from the methods decorated with the + :py:func:`~astrodata.astro_data_tag` decorator. + """ return self._process_tags() @property def descriptors(self): - """Returns a sequence of names for the methods that have been - decorated as descriptors. + """Return a sequence of names for descriptor methods. + + These are the methods that are decorated with the + :py:func:`~astrodata.astro_data_descriptor` decorator. + + This checks for the existence of the descriptor_method attribute in + the class members, so anything with that attribute (regardless of the + attribute's value) will be interpreted as a descriptor. Returns - -------- + ------- tuple of str """ members = inspect.getmembers( @@ -355,8 +454,21 @@ def descriptors(self): @property def id(self): - """Returns the extension identifier (1-based extension number) - for sliced objects. + """Return the extension identifier. + + The identifier is a 1-based extension number for objects with single + slices. + + For objects that are not single slices, a ValueError will be raised. + + Notes + ----- + To get all the id values, use the `indices` property and add 1 to each + value: + + .. code-block:: python + + ids = [i + 1 for i in ad.indices] """ if self.is_single: return self._indices[0] + 1 @@ -368,13 +480,17 @@ def id(self): @property def indices(self): - """Returns the extensions indices for sliced objects.""" + """Return the extensions indices for sliced objects.""" return self._indices if self._indices else list(range(len(self))) @property def is_sliced(self): - """If this data provider instance represents the whole dataset, return - False. If it represents a slice out of the whole, return True. + """Return True if this object is a slice of a dataset. + + If this data provider instance represents a whole dataset, return + False. If it represents a slice out of a whole, return True. + + It does this by checking if the ``_indices`` private attribute is set. """ return self._indices is not None @@ -387,8 +503,9 @@ def is_settable(self, attr): @property def _nddata(self): - """Return the list of `astrodata.NDAstroData` objects. Contrary to - ``self.nddata`` this always returns a list. + """Return the list of `astrodata.NDAstroData` objects. + + Unlike ``self.nddata``, this always returns a list. """ if self._indices is not None: return [self._all_nddatas[i] for i in self._indices] @@ -418,16 +535,12 @@ def table(self): @property def tables(self): - """Return the names of the `astropy.table.Table` objects associated to - the top-level object. - """ + """Return the names of the associated `astropy.table.Table` objects.""" return set(self._tables) @property def ext_tables(self): - """Return the names of the `astropy.table.Table` objects associated to - an extension. - """ + """Return names of the extensions' `astropy.table.Table` objects.""" if not self.is_single: raise AttributeError("this is only available for extensions") @@ -440,16 +553,27 @@ def ext_tables(self): @property @returns_list def shape(self): - """Return the shape of the data array for each extension as a list of - shapes. + """Return the shape of the data array for each extension. + + Returns + ------- + list of tuple """ return [nd.shape for nd in self._nddata] @property @returns_list def data(self): - """A list of the arrays (or single array, if this is a single slice) - corresponding to the science data attached to each extension. + """Create a list of arrays corresponding to data in extensions. + + This may be a single array, if the data is a single slice. + + If set, it expects the value to be something with a shape, such as a + numpy array. + + Notes + ----- + The result will always be a list, even if it's a single slice. """ return [nd.data for nd in self._nddata] @@ -469,16 +593,18 @@ def data(self, value): @property @returns_list def uncertainty(self): - """A list of the uncertainty objects (or a single object, if this is - a single slice) attached to the science data, for each extension. + """Create a list of the uncertainty objects for each extension. The objects are instances of AstroPy's `astropy.nddata.NDUncertainty`, or `None` where no information is available. - See also + See Also -------- variance : The actual array supporting the uncertainty object. + Notes + ----- + The result will always be a list, even if it's a single slice. """ return [nd.uncertainty for nd in self._nddata] @@ -490,10 +616,16 @@ def uncertainty(self, value): @property @returns_list def mask(self): - """A list of the mask arrays (or a single array, if this is a single - slice) attached to the science data, for each extension. + """Return a list of the mask arrays for each extension. + + Returns a list of the mask arrays (or a single array, if this is a + single slice) attached to the science data, for each extension. For objects that miss a mask, `None` will be provided instead. + + Notes + ----- + The result will always be a list, even if it's a single slice. """ return [nd.mask for nd in self._nddata] @@ -505,16 +637,21 @@ def mask(self, value): @property @returns_list def variance(self): - """A list of the variance arrays (or a single array, if this is a + """Return a list of variance arrays for each extension. + + A list of the variance arrays (or a single array, if this is a single slice) attached to the science data, for each extension. For objects that miss uncertainty information, `None` will be provided instead. - See also - --------- + See Also + -------- uncertainty : The uncertainty objects used under the hood. + Notes + ----- + The result will always be a list, even if it's a single slice. """ return [nd.variance for nd in self._nddata] @@ -529,7 +666,12 @@ def variance(self, value): @property def wcs(self): - """Returns the list of WCS objects for each extension.""" + """Return the list of WCS objects for each extension. + + Warning + ------- + This is what is returned by the ``astropy.nddata.NDData.wcs`` property. + """ if self.is_single: return self.nddata.wcs @@ -544,6 +686,14 @@ def wcs(self, value): self.nddata.wcs = value def __iter__(self): + """Iterate over the extensions.. + + This generator yields the `AstroData` object for each extension. + + Notes + ----- + This will yield the object, once, if it's a single slice. + """ if self.is_single: yield self else: @@ -551,16 +701,18 @@ def __iter__(self): yield self[n] def __getitem__(self, idx): - """Returns a sliced view of the instance. It supports the standard - Python indexing syntax. + """Get the item at the specified index. - Parameters - ---------- + Returns a sliced view of the instance. It supports the standard Python + indexing syntax. + + Arguments + --------- slice : int, `slice` An integer or an instance of a Python standard `slice` object Raises - ------- + ------ TypeError If trying to slice an object when it doesn't make sense (e.g. slicing a single slice) @@ -595,38 +747,47 @@ def __getitem__(self, idx): return obj def __delitem__(self, idx): - """Called to implement deletion of ``self[idx]``. Supports standard - Python syntax (including negative indices). + """Delete an item using ``del self[idx]``. + + Supports standard Python syntax (including negative indices). - Parameters - ---------- + Arguments + --------- idx : int This index represents the order of the element that you want to remove. Raises - ------- + ------ IndexError If `idx` is out of range. """ if self.is_sliced: raise TypeError("Can't remove items from a sliced object") + del self._all_nddatas[idx] def __getattr__(self, attribute): - """Called when an attribute lookup has not found the attribute in the + """Get the attribute with the specified name. + + Called when an attribute lookup has not found the attribute in the usual places (not an instance attribute, and not in the class tree for ``self``). - Parameters - ---------- + Arguments + --------- attribute : str The attribute's name. Raises - ------- + ------ AttributeError If the attribute could not be found/computed. + + Notes + ----- + For more information, see the documentation on the `__getattr__` method + in the Python documentation. """ # If we're working with single slices, let's look some things up # in the ND object @@ -643,11 +804,13 @@ def __getattr__(self, attribute): ) def __setattr__(self, attribute, value): - """Called when an attribute assignment is attempted, instead of the + """Set the attribute with the specified name to a given value. + + Called when an attribute assignment is attempted, instead of the normal mechanism. - Parameters - ---------- + Arguments + --------- attribute : str The attribute's name. @@ -692,7 +855,7 @@ def _my_attribute(attr): super().__setattr__(attribute, value) def __delattr__(self, attribute): - """Implements attribute removal.""" + """Delete an attribute.""" if not attribute.isupper(): super().__delattr__(attribute) return @@ -718,16 +881,21 @@ def __delattr__(self, attribute): ) def __contains__(self, attribute): - """Implements the ability to use the ``in`` operator with an - `AstroData` object. + """Return True if the attribute is exposed in this instance. + + Implements the ability to use the ``in`` operator with an `AstroData` + object. + + This looks for the attribute in + :py:meth:``~astrodata.AstroData.exposed``. - Parameters - ---------- + Arguments + -------- attribute : str An attribute name. Returns - -------- + ------- bool """ return attribute in self.exposed @@ -744,13 +912,15 @@ def __len__(self): @property def exposed(self): - """A collection of strings with the names of objects that can be + """Return a set of attribute names that can be accessed directly. + + A collection of strings with the names of objects that can be accessed directly by name as attributes of this instance, and that are not part of its standard interface (i.e. data objects that have been added dynamically). Examples - --------- + -------- >>> ad[0].exposed # doctest: +SKIP set(['OBJMASK', 'OBJCAT']) @@ -762,6 +932,16 @@ def exposed(self): return exposed def _pixel_info(self): + """Get the pixel information for each extension. + + This is a generator that yields a dictionary with the information + about a single extension, until all extensions have been yielded. + + Yields + ------ + dict + A dictionary with the pixel information for an extension. + """ for idx, nd in enumerate(self._nddata): other_objects = [] uncer = nd.uncertainty @@ -826,7 +1006,7 @@ def _pixel_info(self): yield out_dict def info(self): - """Prints out information about the contents of this instance.""" + """Print out information about the contents of this instance.""" unknown_file = "Unknown" print(f"Filename: {self.path if self.path else unknown_file}") @@ -885,6 +1065,7 @@ def info(self): ) def _oper(self, operator, operand): + """Perform an operation on the data with the specified operand.""" ind = self.indices ndd = self._all_nddatas if isinstance(operand, AstroData): @@ -916,52 +1097,53 @@ def _oper(self, operator, operand): ndd[ind[n]] = operator(ndd[ind[n]], operand) def _standard_nddata_op(self, fn, operand): + """Operate on the data with the specified function and operand.""" return self._oper( partial(fn, handle_mask=np.bitwise_or, handle_meta="first_found"), operand, ) @format_doc(_ARIT_DOC, name="addition", op="+") - def __add__(self, oper): + def __add__(self, oper): # noqa copy = deepcopy(self) copy += oper return copy @format_doc(_ARIT_DOC, name="subtraction", op="-") - def __sub__(self, oper): + def __sub__(self, oper): # noqa copy = deepcopy(self) copy -= oper return copy @format_doc(_ARIT_DOC, name="multiplication", op="*") - def __mul__(self, oper): + def __mul__(self, oper): # noqa copy = deepcopy(self) copy *= oper return copy @format_doc(_ARIT_DOC, name="division", op="/") - def __truediv__(self, oper): + def __truediv__(self, oper): # noqa copy = deepcopy(self) copy /= oper return copy @format_doc(_ARIT_DOC, name="inplace addition", op="+=") - def __iadd__(self, oper): + def __iadd__(self, oper): # noqa self._standard_nddata_op(NDAstroData.add, oper) return self @format_doc(_ARIT_DOC, name="inplace subtraction", op="-=") - def __isub__(self, oper): + def __isub__(self, oper): # noqa self._standard_nddata_op(NDAstroData.subtract, oper) return self @format_doc(_ARIT_DOC, name="inplace multiplication", op="*=") - def __imul__(self, oper): + def __imul__(self, oper): # noqa self._standard_nddata_op(NDAstroData.multiply, oper) return self @format_doc(_ARIT_DOC, name="inplace division", op="/=") - def __itruediv__(self, oper): + def __itruediv__(self, oper): # noqa self._standard_nddata_op(NDAstroData.divide, oper) return self @@ -974,14 +1156,17 @@ def __itruediv__(self, oper): __rmul__ = __mul__ def __rsub__(self, oper): + """Subtract with the operand on the right, using a copy.""" copy = (deepcopy(self) - oper) * -1 return copy def _rdiv(self, ndd, operand): + """Divide the data by the NDData object.""" # Divide method works with the operand first return NDAstroData.divide(operand, ndd) def __rtruediv__(self, oper): + """Divide (//) with the operand on the right, using a copy.""" obj = deepcopy(self) obj._oper(obj._rdiv, oper) return obj @@ -989,6 +1174,27 @@ def __rtruediv__(self, oper): def _process_pixel_plane( self, pixim, name=None, top_level=False, custom_header=None ): + """Process a pixel plane and return an NDData object. + + Arguments + --------- + pixim : `astropy.io.fits.ImageHDU` or `numpy.ndarray` + The pixel plane to be processed. + + name : str + The name of the extension. + + top_level : bool + Whether this is a top-level extension. + + custom_header : `astropy.io.fits.Header` + A custom header to be used. + + Returns + ------- + `astrodata.NDAstroData` + The processed pixel plane. + """ # Assume that we get an ImageHDU or something that can be # turned into one if isinstance(pixim, fits.ImageHDU): @@ -1013,6 +1219,27 @@ def _process_pixel_plane( return nd def _append_array(self, data, name=None, header=None, add_to=None): + """Append an array to the AstroData object. + + Arguments + --------- + data : `numpy.ndarray` + The data to be appended. + + name : str + The name of the extension. + + header : `astropy.io.fits.Header` + The header to be used. + + add_to : `NDAstroData` + The NDData object to append to. + + Returns + ------- + `NDAstroData` + The NDData object that was appended. + """ if name in {"DQ", "VAR"}: raise ValueError( f"'{name}' need to be associated to a " @@ -1039,6 +1266,27 @@ def _append_array(self, data, name=None, header=None, add_to=None): return ret def _append_imagehdu(self, hdu, name, header, add_to): + """Append an ImageHDU to the AstroData object. + + Arguments + --------- + hdu : `astropy.io.fits.ImageHDU` + The ImageHDU to be appended. + + name : str + The name of the extension. + + header : `astropy.io.fits.Header` + The header to be used. + + add_to : `NDAstroData` + The NDData object to append to. + + Returns + ------- + `NDAstroData` + The NDData object that was appended. + """ if name in {"DQ", "VAR"} or add_to is not None: return self._append_array(hdu.data, name=name, add_to=add_to) @@ -1048,6 +1296,27 @@ def _append_imagehdu(self, hdu, name, header, add_to): return self._append_nddata(nd, name, add_to=None) def _append_raw_nddata(self, raw_nddata, name, header, add_to): + """Append an NDData object to the AstroData object. + + Arguments + --------- + raw_nddata : `astropy.nddata.NDData` + The NDData object to be appended. + + name : str + The name of the extension. + + header : `astropy.io.fits.Header` + The header to be used. + + add_to : `NDAstroData` + The NDData object to append to. + + Returns + ------- + `NDAstroData` + The NDData object that was appended. + """ logging.debug("Appending data to nddata: %s", name) # We want to make sure that the instance we add is whatever we specify @@ -1063,10 +1332,32 @@ def _append_raw_nddata(self, raw_nddata, name, header, add_to): return self._append_nddata(processed_nddata, name=name, add_to=add_to) def _append_nddata(self, new_nddata, name, add_to): - # NOTE: This method is only used by others that have constructed NDData - # according to our internal format. We don't accept new headers at this - # point, and that's why it's missing from the signature. 'name' is - # ignored. It's there just to comply with the _append_XXX signature. + """Append an NDData object to the AstroData object. + + .. warning:: + + This method is only used by others that have constructed NDData + according to our internal format. We don't accept new headers at + this point, and that's why it's missing from the signature. 'name' + is ignored. It's there just to comply with the _append_XXX + signature. + + Arguments + --------- + new_nddata : `NDAstroData` + The NDData object to be appended. + + name : str + The name of the extension. + + add_to : `NDAstroData` + The NDData object to append to. + + Returns + ------- + `NDAstroData` + The NDData object that was appended. + """ if add_to is not None: raise TypeError( "You can only append NDData derived instances " @@ -1090,6 +1381,27 @@ def _append_nddata(self, new_nddata, name, add_to): return new_nddata def _append_table(self, new_table, name, header, add_to): + """Append a Table object to the AstroData object. + + Arguments + --------- + new_table : `astropy.table.Table` + The Table object to be appended. + + name : str + The name of the extension. + + header : `astropy.io.fits.Header` + The header to be used. + + add_to : `NDAstroData` + The NDData object to append to. + + Returns + ------- + `NDAstroData` + The NDData object that was appended. + """ tb = _process_table(new_table, name, header) hname = tb.meta["header"].get("EXTNAME") @@ -1130,6 +1442,27 @@ def find_next_num(tables): return tb def _append_astrodata(self, ad, name, header, add_to): + """Append an AstroData object to the AstroData object. + + Arguments + --------- + ad : `AstroData` + The AstroData object to be appended. + + name : str + The name of the extension. + + header : `astropy.io.fits.Header` + The header to be used. + + add_to : `NDAstroData` + The NDData object to append to. + + Returns + ------- + `NDAstroData` + The NDData object that was appended. + """ logging.debug("Appending astrodata object: %s", name) if not ad.is_single: @@ -1150,7 +1483,8 @@ def _append_astrodata(self, ad, name, header, add_to): return self._append_nddata(new_nddata, name=None, add_to=None) def _append(self, ext, name=None, header=None, add_to=None): - """ + """Append an extension to the AstroData object. + Internal method to dispatch to the type specific methods. This is called either by ``.append`` to append on top-level objects only or by ``__setattr__``. In the second case ``name`` cannot be None, so @@ -1171,17 +1505,17 @@ def _append(self, ext, name=None, header=None, add_to=None): return self._append_array(ext, name=name, header=header, add_to=add_to) def append(self, ext, name=None, header=None): - """ - Adds a new top-level extension. + """Add a new top-level extension. - Parameters - ---------- + Arguments + --------- ext : array, `astropy.nddata.NDData`, `astropy.table.Table`, other The contents for the new extension. The exact accepted types depend on the class implementing this interface. Implementations specific to certain data formats may accept specialized types (eg. a FITS provider will accept an `astropy.io.fits.ImageHDU` and extract the array out of it). + name : str, optional A name that may be used to access the new object, as an attribute of the provider. The name is typically ignored for top-level @@ -1193,12 +1527,12 @@ def append(self, ext, name=None, header=None): character cannot be a number ("[A-Z][A-Z0-9]*"). Returns - -------- + ------- The same object, or a new one, if it was necessary to convert it to a more suitable format for internal use. Raises - ------- + ------ TypeError If adding the object in an invalid situation (eg. ``name`` is `None` when adding to a single slice). @@ -1243,13 +1577,13 @@ def read(cls, source, extname_parser=None): load = read # for backward compatibility def write(self, filename=None, overwrite=False): - """ - Write the object to disk. + """Write the object to a file. - Parameters - ---------- + Arguments + --------- filename : str, optional If the filename is not given, ``self.path`` is used. + overwrite : bool If True, overwrites existing file. @@ -1262,7 +1596,8 @@ def write(self, filename=None, overwrite=False): write_fits(self, filename, overwrite=overwrite) def operate(self, operator, *args, **kwargs): - """ + """Apply a function to the data in each extension. + Applies a function to the main data array on each extension, replacing the data with the result. The data will be passed as the first argument to the function. @@ -1281,16 +1616,17 @@ def operate(self, operator, *args, **kwargs): with the additional advantage that it will work on single slices, too. - Parameters - ---------- + Arguments + --------- operator : callable A function that takes an array (and, maybe, other arguments) and returns an array. + args, kwargs : optional Additional arguments to be passed to the ``operator``. Examples - --------- + -------- >>> import numpy as np >>> ad.operate(np.squeeze) # doctest: +SKIP @@ -1304,28 +1640,33 @@ def operate(self, operator, *args, **kwargs): ext.variance = operator(ext.variance, *args, **kwargs) def reset(self, data, mask=NO_DEFAULT, variance=NO_DEFAULT, check=True): - """ + """Reset the data, and optionally mask and variance of an extension. + Sets the ``.data``, and optionally ``.mask`` and ``.variance`` attributes of a single-extension AstroData slice. This function will optionally check whether these attributes have the same shape. - Parameters - ---------- + Arguments + --------- data : ndarray The array to assign to the ``.data`` attribute ("SCI"). + mask : ndarray, optional The array to assign to the ``.mask`` attribute ("DQ"). + variance: ndarray, optional The array to assign to the ``.variance`` attribute ("VAR"). + check: bool If set, then the function will check that the mask and variance arrays have the same shape as the data array. Raises - ------- + ------ TypeError if an attempt is made to set the .mask or .variance attributes with something other than an array + ValueError if the .mask or .variance attributes don't have the same shape as .data, OR if this is called on an AD instance that isn't a single @@ -1399,8 +1740,8 @@ def update_filename(self, prefix=None, suffix=None, strip=False): Note that, if ``strip=True``, a prefix or suffix will only be stripped if '' is specified. - Parameters - ---------- + Arguments + --------- prefix: str, optional New prefix (None => leave alone) @@ -1471,12 +1812,12 @@ def update_filename(self, prefix=None, suffix=None, strip=False): def _crop_nd(self, nd, x1, y1, x2, y2): """Crop the input nd array and its associated attributes. - Args: - nd: The input nd array. - x1: The starting x-coordinate of the crop region. - y1: The starting y-coordinate of the crop region. - x2: The ending x-coordinate of the crop region. - y2: The ending y-coordinate of the crop region. + Arguments + --------- + nd: `NDAstroData` + The input nd array. + x1, y1, x2, y2: int + The minimum (1) and maximum (2) indices for the x and y axis. """ y_start, y_end = y1, y2 + 1 x_start, x_end = x1, x2 + 1 @@ -1492,11 +1833,10 @@ def _crop_nd(self, nd, x1, y1, x2, y2): def crop(self, x1, y1, x2, y2): """Crop the NDData objects given indices. - Parameters - ---------- + Arguments + --------- x1, y1, x2, y2 : int Minimum and maximum indices for the x and y axis. - """ for nd in self._nddata: orig_shape = nd.data.shape @@ -1516,15 +1856,15 @@ def crop(self, x1, y1, x2, y2): @astro_data_descriptor def instrument(self): - """Returns the name of the instrument making the observation.""" + """Return the name of the instrument making the observation.""" return self.phu.get(self._keyword_for("instrument")) @astro_data_descriptor def object(self): - """Returns the name of the object being observed.""" + """Return the name of the object being observed.""" return self.phu.get(self._keyword_for("object")) @astro_data_descriptor def telescope(self): - """Returns the name of the telescope.""" + """Return the name of the telescope.""" return self.phu.get(self._keyword_for("telescope")) diff --git a/astrodata/fits.py b/astrodata/fits.py index aa61d6ee..d07cd12b 100644 --- a/astrodata/fits.py +++ b/astrodata/fits.py @@ -57,8 +57,8 @@ class FitsHeaderCollection: It exposes a number of methods (``set``, ``get``, etc.) that operate over all the headers at the same time. It can also be iterated. - Parameters - ---------- + Arguments + --------- headers : list of `astropy.io.fits.Header` List of Header objects. """ @@ -67,23 +67,40 @@ def __init__(self, headers): self._headers = list(headers) def _insert(self, idx, header): + """Insert a header at a given index. + + Arguments + --------- + idx : int + The index where to insert the header. + + header : `astropy.io.fits.Header` + The header to insert. + + Returns + ------- + None + """ self._headers.insert(idx, header) def __iter__(self): + """Iterate over the headers.""" yield from self._headers def __setitem__(self, key, value): + """Set keyword value in all the headers.""" if isinstance(value, tuple): self.set(key, value=value[0], comment=value[1]) else: self.set(key, value=value) def set(self, key, value=None, comment=None): - """Set a keyword in all the headers.""" + """Set a keyword value in all the headers.""" for header in self._headers: header.set(key, value=value, comment=comment) def __getitem__(self, key): + """Get item from all the headers by key.""" missing_at = [] ret = [] for n, header in enumerate(self._headers): @@ -121,6 +138,7 @@ def get(self, key, default=None): return vals def __delitem__(self, key): + """Remove key from all the headers.""" self.remove(key) def remove(self, key): @@ -155,14 +173,15 @@ def _inner_set_comment(header): raise KeyError(f"{err.args[0]} at header {n}") from err def __contains__(self, key): + """Return True if key is present in any of the headers.""" return any(tuple(key in h for h in self._headers)) def new_imagehdu(data, header, name=None): """Create a new ImageHDU from data and header. - Parameters - ---------- + Arguments + --------- data : `numpy.ndarray` The data array. @@ -193,8 +212,8 @@ def new_imagehdu(data, header, name=None): def table_to_bintablehdu(table, extname=None): """Convert an astropy Table object to a BinTableHDU before writing to disk. - Parameters - ---------- + Arguments + --------- table: astropy.table.Table instance the table to be converted to a BinTableHDU @@ -270,8 +289,33 @@ def header_for_table(table): return fits_header +@deprecated( + "The functionality of this function is now covered by the " + "astropy.io.fits.Header class." +) def add_header_to_table(table): - """Add a FITS header to a table.""" + """Add a FITS header to a table's metadata. Deprecated. + + This does not modify the table itself, but adds the header to the table's + metadata. If a header is already present in the table's metadata, it will + ensure it's up to date with the table's columns. + + Warning + ------- + This function is deprecated and will be removed in a future version. Its + functionality is covered by Tables. + + Arguments + --------- + table : `astropy.table.Table` + The table to add the header to. + + Returns + ------- + header : `astropy.io.fits.Header` + The header to add to the table + + """ header = header_for_table(table) table.meta["header"] = header return header @@ -315,11 +359,10 @@ def _process_table(table, name=None, header=None): def card_filter(cards, include=None, exclude=None): - """Filter a list of cards, lazily returning only those that match the - criteria. + """Filter a list of cards, returning only those that match the criteria. - Parameters - ---------- + Arguments + --------- cards : iterable The cards to filter. @@ -333,6 +376,7 @@ def card_filter(cards, include=None, exclude=None): ------ card : tuple A card that matches the criteria. + """ for card in cards: if include is not None and card[0] not in include: @@ -345,16 +389,16 @@ def card_filter(cards, include=None, exclude=None): def update_header(headera, headerb): - """Update headera with the cards from headerb, but only if they are - different. + """Update headera with the cards from headerb if they differ. - Parameters - ---------- + Arguments + --------- headera : `astropy.io.fits.Header` The header to update. headerb : `astropy.io.fits.Header` The header to update from. + """ cardsa = tuple(tuple(cr) for cr in headera.cards) cardsb = tuple(tuple(cr) for cr in headerb.cards) @@ -381,7 +425,7 @@ def update_header(headera, headerb): def fits_ext_comp_key(ext): - """Returns a pair (int, str) that will be used to sort extensions.""" + """Return a pair (int, str) that can be used to sort extensions.""" if isinstance(ext, PrimaryHDU): # This will guarantee that the primary HDU goes first ret = (-1, "") @@ -417,10 +461,10 @@ class FitsLazyLoadable: """Class to delay loading of data from a FITS file.""" def __init__(self, obj): - """Initializes the object. + """Initialize the object. - Parameters - ---------- + Arguments + --------- obj : `astropy.io.fits.ImageHDU` or `astropy.io.fits.BinTableHDU` The HDU to delay loading from. """ @@ -447,6 +491,7 @@ def _scale(self, data): return (bscale * data + bzero).astype(self.dtype) def __getitem__(self, arr_slice): + """Get a slice of the data.""" return self._scale(self._obj.section[arr_slice]) @property @@ -468,7 +513,9 @@ def shape(self): @property def dtype(self): - """Need to to some overriding of astropy.io.fits since it doesn't + """Return the dtype for the pixel data. + + Need to to some overriding of astropy.io.fits since it doesn't know about BITPIX=8 """ # pylint: disable=protected-access @@ -500,8 +547,8 @@ def dtype(self): def _prepare_hdulist(hdulist, default_extension="SCI", extname_parser=None): """Prepare an HDUList for reading. - Parameters - ---------- + Arguments + --------- hdulist : `astropy.io.fits.HDUList` The HDUList to prepare. @@ -575,14 +622,16 @@ def _prepare_hdulist(hdulist, default_extension="SCI", extname_parser=None): def read_fits(cls, source, extname_parser=None): - """Takes either a string (with the path to a file) or an HDUList as input, + """Read a FITS file and return an AstroData object. + + Takes either a string (with the path to a file) or an HDUList as input, and tries to return a populated AstroData (or descendant) instance. It will raise exceptions if the file is not found, or if there is no match for the HDUList, among the registered AstroData classes. - Parameters - ---------- + Arguments + --------- cls : class The class to instantiate. @@ -597,7 +646,6 @@ def read_fits(cls, source, extname_parser=None): ad : `astrodata.AstroData` or descendant The populated AstroData object. This is of the type specified by cls. """ - ad = cls() if isinstance(source, (str, os.PathLike)): @@ -763,7 +811,7 @@ def associated_extensions(ver): def ad_to_hdulist(ad): - """Creates an HDUList from an AstroData object.""" + """Create an HDUList from an AstroData object.""" hdul = HDUList() hdul.append(PrimaryHDU(header=ad.phu, data=DELAYED)) @@ -891,7 +939,7 @@ def ad_to_hdulist(ad): def write_fits(ad, filename, overwrite=False): - """Writes the AstroData object to a FITS file.""" + """Write the AstroData object to a FITS file.""" hdul = ad_to_hdulist(ad) hdul.writeto(filename, overwrite=overwrite) @@ -901,7 +949,10 @@ def write_fits(ad, filename, overwrite=False): "and will be removed in a future version." ) def windowedOp(*args, **kwargs): # pylint: disable=invalid-name - """Deprecated alias for windowed_operation.""" + """Perform a windowed operation. + + Deprecated alias for :py:meth:`~astrodata.fits.windowed_operation`. + """ return windowed_operation(*args, **kwargs) @@ -935,12 +986,13 @@ def _get_shape(sequence): def _apply_func(func, sequence, boxes, result, **kwargs): - """ + """Call a function on a sequence of elements with specific chunking. + Apply a given function to a sequence of elements within specified boxes and store the result in the result object. - Parameters - ---------- + Arguments + --------- func : function The function to apply to the elements. sequence : list @@ -987,11 +1039,14 @@ def windowed_operation( with_mask=False, **kwargs, ): - """Apply function on a NDData obbjects, splitting the data in chunks to - limit memory usage. + """Apply function on a NDData objects by chunks. + + This splits the input arrays in chunks of size ``kernel`` and applies the + function to each chunk. The output arrays are then gathered in a new + NDData object. - Parameters - ---------- + Arguments + --------- func : callable The function to apply. @@ -1094,8 +1149,8 @@ def wcs_to_asdftablehdu(wcs, extver=None): Returns None (issuing a warning) if the WCS object cannot be serialized, so the rest of the file can still be written. - Parameters - ---------- + Arguments + --------- wcs : gWCS The gWCS object to serialize. diff --git a/astrodata/nddata.py b/astrodata/nddata.py index 8c284d7f..8d793fc6 100644 --- a/astrodata/nddata.py +++ b/astrodata/nddata.py @@ -1,4 +1,6 @@ -"""This module implements a derivative class based on NDData with some Mixins, +"""Implementations of NDData-like classes for |AstroData| objects. + +This module implements a derivative class based on NDData with some Mixins, implementing windowing and on-the-fly data scaling. """ @@ -39,7 +41,9 @@ def array(self, value): class AstroDataMixin: - """A Mixin for ``NDData``-like classes (such as ``Spectrum1D``) to enable + """Mixin with AstroData-like behavior for NDData-like classes. + + Mixin for ``NDData``-like classes (such as ``Spectrum1D``) to enable them to behave similarly to ``AstroData`` objects. These behaviors are: @@ -53,8 +57,11 @@ class AstroDataMixin: """ def __getattr__(self, attribute): - """Allow access to attributes stored in self.meta['other'], as we do - with AstroData objects. + """Access attributes stored in self.meta['other']. + + Required to access attributes like |AstroData| objects. See the + documentation for |AstroData|'s ``__getattr__`` method for more + information. """ if attribute.isupper(): try: @@ -80,8 +87,14 @@ def _arithmetic( compare_wcs="first_found", **kwds, ): - """Override the NDData method so that "bitwise_or" becomes the default + """Perform arithmetic operations on the data. + + Overrides the NDData method so that "bitwise_or" becomes the default operation to combine masks, rather than "logical_or" + + .. warning:: + This method is not intended to be called directly. Use the + arithmetic methods of the NDData object instead. """ return super()._arithmetic( operation, @@ -95,9 +108,16 @@ def _arithmetic( ) def _slice_wcs(self, slices): - """The ``__call__()`` method of gWCS doesn't appear to conform to the + """Slice the WCS object. + + The ``__call__()`` method of gWCS doesn't appear to conform to the APE 14 interface for WCS implementations, and doesn't react to slicing properly. We override NDSlicing's method to do what we want. + + Arguments + --------- + slices : slice or tuple of slices + The slice or slices to apply to the WCS. """ if not isinstance(self.wcs, gWCS): return self.wcs @@ -183,7 +203,7 @@ def _slice_wcs(self, slices): @property def variance(self): - """A convenience property to access the contents of ``uncertainty``.""" + """Access the contents of ``uncertainty``.""" return getattr(self.uncertainty, "array", None) @variance.setter @@ -194,7 +214,9 @@ def variance(self, value): @property def wcs(self): - """The WCS of the data. This is a gWCS object, not a FITS WCS object. + """Return the WCS of the data as a gWCS object. + + This is a gWCS object, not a FITS WCS object. This is returning wcs from an inhertited class, see NDData.wcs for more details. @@ -219,7 +241,12 @@ def size(self): class FakeArray: - """A class that pretends to be an array, but is actually a lazy-loaded""" + """Fake array class for lazy-loaded data. + + A class that pretends to be an array, but is actually a lazy-loaded. + This is used to fool the NDData class into thinking it has an array + when it doesn't. + """ def __init__(self, very_faked): self.data = very_faked @@ -234,7 +261,9 @@ def __array__(self): class NDWindowing: - """A class to allow "windowed" access to some properties of an + """Window access to an ``NDAstroData`` instance. + + A class to allow "windowed" access to some properties of an ``NDAstroData`` instance. In particular, ``data``, ``uncertainty``, ``variance``, and ``mask`` return clipped data. """ @@ -249,7 +278,9 @@ def __getitem__(self, window_slice): class NDWindowingAstroData( AstroDataMixin, NDArithmeticMixin, NDSlicingMixin, NDData ): - """Allows "windowed" access to some properties of an ``NDAstroData`` + """Implement windowed access to an ``NDAstroData`` instance. + + Provide "windowed" access to some properties of an ``NDAstroData`` instance. In particular, ``data``, ``uncertainty``, ``variance``, and ``mask`` return clipped data. """ @@ -260,8 +291,11 @@ def __init__(self, target, window): self._window = window def __getattr__(self, attribute): - """Allow access to attributes stored in self.meta['other'], as we do - with AstroData objects. + """Access attributes stored in self.meta['other']. + + This is required to access attributes like |AstroData| objects. See the + documentation for |AstroData|'s ``__getattr__`` method for more + information. """ if attribute.isupper(): try: @@ -281,17 +315,14 @@ def unit(self): @property def wcs(self): - # pylint: disable=protected-access return self._target._slice_wcs(self._window) @property def data(self): - # pylint: disable=protected-access return self._target._get_simple("_data", section=self._window) @property def uncertainty(self): - # pylint: disable=protected-access return self._target._get_uncertainty(section=self._window) @property @@ -303,18 +334,18 @@ def variance(self): @property def mask(self): - # pylint: disable=protected-access return self._target._get_simple("_mask", section=self._window) def is_lazy(item): - """Returns True if the item is a lazy-loaded object, False otherwise.""" + """Return True if the item is a lazy-loaded object, False otherwise.""" return isinstance(item, ImageHDU) or getattr(item, "lazy", False) class NDAstroData(AstroDataMixin, NDArithmeticMixin, NDSlicingMixin, NDData): - """Implements ``NDData`` with all Mixins, plus some ``AstroData`` - specifics. + """Primary data class for AstroData objects. + + Implements ``NDData`` with all Mixins, plus some ``AstroData`` specifics. This class implements an ``NDData``-like container that supports reading and writing as implemented in the ``astropy.io.registry`` and also slicing @@ -327,7 +358,7 @@ class NDAstroData(AstroDataMixin, NDArithmeticMixin, NDSlicingMixin, NDData): Documentation is provided where our class differs. - See also + See Also -------- NDData NDArithmeticMixin @@ -335,7 +366,6 @@ class NDAstroData(AstroDataMixin, NDArithmeticMixin, NDSlicingMixin, NDData): Examples -------- - The mixins allow operation that are not possible with ``NDData`` or ``NDDataBase``, i.e. simple arithmetics:: @@ -382,8 +412,8 @@ def __init__( ): """Initialize an ``NDAstroData`` instance. - Parameters - ---------- + Arguments + --------- data : array-like The actual data. This can be a numpy array, a memmap, or a ``fits.ImageHDU`` object. @@ -449,6 +479,12 @@ def __init__( self.uncertainty = uncertainty def __deepcopy__(self, memo): + """Implement the deepcopy protocol for this class. + + This implementation accounts for the lazy-loading of the data and + uncertainty attributes. It also avoids recursion when copying the + uncertainty attribute. + """ new = self.__class__( self._data if is_lazy(self._data) else deepcopy(self.data, memo), self._uncertainty if is_lazy(self._uncertainty) else None, @@ -465,25 +501,37 @@ def __deepcopy__(self, memo): @property def window(self): - """Interface to access a section of the data, using lazy access + """Access a slice of the data. + + Interface to access a section of the data, using lazy access whenever possible. Returns - -------- + ------- An instance of ``NDWindowing``, which provides ``__getitem__``, to allow the use of square brackets when specifying the window. Ultimately, an ``NDWindowingAstrodata`` instance is returned. Examples - --------- - + -------- >>> ad[0].nddata.window[100:200, 100:200] # doctest: +SKIP """ return NDWindowing(self) def _get_uncertainty(self, section=None): - """Return the ADVarianceUncertainty object, or a slice of it.""" + """Return the ADVarianceUncertainty object, or a slice of it. + + Arguments + --------- + section : slice, optional + The slice to apply to the uncertainty object. + + Returns + ------- + ADVarianceUncertainty + The uncertainty object, or a slice of it if a section is provided. + """ if self._uncertainty is not None: if is_lazy(self._uncertainty): if section is None: @@ -502,8 +550,12 @@ def _get_uncertainty(self, section=None): return None def _get_simple(self, target, section=None): - """Only use 'section' for image-like objects that have the same shape - as the NDAstroData object; otherwise, return the whole object""" + """Return the section of image-like objects, or the whole object. + + Only use 'section' for image-like objects that have the same shape + as the NDAstroData object; otherwise, return the whole object. + """ + # TODO(teald): Unclear description of what this method does. source = getattr(self, target) if source is not None: if is_lazy(source): @@ -529,9 +581,7 @@ def _get_simple(self, target, section=None): @property def data(self): - """An array representing the raw data stored in this instance. It - implements a setter. - """ + """Access the data stored in this instance. It implements a setter.""" return self._get_simple("_data") @data.setter @@ -546,6 +596,7 @@ def data(self, value): @property def uncertainty(self): + """Get or set the uncertainty of the data.""" return self._get_uncertainty() @uncertainty.setter @@ -571,9 +622,12 @@ def mask(self, value): @property def variance(self): - """A convenience property to access the contents of ``uncertainty``, + """Get and aset the variance of the data. + + A convenience property to access the contents of ``uncertainty``, squared (as the uncertainty data is stored as standard deviation). """ + # TODO(teald): Refactor uncertainty and variance implementation. arr = self._get_uncertainty() if arr is not None: @@ -588,12 +642,14 @@ def variance(self, value): ) def set_section(self, section, input_data): - """Sets only a section of the data. This method is meant to prevent + """Set a section of the data to the input data. + + Sets only a section of the data. This method is meant to prevent fragmentation in the Python heap, by reusing the internal structures instead of replacing them with new ones. - Args - ----- + Arguments + --------- section : ``slice`` The area that will be replaced @@ -603,8 +659,7 @@ def set_section(self, section, input_data): area defined by ``section``. Examples - --------- - + -------- >>> def setup(): ... sec = NDData(np.zeros((100,100))) ... ad[0].nddata.set_section( @@ -624,6 +679,13 @@ def set_section(self, section, input_data): self.mask[section] = input_data.mask def __repr__(self): + """Return a string representation of the object. + + If the data is lazy-loaded, the string representation will include + the class name and the string "(Memmapped)", representing that this + memory may not have been loaded in yet. + """ + # TODO(teald): Check that repr reverts to normal behavior after loading if is_lazy(self._data): return self.__class__.__name__ + "(Memmapped)" diff --git a/astrodata/provenance.py b/astrodata/provenance.py index cf6dedb1..44a41560 100644 --- a/astrodata/provenance.py +++ b/astrodata/provenance.py @@ -1,4 +1,6 @@ -"""Provides functions for adding provenance information to +"""Provence module for managing |AstroData| object provenance information. + +Provides functions for adding provenance information to `~astrodata.core.AstroData` objects. """ @@ -9,14 +11,13 @@ def add_provenance(ad, filename, md5, primitive, timestamp=None): - """Add the given provenance entry to the full set of provenance records on - this object. + """Add a provenance entry to the all provenance records in this object. Provenance is added even if the incoming md5 is None or ''. This indicates source data for the provenance that are not on disk. - Parameters - ---------- + Arguments + --------- ad : `astrodata.AstroData` AstroData object to add provenance record to. @@ -67,11 +68,10 @@ def add_provenance(ad, filename, md5, primitive, timestamp=None): def add_history(ad, timestamp_start, timestamp_stop, primitive, args): - """Add the given History entry to the full set of history records on this - object. + """Add history entry to the full set of history records on this object. - Parameters - ---------- + Arguments + --------- ad : `astrodata.AstroData` AstroData object to add history record to. @@ -172,15 +172,17 @@ def add_history(ad, timestamp_start, timestamp_stop, primitive, args): def clone_provenance(provenance_data, ad): - """For a single input's provenance, copy it into the output + """Copy provenance information from one `AstroData` object to another. + + For a single input's provenance, copy it into the output `AstroData` object as appropriate. This takes a dictionary with a source filename, md5 and both its original provenance and history information. It duplicates the provenance data into the outgoing `AstroData` ad object. - Parameters - ---------- + Arguments + --------- provenance_data : Pointer to the `~astrodata.AstroData` table with the provenance information. *Note* this may be the output `~astrodata.AstroData` @@ -195,15 +197,17 @@ def clone_provenance(provenance_data, ad): def clone_history(history_data, ad): - """For a single input's history, copy it into the output `AstroData` object + """Copy history information from one `AstroData` object to another. + + For a single input's history, copy it into the output `AstroData` object as appropriate. This takes a dictionary with a source filename, md5 and both its original provenance and history information. It duplicates the history data into the outgoing `AstroData` ad object. - Parameters - ---------- + Arguments + --------- history_data : pointer to the `AstroData` table with the history information. *Note* this may be the output `~astrodata.AstroData` as well, so we @@ -265,7 +269,9 @@ def find_history_column_indices(ad): def provenance_summary(ad, provenance=True, history=True): - """Generate a pretty text display of the provenance information for an + """Summarize provenance information for an |AstroData| object as a str. + + Generate a pretty text display of the provenance information for an `~astrodata.core.AstroData`. This pulls the provenance and history information from a @@ -273,8 +279,8 @@ def provenance_summary(ad, provenance=True, history=True): primitive arguments in the history are wrapped across multiple lines to keep the overall width manageable. - Parameters - ---------- + Arguments + --------- ad : :class:`~astrodata.core.AstroData` Input data to read provenance from diff --git a/astrodata/testing.py b/astrodata/testing.py index 41726536..a0df7c8f 100644 --- a/astrodata/testing.py +++ b/astrodata/testing.py @@ -1,5 +1,5 @@ # pragma: no cover -"""Fixtures to be used in tests in DRAGONS""" +"""Define utility functions and classes for testing purposes.""" import enum import functools @@ -38,13 +38,33 @@ class DownloadResult(enum.Enum): + """Result status for the download_from_archive function. + + Enum class to store the possible states of the download_from_archive + function. The states are used to determine if the function was successful + in downloading a file from the archive. + + Attributes + ---------- + SUCCESS : int + The download was successful. + + NOT_FOUND : int + The file was not found in the archive. + + NONE : int + The state is not set. + """ + SUCCESS = 2 NOT_FOUND = 1 NONE = 0 class DownloadState: - """Singleton class to hold the state of the download_from_archive function. + """Stores the success state of the download_from_archive function. + + Singleton class to hold the state of the download_from_archive function. A bit of an annoying workaround because of conflicts with how ``pytest``'s fixtures work. @@ -52,6 +72,27 @@ class DownloadState: instantiated directly. Instead, the instance should be accessed via the ``_instance`` class attribute. Instantiation using ``DownloadState()`` will do this automatically. + + Attributes + ---------- + _state : DownloadResult + The state of the download_from_archive function. + + _valid_state : bool + Flag to indicate if the state is valid. + + test_result : bool + Result of the test download_from_archive function. + + Notes + ----- + To check the state of the download_from_archive function, use the + ``check_state`` method. This method will return the state of the function + as a :class:`~DownloadResult` enum. If the state is not valid, the method + will re-test the function. + + See the :class:`~DownloadResult` enum for the possible states and their + documentation. """ __slots__ = ["_state", "_valid_state", "test_result"] @@ -59,6 +100,7 @@ class DownloadState: _instance = None def __new__(cls): + """Instantiate or return the singleton class.""" if cls._instance is None: cls._instance = super().__new__(cls) cls._instance._state = None @@ -97,8 +139,7 @@ def invalidate_cache(self): def skip_if_download_none(func): - """Skip test if download_from_archive is returning None. Otherwise, - continue. + """Skip test if download_from_archive is returning None. Used as a wrapper for testing functions. Works with nose, pynose, and pytest. @@ -119,11 +160,13 @@ def wrapper(*args, **kwargs): def get_corners(shape): - """This is a recursive function to calculate the corner indices + """Calculate the corner indices of an array of the specified shape. + + This is a recursive function to calculate the corner indices of an array of the specified shape. - Parameters - ---------- + Arguments + --------- shape : tuple of ints Length of the dimensions of the array @@ -156,17 +199,25 @@ def get_corners(shape): def assert_most_close( - actual, desired, max_miss, rtol=1e-7, atol=0, equal_nan=True, verbose=True + actual, + desired, + max_miss, + rtol=1e-7, + atol=0, + equal_nan=True, + verbose=True, ): - """Raises an AssertionError if the number of elements in two objects that + """Assert that two objects are equal up to a specified number of elements. + + Raises an AssertionError if the number of elements in two objects that are not equal up to desired tolerance is greater than expected. See Also -------- :func:`~numpy.testing.assert_allclose` - Parameters - ---------- + Arguments + --------- actual : array_like Array obtained. @@ -227,11 +278,13 @@ def assert_most_close( def assert_most_equal(actual, desired, max_miss, verbose=True): - """Raises an AssertionError if more than `n` elements in two objects are - not equal. For more information, check :func:`numpy.testing.assert_equal`. + """Assert that two objects are equal up to a specified number of elements. - Parameters - ---------- + Raises an AssertionError if more than `n` elements in two objects are not + equal. For more information, check :func:`numpy.testing.assert_equal`. + + Arguments + --------- actual : array_like The object to check. @@ -249,6 +302,7 @@ def assert_most_equal(actual, desired, max_miss, verbose=True): AssertionError If actual and desired are not equal. """ + # TODO(teald): Use importlib to import the necessary modules. from numpy.testing import assert_equal try: @@ -276,16 +330,19 @@ def assert_most_equal(actual, desired, max_miss, verbose=True): def assert_same_class(ad, ad_ref): - """Compare if two :class:`~astrodata.AstroData` (or any subclass) have the - same class. + """Compare two :class:`~astrodata.AstroData` objects have the same class. - Parameters + This function is used to compare two |AstroData| objects to ensure they are + are equivalent classes, i.e., they are both |AstroData| objects or both + |AstroData| subclasses. + + Arguments ---------- - ad : :class:`astrodata.AstroData` or any subclass - AstroData object to be checked. + ad : :class:`astrodata.AstroData` or any subclass + AstroData object to be checked. - ad_ref : :class:`astrodata.AstroData` or any subclass - AstroData object used as reference + ad_ref : :class:`astrodata.AstroData` or any subclass + AstroData object used as reference """ from astrodata import AstroData @@ -295,7 +352,9 @@ def assert_same_class(ad, ad_ref): def compare_models(model1, model2, rtol=1e-7, atol=0.0, check_inverse=True): - """Check that any two models are the same, within some tolerance on + """Compare two models for similarity. + + Check that any two models are the same, within some tolerance on parameters (using the same defaults as numpy.assert_allclose()). This is constructed like a test, rather than returning True/False, in order @@ -313,7 +372,36 @@ def compare_models(model1, model2, rtol=1e-7, atol=0.0, check_inverse=True): parameters controlling how the array of model `parameters` is interpreted (eg. the orders in SIP?), but it does cover our common use of compound models involving orthonormal polynomials etc. + + Arguments + --------- + model1 : `astropy.modeling.Model` + First model to compare. + + model2 : `astropy.modeling.Model` + Second model to compare. + + rtol : float + Relative tolerance. + + atol : float + Absolute tolerance. + + check_inverse : bool + If True, compare the inverses of the models as well. + + Raises + ------ + AssertionError + If the models are not the same. + + Notes + ----- + This function is taken from the `DRAGONS` repository and is used to compare + models in the context of the `DRAGONS` project. It is included here for + completeness. """ + # TODO(teald): Use importlib to import the necessary modules. from astropy.modeling import Model from numpy.testing import assert_allclose @@ -397,8 +485,8 @@ def download_multiple_files( ): """Download multiple files from the archive and store them at a given path. - Parameters - ---------- + Arguments + --------- files : list of str List of filenames to download. @@ -493,10 +581,10 @@ def download_from_archive( fail_on_error=True, suppress_stdout=False, ): - """Download file from the archive and store it in the local cache. + """Download a file from the archive and store it in the local cache. - Parameters - ---------- + Arguments + --------- filename : str The filename, e.g. N20160524S0119.fits @@ -608,11 +696,13 @@ def download_from_archive( def get_associated_calibrations(filename, nbias=5): - """Queries Gemini Observatory Archive for associated calibrations to reduce - the data that will be used for testing. + """Query Gemini Observatory Archive for associated calibrations. - Parameters - ---------- + This function quieries the Gemini Observatory Archive for calibrations + associated with a given data file. + + Arguments + --------- filename : str Input file name """ @@ -635,7 +725,9 @@ def get_associated_calibrations(filename, nbias=5): class ADCompare: - """Compare two AstroData instances to determine whether they are basically + """Compare two |AstroData| instances for near-equality. + + Use this class to determine whether two |AstroData| instances are basically the same. Various properties (both data and metadata) can be compared """ @@ -670,8 +762,8 @@ def run_comparison( ): """Perform a comparison between the two AD objects in this instance. - Parameters - ---------- + Arguments + --------- max_miss: int maximum number of elements in each array that can disagree @@ -701,7 +793,7 @@ def run_comparison( specific mismatch is permitted Raises - ------- + ------ AssertionError if the AD objects do not agree. """ self.max_miss = max_miss @@ -739,7 +831,7 @@ def run_comparison( return errordict def numext(self): - """Check the number of extensions is equal""" + """Check the number of extensions is equal.""" numext1, numext2 = len(self.ad1), len(self.ad2) if numext1 != numext2: return [f"{numext1} v {numext2}"] @@ -747,7 +839,7 @@ def numext(self): return [] def filename(self): - """Check the filenames are equal""" + """Check the filenames are equal.""" fname1, fname2 = self.ad1.filename, self.ad2.filename if fname1 != fname2: @@ -756,7 +848,7 @@ def filename(self): return [] def tags(self): - """Check the tags are equal""" + """Check the tags are equal.""" tags1, tags2 = self.ad1.tags, self.ad2.tags if tags1 != tags2: @@ -765,7 +857,7 @@ def tags(self): return [] def phu(self): - """Check the PHUs agree""" + """Check the PHUs agree.""" # Ignore NEXTEND as only recently added and len(ad) handles it errorlist = self._header( self.ad1.phu, @@ -776,7 +868,7 @@ def phu(self): return errorlist def hdr(self): - """Check the extension headers agree""" + """Check the extension headers agree.""" errorlist = [] for i, (hdr1, hdr2) in enumerate(zip(self.ad1.hdr, self.ad2.hdr)): elist = self._header(hdr1, hdr2, ignore=self.ignore_kw) @@ -785,7 +877,19 @@ def hdr(self): return errorlist def _header(self, hdr1, hdr2, ignore=None): - """General method for comparing headers, ignoring some keywords""" + """Compare headers, ignoring keywords in ignore. + + Arguments + --------- + hdr1 : Header + First header to compare. + + hdr2 : Header + Second header to compare. + + ignore : list + List of keywords to ignore during comparison. + """ errorlist = [] s1 = set(hdr1.keys()) - {"HISTORY", "COMMENT"} s2 = set(hdr2.keys()) - {"HISTORY", "COMMENT"} @@ -832,7 +936,7 @@ def _header(self, hdr1, hdr2, ignore=None): return errorlist def refcat(self): - """Check both ADs have REFCATs (or not) and that the lengths agree""" + """Check both ADs have REFCATs (or not) and their lengths agree.""" # REFCAT can be in the PHU or the AD itself, depending on if REFCAT is # implemented as a property/attr or not in the parent class. refcat1 = getattr(self.ad1.phu, "REFCAT", None) @@ -854,7 +958,7 @@ def refcat(self): return [] def attributes(self): - """Check extension-level attributes""" + """Check extension-level attributes.""" errorlist = [] for i, (ext1, ext2) in enumerate(zip(self.ad1, self.ad2)): elist = self._attributes(ext1, ext2) @@ -863,7 +967,7 @@ def attributes(self): return errorlist def _attributes(self, ext1, ext2): - """Helper method for checking attributes""" + """Check the attributes of two extensions.""" errorlist = [] for attr in ["data", "mask", "variance", "OBJMASK", "OBJCAT"]: attr1 = getattr(ext1, attr, None) @@ -879,10 +983,10 @@ def _attributes(self, ext1, ext2): return errorlist def wcs(self): - """Check WCS agrees""" + """Check WCS agrees between ad objects.""" def compare_frames(frame1, frame2): - """Compare the important stuff of two CoordinateFrame instances""" + """Compare the important stuff of two CoordinateFrame instances.""" for attr in ( "naxes", "axes_type", @@ -937,7 +1041,7 @@ def compare_frames(frame1, frame2): return errorlist def format_errordict(self, errordict): - """Format the errordict into a str for reporting""" + """Format the errordict into a str for reporting.""" errormsg = ( f"Comparison between {self.ad1.filename} and {self.ad2.filename}" ) @@ -949,11 +1053,13 @@ def format_errordict(self, errordict): def ad_compare(ad1, ad2, **kwargs): - """Compares the tags, headers, and pixel values of two images. This is - simply a wrapper for ADCompare.run_comparison() for backward-compatibility. + """Compare the tags, headers, and pixel values of two images. - Parameters - ---------- + This is a wrapper for ADCompare.run_comparison() for + backward-compatibility. + + Arguments + --------- ad1: AstroData first AD objects @@ -985,8 +1091,7 @@ def fake_fits_bytes( masks: bool = False, single_hdu: bool = False, ) -> io.BytesIO: - """Create a fake FITS file in memory and return a BytesIO object that can - access it. + """Create a fake FITS file in memory and return readable BytesIO object. Arguments --------- @@ -1150,8 +1255,8 @@ def test_script_file( All matches (i.e., stdout_result and stderr_result) can use regular expressions. - Parameters - ---------- + Arguments + --------- script_path : str The path to the script to be run. @@ -1252,11 +1357,10 @@ def _reg_assert(result, expected): def process_string_to_python_script(string: str) -> str: - """Takes an input string and performs tasks to make it properly - python-formatted. + """Format a stirng to be used as a Python script. - Parameters - ---------- + Arguments + --------- string : str The string to be processed. """ @@ -1277,14 +1381,20 @@ def process_string_to_python_script(string: str) -> str: def get_program_observations(): + """Get the program and observation IDs for the current test. + + .. warning:: + This function is not implemented. It will be implemented in a future + release. + """ raise NotImplementedError def expand_file_range(files: str) -> list[str]: """Expand a range of files into a list of file names. - Parameters - ---------- + Arguments + --------- files : str A range of files, e.g., "N20170614S0201-205". This would produce: diff --git a/astrodata/utils.py b/astrodata/utils.py index e077ed2f..49d59f8f 100644 --- a/astrodata/utils.py +++ b/astrodata/utils.py @@ -32,10 +32,10 @@ class AstroDataDeprecationWarning(DeprecationWarning): def deprecated(reason): - """Marks a function as deprecated. + """Mark a function as deprecated. - Parameters - ---------- + Arguments + --------- reason : str The reason why the function is deprecated @@ -50,6 +50,7 @@ def deprecated(reason): >>> @deprecated("Use another function instead") ... def my_function(): ... pass + """ def decorator_wrapper(fn): @@ -97,7 +98,9 @@ def normalize_indices(slc, nitems): class TagSet(namedtuple("TagSet", "add remove blocked_by blocks if_present")): - """Named tuple that is used by tag methods to return which actions should + """A named tuple of sets of tag strings. + + Named tuple that is used by tag methods to return which actions should be performed on a tag set. All the attributes are optional, and any combination of them can be used, @@ -128,7 +131,7 @@ class TagSet(namedtuple("TagSet", "add remove blocked_by blocks if_present")): This TagSet will be applied only *all* of these tags are present Examples - --------- + -------- >>> TagSet() # doctest: +SKIP TagSet( add=set(), @@ -153,6 +156,15 @@ class TagSet(namedtuple("TagSet", "add remove blocked_by blocks if_present")): blocks=set(), if_present=set() ) + + + Notes + ----- + If arguments are not provided, the default is an empty set. + + These arguments are not applied within the object, instead they are + used when tags are being applied to an AstroData object. + """ def __new__( @@ -163,6 +175,7 @@ def __new__( blocks=None, if_present=None, ): + """Instantiate a new TagSet object.""" return super().__new__( cls, add or set(), @@ -174,12 +187,7 @@ def __new__( def astro_data_descriptor(fn): - """Decorator that will mark a class method as an AstroData descriptor. - Useful to produce list of descriptors, for example. - - If used in combination with other decorators, this one *must* be the - one on the top (ie. the last one applying). It doesn't modify the - method in any other way. + """Mark a class method as an AstroData descriptor. Args ----- @@ -187,29 +195,90 @@ def astro_data_descriptor(fn): The method to be decorated Returns - -------- + ------- The tagged method (not a wrapper) + + Warning + ------- + + If used in combination with other decorators, this one *must* be the one on + the top (i.e., the last one being applied). It doesn't modify the method in + any other way. + + e.g., + + .. code-block:: python + + @astro_data_descriptor # This must be above returns_list + @returns_list + def my_descriptor_method(self): + pass + + Notes + ----- + This decorator is exactly equivalent to: + + .. code-block:: python + + class MyClass: + def my_descriptor_method(self): + pass + + my_descriptor_method.descriptor_method = True + + It is used to mark descriptors for collective operations, such as + listing out the descriptors an |AstroData| object has or applying + them to a set of extensions. See the documentation for + :py:meth:`~astrodata.AstroData.descriptors` for an example. """ fn.descriptor_method = True return fn def returns_list(fn): - """Decorator to ensure that descriptors that should return a list (of one - value per extension) only returns single values when operating on single - slices; and vice versa. + """Ensure a function returns a list. + + Decorator to ensure that descriptors returning a list (of one value per + extension) only returns single values when operating on single slices; and + vice versa. This is a common case, and you can use the decorator to simplify the logic of your descriptors. - Args - ----- - fn : method + Arguments + --------- + fn : Callable The method to be decorated Returns - -------- - A function + ------- + Callable + A function + + Example + ------- + + .. code-block:: python + + from astrodata import ( + AstroData, + astro_data_descriptor, + returns_list, + NDAstroData + ) + + class MyAstroData(AstroData): + @astro_data_descriptor + @returns_list + def my_descriptor(self): + return 1 + + # Create an instance of the class with slices + ad = MyAstroData([NDAstroData([1, 2, 3]), NDAstroData([4, 5, 6])]) + + # This will print [1, 1] to stdout + print(ad.my_descriptor()) + """ @wraps(fn) @@ -260,13 +329,7 @@ def wrapper(self, *args, **kwargs): def astro_data_tag(fn): - """Decorator that marks methods of an `AstroData` derived class as part of - the tag-producing system. - - It wraps the method around a function that will ensure a consistent return - value: the wrapped method can return any sequence of sequences of strings, - and they will be converted to a TagSet. If the wrapped method - returns None, it will be turned into an empty TagSet. + """Mark a method as a tag-producing method. Args ----- @@ -274,8 +337,33 @@ def astro_data_tag(fn): The method to be decorated Returns - -------- + ------- A wrapper function + + Notes + ----- + Decorator that marks methods of an `AstroData` derived class as part of + the tag-producing system. + + It wraps the method around a function that will ensure a consistent return + value: the wrapped method can return any sequence of sequences of strings, + and they will be converted to a TagSet. If the wrapped method + returns None, it will be turned into an empty TagSet. + + Example + ------- + + .. code-block:: python + + class MyAstroData(AstroData): + @astro_data_tag + def my_tag_method(self): + # Below are the tags generated by this method based on whether + # the instrument is GMOS or not. + if self.phu.get('INSTRUME') == 'GMOS': + return {'GMOS'} + + return {'NOT_GMOS'} """ @wraps(fn) @@ -301,9 +389,26 @@ def wrapper(self): class Section(tuple): - """A class to handle n-dimensional sections""" + """A class to handle n-dimensional sections.""" def __new__(cls, *args, **kwargs): + """Instantiate a new Section object. + + Expects a sequence of pairs of start and end coordinates for each axis. + This can be passed in order of axis (e.g., x1, x2, y1, y2) or as a + set of keyword arguments (e.g., x1=0, x2=10, y1=0, y2=10). + + Arguments + --------- + x1, x2, y1, y2, ... : int + The start and end coordinates for each axis. If passed as + positional arguments, they should be in order of axis. Otherwise, + they can be passed as keyword arguments, such as: + + .. code-block:: python + + section = Section(x1=0, x2=10, y1=0, y2=10) + """ # Ensure that the order of keys is what we want axis_names = [x for axis in "xyzuvw" for x in (f"{axis}1", f"{axis}2")] @@ -327,18 +432,22 @@ def __new__(cls, *args, **kwargs): @property def axis_dict(self): + """Return a dictionary with the axis names as keys.""" return dict(zip(self._axis_names, self)) def __getnewargs__(self): + """Return arguments needed to create an equivalent Section instance.""" return tuple(self) def __getattr__(self, attr): + """Check for attrs in the axis_dict (axis names).""" if attr in self._axis_names: return self.axis_dict[attr] raise AttributeError(f"No such attribute '{attr}'") def __repr__(self): + """Return a string representation of the Section object.""" return ( "Section(" + ", ".join([f"{k}={self.axis_dict[k]}" for k in self._axis_names]) @@ -352,12 +461,20 @@ def ndim(self): @staticmethod def from_shape(value): - """Produce a Section object defining a given shape.""" + """Produce a Section object defining a given shape. + + Examples + -------- + >>> Section.from_shape((10, 10)) + Section(x1=0, x2=10, y1=0, y2=10) + >>> Section.from_shape((10, 10, 10)) + Section(x1=0, x2=10, y1=0, y2=10, z1=0, z2=10) + """ return Section(*[y for x in reversed(value) for y in (0, x)]) @staticmethod def from_string(value): - """The inverse of __str__, produce a Section object from a string.""" + """Produce a Section object from a string.""" return Section( *[ y @@ -375,12 +492,20 @@ def from_string(value): "and will be removed in a future version." ) def asIRAFsection(self): # pylint: disable=invalid-name - """Deprecated, see as_iraf_section""" + """Produce string with '[x1:x2,y1:y2]' 1-indexed and end-inclusive. + + Deprecated, see :py:meth:`~astrodata.Section.as_iraf_section`. + """ return self.as_iraf_section() def as_iraf_section(self): - """Produce string of style '[x1:x2,y1:y2]' that is 1-indexed - and end-inclusive + """Produce string with '[x1:x2,y1:y2]' 1-indexed and end-inclusive. + + This is the format used by IRAF for sections. + + For example, + >>> Section(0, 10, 0, 10).as_iraf_section() + '[1:10,1:10]' """ return ( "[" @@ -398,9 +523,17 @@ def as_iraf_section(self): + "]" ) + # TODO(teald): Deprecate and rename Section.asslice. def asslice(self, add_dims=0): - """Return the Section object as a slice/list of slices. Higher - dimensionality can be achieved with the add_dims parameter. + """Return the Section object as a slice/list of slices. + + Higher dimensionality can be achieved with the add_dims parameter. + + Arguments + --------- + + add_dims : int + The number of dimensions to add to the slice. """ return (slice(None),) * add_dims + tuple( slice(self.axis_dict[axis], self.axis_dict[axis.replace("1", "2")]) @@ -408,7 +541,37 @@ def asslice(self, add_dims=0): ) def contains(self, section): - """Return True if the supplied section is entirely within self""" + """Return True if the section is entirely within this Section. + + Arguments + --------- + section : Section + The Section to check for containment. + + Returns + ------- + bool + True if the Section is entirely within this Section, otherwise + False. + + Raises + ------ + ValueError + If the Sections have different dimensionality. + + Examples + -------- + >>> Section(0, 10, 0, 10).contains(Section(1, 9, 1, 9)) + True + >>> Section(0, 10, 0, 10).contains(Section(1, 11, 1, 9)) + False + >>> Section(0, 10, 0, 10).contains(Section(1, 9, 1, 11)) + False + >>> Section(0, 10, 0, 10).contains(Section(1, 3, 1, 7)) + True + >>> Section(0, 10, 0, 10).contains(Section(1, 3, 1, 11)) + False + """ if self.ndim != section.ndim: raise ValueError("Sections have different dimensionality") @@ -422,12 +585,45 @@ def contains(self, section): return con1 and con2 def is_same_size(self, section): - """Return True if the Sections are the same size""" + """Return True if the Sections are the same size, otherwise False. + + Examples + -------- + >>> Section(0, 10, 0, 10).is_same_size(Section(0, 10, 0, 10)) + True + >>> Section(0, 10, 0, 10).is_same_size(Section(0, 10, 0, 11)) + False + """ return np.array_equal(np.diff(self)[::2], np.diff(section)[::2]) def overlap(self, section): - """Determine whether the two sections overlap. If so, the Section - common to both is returned, otherwise None + """Return the overlap between two sections, or None if no overlap. + + Determine whether the two sections overlap. If so, the Section common + to both is returned, otherwise None. + + Examples + -------- + >>> Section(0, 10, 0, 10).overlap(Section(1, 9, 1, 9)) + Section(x1=1, x2=9, y1=1, y2=9) + >>> Section(0, 10, 0, 10).overlap(Section(1, 11, 1, 9)) + Section(x1=1, x2=10, y1=1, y2=9) + >>> Section(0, 10, 0, 10).overlap(Section(1, 9, 1, 11)) + Section(x1=1, x2=9, y1=1, y2=10) + >>> Section(0, 10, 0, 10).overlap(Section(1, 3, 1, 7)) + Section(x1=1, x2=3, y1=1, y2=7) + >>> Section(4, 6, 4, 6).overlap(Section(1, 3, 1, 2)) + None + + Raises + ------ + ValueError + If the Sections have different dimensionality. + + Notes + ----- + If sections do not overlap, a warning is logged when None is returned. + This is to help with debugging, as it is often not an error condition. """ if self.ndim != section.ndim: raise ValueError("Sections have different dimensionality") @@ -440,6 +636,7 @@ def overlap(self, section): *[v for pair in zip(mins, maxs) for v in pair] ) + # TODO(teald): Check overlap explicitly instead of catching ValueError except ValueError as err: logging.warning( "Sections do not overlap, recieved %s: %s", @@ -450,7 +647,32 @@ def overlap(self, section): return None def shift(self, *shifts): - """Shift a section in each direction by the specified amount""" + """Shift a section in each direction by the specified amount. + + Arguments + --------- + shifts : positional arguments + The amount to shift the section in each direction. + + Returns + ------- + Section + The shifted section. + + Raises + ------ + ValueError + If the number of shifts is not equal to the number of dimensions. + + Examples + -------- + >>> Section(0, 10, 0, 10).shift(1, 1) + Section(x1=1, x2=11, y1=1, y2=11) + >>> Section(0, 10, 0, 10).shift(1, 1, 1) + Traceback (most recent call last): + ... + ValueError: Number of shifts 3 incompatible with dimensionality 2 + """ if len(shifts) != self.ndim: raise ValueError( f"Number of shifts {len(shifts)} incompatible " diff --git a/astrodata/wcs.py b/astrodata/wcs.py index f3552f6e..d642c6a3 100644 --- a/astrodata/wcs.py +++ b/astrodata/wcs.py @@ -36,16 +36,17 @@ # FITS-WCS -> gWCS # ----------------------------------------------------------------------------- def pixel_frame(naxes, name="pixels"): - """Make a CoordinateFrame for pixels + """Make a CoordinateFrame for pixels. - Parameters - ---------- + Arguments + --------- naxes: int Number of axes Returns ------- CoordinateFrame + The pixel frame. """ axes_names = ("x", "y", "z", "u", "v", "w")[:naxes] return cf.CoordinateFrame( @@ -59,11 +60,13 @@ def pixel_frame(naxes, name="pixels"): def fitswcs_to_gwcs(input_data, *, raise_errors: bool = False): - """Create and return a gWCS object from a FITS header or NDData object. If + """Convert a FITS WCS header or NDData object to a gWCS object. + + Create and return a gWCS object from a FITS header or NDData object. If it can't construct one, it should quietly return None. - Parameters - ---------- + Arguments + --------- input_data : `astropy.io.fits.Header` or `astropy.nddata.NDData` FITS Header or NDData object with basic FITS WCS keywords. @@ -194,13 +197,15 @@ def fitswcs_to_gwcs(input_data, *, raise_errors: bool = False): # TODO: Rename this and deprecate this function. The name implies it is a # gwcs object being passed, but it requires an NDData object. def gwcs_to_fits(ndd, hdr=None): - """Convert a gWCS object to a collection of FITS WCS keyword/value pairs, + """Convert a gWCS object to FITS WCS keyword/value pairs. + + Convert a gWCS object to a collection of FITS WCS keyword/value pairs, if possible. If the FITS WCS is only approximate, this should be indicated with a dict entry {'FITS-WCS': 'APPROXIMATE'}. If there is no suitable FITS representation, then a ValueError or NotImplementedError can be raised. - Parameters - ---------- + Arguments + --------- ndd : `astropy.nddata.NDData` The NDData whose wcs attribute we want converted @@ -211,6 +216,11 @@ def gwcs_to_fits(ndd, hdr=None): ------- dict values to insert into the FITS header to express this WCS + + Warning + ------- + This function is experimental and may not work for all WCS objects. + It is likely to be deprecated in a future release. """ if hdr is None: hdr = {} @@ -502,7 +512,9 @@ def gwcs_to_fits(ndd, hdr=None): def model_is_affine(model): - """Test a Model for affinity. This is currently done by checking the name + """Return True if the model is a valid affine transformation. + + Test a Model for affinity. This is currently done by checking the name of its class (or the class names of all its submodels) TODO: Is this the right thing to do? We could compute the affine matrices @@ -530,15 +542,17 @@ def model_is_affine(model): def calculate_affine_matrices(func, shape, origin=None): - """Compute the matrix and offset necessary of an affine transform that + """Calculate the affine matrices and offset for a function. + + Compute the matrix and offset necessary of an affine transform that represents the supplied function. This is done by computing the linear matrix along all axes extending from the centre of the region, and then calculating the offset such that the transformation is accurate at the centre of the region. The matrix and offset are returned in the standard python order (i.e., y-first for 2D). - Parameters - ---------- + Arguments + --------- func : callable function that maps input->output coordinates @@ -601,8 +615,8 @@ def calculate_affine_matrices(func, shape, origin=None): def read_wcs_from_header(header): """Extract basic FITS WCS keywords from a FITS Header. - Parameters - ---------- + Arguments + --------- header : `astropy.io.fits.Header` FITS Header with WCS information. @@ -705,10 +719,10 @@ def read_wcs_from_header(header): def get_axes(header): - """Matches input with spectral and sky coordinate axes. + """Match input with spectral and sky coordinate axes. - Parameters - ---------- + Arguments + --------- header : `astropy.io.fits.Header` or dict FITS Header (or dict) with basic WCS information. @@ -749,11 +763,10 @@ def get_axes(header): def _is_skysys_consistent(ctype, sky_inmap): - """Determine if the sky axes in CTYPE match to form a standard celestial - system. + """Determine if the sky axes in CTYPE match a standard celestial system. - Parameters - ---------- + Arguments + --------- ctype : list List of CTYPE values. @@ -787,11 +800,13 @@ def _is_skysys_consistent(ctype, sky_inmap): def _get_contributing_axes(wcs_info, world_axes): - """Returns a tuple indicating which axes in the pixel frame make a + """Get the pixel axes that contribute to the output axes. + + Returns a tuple indicating which axes in the pixel frame make a contribution to an axis or axes in the output frame. - Parameters - ---------- + Arguments + --------- wcs_info : dict dict of WCS information @@ -802,6 +817,7 @@ def _get_contributing_axes(wcs_info, world_axes): ------- axes : list axes whose pixel coordinates affect the output axis/axes + """ cd = wcs_info["CD"] try: @@ -817,8 +833,8 @@ def _get_contributing_axes(wcs_info, world_axes): def make_fitswcs_transform(trans_input): """Create a basic FITS WCS transform. It does not include distortions. - Parameters - ---------- + Arguments + --------- header : `astropy.io.fits.Header` or dict FITS Header (or dict) with basic WCS information @@ -880,12 +896,14 @@ def make_fitswcs_transform(trans_input): def fitswcs_image(header): - """Make a complete transform from CRPIX-shifted pixels to sky coordinates + """Create a transorm from pixel to sky coordinates. + + Make a complete transform from CRPIX-shifted pixels to sky coordinates from FITS WCS keywords. A Mapping is inserted at the beginning, which may be removed later - Parameters - ---------- + Arguments + --------- header : `astropy.io.fits.Header` or dict FITS Header or dict with basic FITS WCS keywords. """ @@ -960,12 +978,14 @@ def fitswcs_image(header): def fitswcs_other(header, other=None): - """Create WCS linear transforms for any axes not associated with celestial + """Create appropriate models for unassociated axes in a FITS WCS. + + Create WCS linear transforms for any axes not associated with celestial coordinates. We require that each world axis aligns precisely with only a single pixel axis. - Parameters - ---------- + Arguments + --------- header : `astropy.io.fits.Header` or dict FITS Header or dict with basic FITS WCS keywords. """ @@ -1043,20 +1063,24 @@ def fitswcs_other(header, other=None): def remove_axis_from_frame(frame, axis): - """Remove the numbered axis from a CoordinateFrame and return a modified + """Remove a pixel axis from a frame. + + Remove the numbered axis from a CoordinateFrame and return a modified CoordinateFrame instance. - Parameters - ---------- + Arguments + --------- frame: CoordinateFrame - The frame from which an axis is to be removed + The frame from which an axis is to be removed. axis: int - index of the axis to be removed + index of the axis to be removed. Returns ------- - CoordinateFrame: the modified frame + CoordinateFrame + The modified frame + """ if axis is None: return frame @@ -1093,13 +1117,15 @@ def remove_axis_from_frame(frame, axis): def remove_axis_from_model(model, axis): - """Take a model where one output (axis) is no longer required and try to + """Remove an axis from a model that is no longer needed. + + Take a model where one output (axis) is no longer required and try to construct a new model whether that output is removed. If the number of inputs is reduced as a result, then report which input (axis) needs to be removed. - Parameters - ---------- + Arguments + --------- model: astropy.modeling.Model instance model to modify @@ -1108,12 +1134,34 @@ def remove_axis_from_model(model, axis): Returns ------- - tuple: Modified version of the model and input axis that is no longer - needed (input axis == None if completely removed) + tuple + Modified version of the model and input axis that is no longer needed + (input axis == None if completely removed) + + Raises + ------ + ValueError + If the model is not recognized or the axis is not found in the model + + Notes + ----- + This function is intended to be used when an output axis is not used in + the data, but is present in the WCS. This can happen when a WCS is + constructed from a FITS header that has more axes than are present in the + data. The function will remove the unused axis from the WCS, and update + the WCS object in the AstroData object. + + The function is recursive, so it can handle compound models. It will + remove the axis from the rightmost model first, and then work its way + leftwards. If the rightmost model is an Identity or Mapping model, it will + remove the axis from that model. If the rightmost model is a compound + model, it will recursively call itself on the rightmost model. If the + rightmost model is an arithmetic model, it will recursively call itself on + both the left and right models. """ def is_identity(model): - """Determine whether a model does nothing and so can be removed""" + """Determine whether a model does nothing and can be removed.""" return ( isinstance(model, models.Identity) or isinstance(model, models.Mapping) @@ -1234,12 +1282,28 @@ def is_identity(model): def remove_unused_world_axis(ext): - """Remove a single axis from the output frame of the WCS if it has no + """Remove an unused axis from the output frame's WCS. + + Remove a single axis from the output frame of the WCS if it has no dependence on input pixel location. - Parameters - ---------- + Arguments + --------- ext: single-slice AstroData object + AstroData object with WCS to be modified + + Raises + ------ + ValueError + If there is no single degenerate output axis to remove + + Notes + ----- + This function is intended to be used when an output axis is not used in + the data, but is present in the WCS. This can happen when a WCS is + constructed from a FITS header that has more axes than are present in the + data. The function will remove the unused axis from the WCS, and update + the WCS object in the AstroData object. """ ndim = len(ext.shape) affine = calculate_affine_matrices(ext.wcs.forward_transform, ext.shape) diff --git a/docs/developer/index.rst b/docs/developer/index.rst index 6fff1255..6ad0abb5 100644 --- a/docs/developer/index.rst +++ b/docs/developer/index.rst @@ -46,16 +46,29 @@ Requirements ------------ .. _Poetry: https://python-poetry.org/docs/ +.. _nox: https://nox.thea.codes/en/stable/ To install |astrodata|, you will need the following: - Python 3.10, 3.11, or 3.12 - Poetry_ in some flavor +- Nox_ (optional, see note below) -Please see the Poetry_ documentation for installation instructions. Note that -it is *not recommended to install Poetry with pip, especially in your working -directory for the project*. There are several solutions to this in their -documentation. +Please see the Poetry_ and nox_ documentation for installation instructions. +Note that it is *not recommended to install Poetry with pip, especially in your +working directory for the project*. There are several solutions to this in +their documentation. + +.. _pipx: https://pipx.pypa.io/stable/ + +We recommend using pipx_ to install Poetry and nox, as it will install them in +isolated environments and make them easier to manage. + +.. note:: + + If you'd prefer not to install nox_, you can invoke it using Poetry:: + + poetry run nox [...arguments...] Instructions ------------ diff --git a/pyproject.toml b/pyproject.toml index c46b2fae..88e4957d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -132,7 +132,7 @@ include = [ [tool.ruff.lint] # select = ["ALL"] -select = ["F", "E", "I"] +select = ["F", "E", "D", "I"] # Ruff ignored linting rules: # These rules are ignored for the entire project. They should be