diff --git a/.github/workflows/CFF-test.yml b/.github/workflows/CFF-test.yml index 723a27e46cd6..e63beb33504a 100644 --- a/.github/workflows/CFF-test.yml +++ b/.github/workflows/CFF-test.yml @@ -1,6 +1,9 @@ name: Checking CITATION.cff on: + workflow_dispatch: + paths: + - "CITATION.cff" push: paths: - "CITATION.cff" diff --git a/.github/workflows/ci_cron_monthly.yml b/.github/workflows/ci_cron_monthly.yml index 1ee9939ae692..3d9bfcf7757f 100644 --- a/.github/workflows/ci_cron_monthly.yml +++ b/.github/workflows/ci_cron_monthly.yml @@ -63,7 +63,7 @@ jobs: with: persist-credentials: false fetch-depth: 0 - - uses: uraimo/run-on-arch-action@d94c13912ea685de38fccc1109385b83fd79427d # v3.0.1 + - uses: uraimo/run-on-arch-action@460cb8e6d9f726a588fc9b5e681c8a6cab09ae41 # v3.2.0 name: Run tests id: build with: @@ -87,9 +87,12 @@ jobs: python3 \ python3-erfa \ python3-extension-helpers \ + python3-hypothesis \ python3-jinja2 \ python3-numpy \ - python3-pytest-astropy \ + python3-pytest-astropy-header \ + python3-pytest-cov \ + python3-pytest-remotedata \ python3-setuptools-scm \ python3-yaml \ python3-venv \ diff --git a/.github/workflows/ci_cron_weekly.yml b/.github/workflows/ci_cron_weekly.yml index bf49c9e27aaa..bc2fcee3f684 100644 --- a/.github/workflows/ci_cron_weekly.yml +++ b/.github/workflows/ci_cron_weekly.yml @@ -54,16 +54,19 @@ jobs: # that gives too many false positives due to URL timeouts. We also # install all dependencies via pip here so we pick up the latest # releases. - - name: Python 3.11 with dev version of key dependencies + # The intention here is to check our latest-supported Python with + # dev versions of third party dependencies + - name: Python 3.14 with dev version of key dependencies os: ubuntu-latest - python: '3.11' - toxenv: py311-test-devdeps + python: '3.14' + toxenv: py314-test-devdeps + # On why we exclude scipy, see # https://github.com/astropy/astropy/issues/15701 - - name: Python 3.11 with dev version of key dependencies without scipy + - name: Python 3.14 with dev version of key dependencies without scipy os: ubuntu-latest - python: '3.11' - toxenv: py311-test-devdeps-noscipy + python: '3.14' + toxenv: py314-test-devdeps-noscipy - name: Python 3.14 with dev version of infrastructure dependencies os: ubuntu-latest @@ -145,7 +148,7 @@ jobs: with: persist-credentials: false fetch-depth: 0 - - uses: uraimo/run-on-arch-action@d94c13912ea685de38fccc1109385b83fd79427d # v3.0.1 + - uses: uraimo/run-on-arch-action@460cb8e6d9f726a588fc9b5e681c8a6cab09ae41 # v3.2.0 name: Run tests id: build with: @@ -172,9 +175,12 @@ jobs: python3 \ python3-erfa \ python3-extension-helpers \ + python3-hypothesis \ python3-jinja2 \ python3-numpy \ - python3-pytest-astropy \ + python3-pytest-astropy-header \ + python3-pytest-cov \ + python3-pytest-remotedata \ python3-setuptools-scm \ python3-yaml \ python3-venv \ @@ -191,4 +197,8 @@ jobs: pip install -U --no-build-isolation pyerfa ASTROPY_USE_SYSTEM_ALL=1 pip install -v --no-build-isolation -e .[test] pip list + # A fresh CI runner has no ~/.astropy directory, but developer + # machines usually do. Create empty cache and config directories + # so we catch failures that only show up when they already exist. + python3 -c "from pathlib import Path; [(Path.home() / '.astropy' / d).mkdir(parents=True, exist_ok=True) for d in ('cache', 'config')]" python3 -m pytest --strict-markers --pyargs astropy -m "not hypothesis" diff --git a/.github/workflows/ci_workflows.yml b/.github/workflows/ci_workflows.yml index 5a615f98f421..09234e5cada8 100644 --- a/.github/workflows/ci_workflows.yml +++ b/.github/workflows/ci_workflows.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_dispatch: push: branches: - main @@ -69,7 +70,7 @@ jobs: tests: needs: [initial_checks] - uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@2835f0cacddf3f8de198db9afdb5354a5cebe0ef # v2.6.3 + uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@22ddf09dfabb443adddfb9f861b7d41a787d6b1a # v3.0.3 secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: @@ -136,7 +137,7 @@ jobs: allowed_failures: needs: [initial_checks] - uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@2835f0cacddf3f8de198db9afdb5354a5cebe0ef # v2.6.3 + uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@22ddf09dfabb443adddfb9f861b7d41a787d6b1a # v3.0.3 with: setenv: | ARCH_ON_CI: "normal" @@ -155,7 +156,7 @@ jobs: stub_tests: needs: [initial_checks] - uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@2835f0cacddf3f8de198db9afdb5354a5cebe0ef # v2.6.3 + uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@22ddf09dfabb443adddfb9f861b7d41a787d6b1a # v3.0.3 with: setenv: | ARCH_ON_CI: "normal" @@ -174,7 +175,7 @@ jobs: # This ensures that a couple of wheel targets work fine in pull requests and pushes permissions: contents: none - uses: OpenAstronomy/github-actions-workflows/.github/workflows/publish.yml@2835f0cacddf3f8de198db9afdb5354a5cebe0ef # v2.6.3 + uses: OpenAstronomy/github-actions-workflows/.github/workflows/publish.yml@22ddf09dfabb443adddfb9f861b7d41a787d6b1a # v3.0.3 with: upload_to_pypi: false upload_to_anaconda: false diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 25b5122a9374..053b608ede2c 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -12,6 +12,7 @@ name: "CodeQL" on: + workflow_dispatch: schedule: # run every Wednesday at 6am UTC - cron: '0 6 * * 3' diff --git a/.github/workflows/open_actions.yml b/.github/workflows/open_actions.yml index ac9c0cc2d12a..ec40395fbd75 100644 --- a/.github/workflows/open_actions.yml +++ b/.github/workflows/open_actions.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Label PR - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0 if: | github.event_name == 'pull_request_target' && github.event.pull_request.user.login != 'meeseeksmachine' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5d16d356d333..d3d959d15ebd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,7 +26,7 @@ jobs: # or if triggered manually via the workflow dispatch, or for a tag. permissions: contents: none - uses: OpenAstronomy/github-actions-workflows/.github/workflows/publish.yml@2835f0cacddf3f8de198db9afdb5354a5cebe0ef # v2.6.3 + uses: OpenAstronomy/github-actions-workflows/.github/workflows/publish.yml@22ddf09dfabb443adddfb9f861b7d41a787d6b1a # v3.0.3 if: | github.repository == 'astropy/astropy' && ( startsWith(github.ref, 'refs/tags/v') || @@ -91,4 +91,4 @@ jobs: pattern: dist-* path: dist - name: Upload to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/.github/workflows/sphinx_rc_workflows.yml b/.github/workflows/sphinx_rc_workflows.yml index 662170a467fe..5b3ff0df6cf8 100644 --- a/.github/workflows/sphinx_rc_workflows.yml +++ b/.github/workflows/sphinx_rc_workflows.yml @@ -13,7 +13,7 @@ permissions: jobs: tests: - uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@2835f0cacddf3f8de198db9afdb5354a5cebe0ef # v2.6.3 + uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@22ddf09dfabb443adddfb9f861b7d41a787d6b1a # v3.0.3 secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: diff --git a/.github/workflows/update_astropy_iers_data_main.yml b/.github/workflows/update_astropy_iers_data_main.yml index 950ef7e2b9e9..679be5b1af1d 100644 --- a/.github/workflows/update_astropy_iers_data_main.yml +++ b/.github/workflows/update_astropy_iers_data_main.yml @@ -30,7 +30,7 @@ jobs: - branch: "v7.2.x" steps: - name: Trigger workflow dispatch for auto-update - uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1 + uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2 with: workflow: "Auto-update astropy-iers-data minimum version" ref: "${{ matrix.branch }}" diff --git a/.github/workflows/update_credits.yml b/.github/workflows/update_credits.yml index 219e3ad41e5d..b5db51fcf209 100644 --- a/.github/workflows/update_credits.yml +++ b/.github/workflows/update_credits.yml @@ -11,7 +11,9 @@ on: - '.github/workflows/update_credits.yml' - '.github/workflows/update_credits.md' - 'scripts/update-credits.py' + - 'scripts/update-latest-whatsnew-stats.py' - 'docs/credits.rst' + - 'docs/whatsnew/*.rst' - '.mailmap' workflow_dispatch: @@ -62,3 +64,30 @@ jobs: --body-file .github/workflows/update_credits.md env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Smoke test for the What's New stats script: check that the latest What's New + # page is discoverable and still has the sentinel markers the script writes + # into. The live counts need the previous release tag (only present on a + # release branch), so this validates structure only. Not run on the cron. + check-whatsnew-stats: + permissions: + contents: read + name: Check What's New stats script + runs-on: ubuntu-latest + if: github.event_name != 'schedule' + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 # needed for the contributor history and release tags + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: 3.x + - name: Check the latest What's New page + run: pipx run scripts/update-latest-whatsnew-stats.py --check + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Show resulting diff + run: git diff docs/whatsnew/ diff --git a/.mailmap b/.mailmap index e707ced0f6f8..8cc146c0d09f 100644 --- a/.mailmap +++ b/.mailmap @@ -115,6 +115,7 @@ Edward Gomez Edward Slavich Eduardo Olinto <90293761+olintoeduardo@users.noreply.github.com> Eero Vaher +Eesh Saxena Elijah Bernstein-Cooper Emily Deibert Emma Hogan @@ -272,6 +273,7 @@ Matthieu Baumann Matthieu Bec Mavani Bhautik +Mengjia Shang Michael Belfrage <216956+mikez@users.noreply.github.com> Michael Brewer Michael Brewer diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ba4b50e1a340..0f7519406c2e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,6 +38,10 @@ repos: - id: end-of-file-fixer # Makes sure files end in a newline and only a newline. exclude: ".*(data.*|extern.*|licenses.*|_static.*|_parsetab.py)$" + - id: mixed-line-ending + args: ["--fix=lf"] + # Forces all text files to use LF line endings. + exclude: ".*(data.*|extern.*|licenses.*|_static.*|_parsetab.py)$" # - id: fix-encoding-pragma # covered by pyupgrade - id: trailing-whitespace # Trims trailing whitespace. diff --git a/.pyinstaller/run_astropy_tests.py b/.pyinstaller/run_astropy_tests.py index 74fbf1a32305..f2f1665f739e 100644 --- a/.pyinstaller/run_astropy_tests.py +++ b/.pyinstaller/run_astropy_tests.py @@ -119,7 +119,7 @@ pytest.main( ["astropy_tests", "-k " + " and ".join("not " + test for test in SKIP_TESTS)], plugins=[ - "pytest_astropy.plugin", + "pytest_skip_slow", "pytest_doctestplus.plugin", "pytest_remotedata.plugin", "pytest_astropy_header.display", diff --git a/CHANGES.rst b/CHANGES.rst index bf67d6b6d350..f562dea8d6c9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,618 @@ +Version 8.0.1 (2026-07-03) +========================== + +API Changes +----------- + +astropy.utils +^^^^^^^^^^^^^ + +- Astropy will now no longer download the latest IERS-A table over the network unless it is actually needed, and will also not warn about download issues unless all mirrors fail. The ``IERS_Auto.open`` method now always reads the table bundled in ``astropy-iers-data`` (or a ``finals2000A.all`` file in the current working directory, if present), and the auto-download is deferred until a calculation actually requests ``UT1-UTC`` or polar motion values beyond the predictive range of the bundled table while that table is older than ``astropy.utils.iers.conf.auto_max_age`` days. This avoids network access, and the associated warnings when offline, in the common case where the bundled table is recent enough. [#19995] + + +Bug Fixes +--------- + +astropy.io.fits +^^^^^^^^^^^^^^^ + +- Fixed a regression where a FITS logical (``'L'``) column created with + ``BinTableHDU.from_columns(..., nrows=N)`` and no input array stored its + values as NULL (``b'\x00'``) instead of False (``b'F'``). Rows later + assigned ``False`` were silently written as NULL, and a spurious + ``contains NULL`` warning was emitted when reading the file back. [#19939] + +- Fixed a bug where a byte other than the FITS L wire-format values ``b'T'``, + ``b'F'`` and ``b'\x00'`` assigned into a fixed-length logical (``'L'``) column + read with ``logical_as_bytes=True`` was written to the file unchecked (and + silently read back as ``False``). Such bytes now raise ``ValueError`` on write, + matching the validation applied to ``|S1`` column input at construction time. [#19940] + +- Fixed an incorrect ``DATASUM`` (and therefore ``CHECKSUM``) being written for + byte-swapped binary tables larger than 64 kB. The data was checksummed in + chunks that were not aligned to 4-byte word boundaries, so the stored value was + valid in memory but failed to verify once the file was read back. [#20001] + +astropy.modeling +^^^^^^^^^^^^^^^^ + +- Bugfix for a ``Parameter`` with a custom ``getter`` / ``setter`` pair raising an + ``AttributeError`` when accessing the ``value`` of the ``Parameter`` before any + value for the parameter has been set. The ``Parameter`` will now return the result + of the ``getter`` applied to a ``nan`` value in this case which is consistent + with the behavior of a ``Parameter`` without a custom ``getter`` / ``setter`` pair. [#19979] + +astropy.nddata +^^^^^^^^^^^^^^ + +- Fix unexpected behavior in ``Cutout2D.plot_on_original()``. The displayed region spanned pixels not included in the cutout array. [#19829] + +astropy.table +^^^^^^^^^^^^^ + +- Fixed a bug where a ``Table.loc[]`` range query using the default + ``SortedArray`` index engine silently dropped rows when the upper bound + matched a value that appears more than once in the indexed column. [#19903] + +- Fixed a bug where a ``Table.loc[]`` range query using the ``BST`` index + engine returned rows in scrambled order if rows had been added after the + index was created. [#19912] + +astropy.units +^^^^^^^^^^^^^ + +- Fixed a bug where ``numpy.lib.recfunctions.structured_to_unstructured`` failed when applied to a ``StructuredQuantity`` by improving unit extraction logic. [#19106] + +astropy.visualization +^^^^^^^^^^^^^^^^^^^^^ + +- Fixed issues with the placement of WCSAxes tick labels for non-perpendicular grids or ticks. [#19057] + +astropy.wcs +^^^^^^^^^^^ + +- Fix caching of ``wcs.world_axis_object_components`` and ``world_axis_object_classes`` in cases where the equinox was NaN [#19898] + + +Version 8.0.0 (2026-06-16) +========================== + + +New Features +------------ + +astropy.config +^^^^^^^^^^^^^^ + +- Added ``astropy.config.temporary_cache_dir_path`` and + ``astropy.config.temporary_config_dir_path`` context managers, which are + safer alternatives to ``astropy.config.set_temp_cache`` and + ``astropy.config.set_temp_config`` respectively, and should be preferred in + new code, but are not drop-in replacements. + + Added support for ``ASTROPY_CACHE_DIR`` and ``ASTROPY_CONFIG_DIR`` + environment variables, offering a more tightly scoped alternative to + ``XDG_CACHE_HOME`` and ``XDG_CONFIG_HOME`` respectively. + When both are defined, ``ASTROPY_`` -prefixed variables take precedence + over ``XDG_`` -prefixed ones. [#19575] + +- Added a ``ensure_exists`` boolean option to cache and config path getters, allowing + callers to disable directory creation on discovery with ``ensure_exists=False``. + When False, callers should handle missing directory on their own. [#19616] + +astropy.cosmology +^^^^^^^^^^^^^^^^^ + +- The ``angular_diameter_distance`` method now accepts two redshift arguments + to compute the angular diameter distance between objects at different redshifts. + The previous separate method ``angular_diameter_distance_z1z2`` is deprecated + and will be removed in a future version. [#18807] + +- The trait ``astropy.cosmology.traits.NeutrinoComponent`` has been added to work with objects that have attributes and methods related to neutrinos. [#18809] + +- Removed deprecated module-level shim files (``astropy.cosmology.connect``, + ``astropy.cosmology.core``, ``astropy.cosmology.flrw``, ``astropy.cosmology.funcs``, and + ``astropy.cosmology.parameter``), which were deprecated in v7.1. All cosmology classes + and functions should be imported directly from ``astropy.cosmology``. [#18856] + +- Registered the ``read_mrt()`` and ``write_mrt()`` methods with the Cosmology class, + enabling the import and export of cosmological data to and from MRT files. [#18952] + +astropy.io.ascii +^^^^^^^^^^^^^^^^ + +- Read CDS table where the data is split into multiple files [#18505] + +- Add a new ``io.ascii`` format ``mesa`` to read history and profile output files from + `MESA `_, a well-known code for stellar evolution + calculations. [#19125] + +astropy.io.fits +^^^^^^^^^^^^^^^ + +- Added a ``logical_as_bytes`` parameter to ``fits.open()`` that, when set to + ``True``, causes FITS logical columns to be read as bytes (``S1``) instead of + ``bool``, preserving NULL (undefined) values that would otherwise be silently + converted to ``False``. Columns read this way round-trip correctly when written + back to a FITS file, preserving ``b'T'``, ``b'F'``, and ``b'\x00'`` (NULL) + values. A warning is now emitted when NULL values are present and + ``logical_as_bytes`` is not set. [#19374] + +- Extended the ``logical_as_bytes`` option of ``fits.open()`` to apply to + variable-length array (``PL``/``QL``) logical columns. When ``True``, + VLA logical columns are returned as ``S1`` byte arrays (one byte per + entry) so that ``b'\x00'`` (NULL) is distinguishable from ``b'F'`` + (False); ``S1`` row arrays also round-trip through writing. A warning + is emitted when NULL bytes are present in a VLA logical column read + with ``logical_as_bytes=False``, except for the ambiguous case of a + column whose heap is entirely ``\x00`` (which may instead be an + all-False column written by astropy <= 7.2.0). [#19659] + +astropy.io.votable +^^^^^^^^^^^^^^^^^^ + +- Added a ``write`` method to ``VOTableFile``. [#19499] + +astropy.nddata +^^^^^^^^^^^^^^ + +- Implemented support for the ``.flags`` attribute in + ``CCDData.to_hdu()``, enabling the CCDData class to write and read + ``.flags`` in FITS format. + Previously this was silent no-op even though it should have raised a ``NotImplementedError``. [#18862] + +astropy.table +^^^^^^^^^^^^^ + +- Support writing table indices to FITS, HDF5, and ECSV formats and subsequently reading + them back to round-trip the original indexed table. This stores the index data as + temporary columns in the output, similar to the mechanism for serializing mixin columns + like ``SkyCoord``. [#18703] + +- Added ``conf.multidim_threshold`` configuration option to control display of multidimensional table columns. Setting this to a value like 3 will display full content for small arrays (e.g., ``[1 2 3]`` for 3-vectors), while larger arrays revert to the abbreviated ``first .. last`` format. [#19123] + +- Enable conversion of tables with multidimensional columns to pandas DataFrames: + + * Previously, ``to_pandas()`` and ``to_df()`` would raise a ``ValueError`` for columns containing lists or arrays (for example, ``[[1, 2], [3, 4]]``). + * Now these columns are automatically converted to 1D object arrays compatible with pandas. [#19173] + +astropy.timeseries +^^^^^^^^^^^^^^^^^^ + +- Adds support for Low Rank Approximation (LRA) to ``trig_sum``. + This is enabled by passing the new argument ``algorithm='lra'``, + which is now the default option for ``fast`` and + ``fastchi2`` implementations. [#17842] + +astropy.units +^^^^^^^^^^^^^ + +- Add support for ``atol`` and ``rtol`` parameters from ``np.matrix_rank`` + when called on a ``Quantity`` object. [#19104] + +- Allow 1d vector-like strings, with or without units, to be converted into ``Quantity``. [#19214] + +astropy.utils +^^^^^^^^^^^^^ + +- ``astropy.utils.data`` functions are now more resilient + against a missing cache directory and avoid forcing its + creation on call. [#19650] + +- Improved error messages from ``get_free_space_in_dir``. [#19795] + +astropy.visualization +^^^^^^^^^^^^^^^^^^^^^ + +- Expose ``MplQuantityConverter`` class in ``astropy.visualization`` and + except numpy arrays in its ``convert`` and ``default_units`` methods. [#19539] + +- Added the ``imshow_simple_norm`` function to provide a more compact interface + to the ``SimpleNorm``/``simple_norm`` interface for quick visualization. [#19623] + +astropy.wcs +^^^^^^^^^^^ + +- ``world_to_pixel`` and ``pixel_to_world`` now can take ``Masked`` input. [#18743] + + +API Changes +----------- + +astropy.config +^^^^^^^^^^^^^^ + +- The ``ConfNamespace.help`` method now raises an exception if called under + Python's optimized mode (``-OO`` flag). [#17571] + +- Cache and configuration directories now adhere to the XDG specification + by default. For example, the default cache location was changed from + ``$HOME/.astropy/cache`` to ``$XDG_CACHE_HOME/astropy``, where + ``XDG_CACHE_HOME`` itself defaults to ``$HOME/.cache``. + + Affected functions from the ``astropy.config`` namespace: + + - ``get_cache_dir`` + - ``get_cache_dir_path`` + - ``get_config_dir`` + - ``get_config_dir_path`` + + In addition, temporary directories set through ``set_temp_cache`` + or ``set_temp_config`` will now not be symlinked to default locations. + The new behavior is also meant as 100% cross platform: Windows isn't + special cased like it used to. [#19575] + +astropy.constants +^^^^^^^^^^^^^^^^^ + +- CODATA 2022 has replaced CODATA 2018 as default. ``astropyconst80`` is available for ``constants`` science state, combining CODATA 2022 with IAU 2015. [#18407] + +astropy.convolution +^^^^^^^^^^^^^^^^^^^ + +- Fixes edge treatment in ``convolve(..., boundary='fill', fill_value=nan)`` to be consistent with + ``convolve_fft``. [#18348] + +astropy.coordinates +^^^^^^^^^^^^^^^^^^^ + +- ``Galactocentric`` and ``LSR`` now use a ``CartesianRepresentation`` instead + of ``CartesianDifferential`` for their velocity attributes (``galcen_v_sun`` + and ``v_bary``, resp.). On input, this has little effect, since instances of + both remain accepted, but for converting the attribute to a ``Quantity``, one + now has to access the ``.xyz`` attribute instead of ``.d_xyz``. [#18362] + +astropy.cosmology +^^^^^^^^^^^^^^^^^ + +- Redshift arguments (``z``, ``z1``, ``z2``) in cosmology methods are now + positional-only, completing the deprecations started in v7.0 and v7.1. [#18800] + +- The ``Ob0=None`` parameter value for cosmology classes is no longer supported. + This deprecation was started in v7.0. Use ``Ob0=0`` instead to indicate zero + baryonic matter density. [#18874] + +astropy.io.fits +^^^^^^^^^^^^^^^ + +- ``np.char.chararray`` being deprecated in Numpy 2.5, in a future version + ``io.fits`` will return a normal array instead of a ``chararray`` for string + columns. As a consequence the special chararray methods are deprecated (e.g., + ``.rstrip()`` or ``.decode()``). Use ``np.strings`` functions instead. [#19267] + +- The ``strip_spaces`` option in ``Table.read`` to strip trailing whitespaces in + string columns is now True by default. Set it to False to keep the old behavior. [#19638] + +astropy.io.registry +^^^^^^^^^^^^^^^^^^^ + +- The ``help`` methods from ``Table.read`` and ``Table.write`` now raise an + exception if called under Python's optimized mode (``-OO`` flag). [#17571] + +astropy.io.votable +^^^^^^^^^^^^^^^^^^ + +- DataOrigin updated with IVOA version 1.2: + + * Terms changed in ``astropy.io.votable.dataorigin`` (``ivoid`` and ``editor`` renamed to ``ivoid_data`` and ``journal``) [#19593] + +astropy.samp +^^^^^^^^^^^^ + +- The whole ``astropy.samp`` module is deprecated. Please use ``pyvo.samp`` instead. [#19373] + +astropy.table +^^^^^^^^^^^^^ + +- The private ``astropy.table.np_utils`` module has been removed. It did not + contain any code that was still used except the definition of + ``TableMergeError``, which should be imported from ``astropy.table``. All + other routines were long ago moved to either ``astropy.table.operations`` or + ``astropy.utils.metadata``. [#19198] + +- Setting the ``Column.dtype`` attribute is now deprecated since mutating + an array is unsafe if an array is shared, especially by multiple + threads. As an alternative, you can create a view with a new dtype + via ``column.view(dtype=new_dtype)``. This follows a similar + deprecation in numpy 2.5.0. [#19542] + +astropy.tests +^^^^^^^^^^^^^ + +- API changes towards a future removal of astropy test runner: + + * Removed ``astropy.tests.command`` module that was deprecated in v6.0. + * Officially deprecated ``astropy.test``, ``astropy.tests.runner.TestRunnerBase``, and ``astropy.tests.runner.TestRunner`` (previously pending deprecation). This will also affect downstream ``packagename.test`` generated using ``TestRunner``. The deprecated functionality will be removed in a future release after a deprecation period as per Astropy deprecation policy. [#17883] + +astropy.utils +^^^^^^^^^^^^^ + +- The ``astropy.utils.iers.IERS.time_now`` property is deprecated. + The ``astropy.time.Time.now()`` function can be used as a replacement. [#19060] + +- Cache directories are not created on discovery anymore. Meaning some functions + that only retrieve a file or directory by name and without populating it won't + guarantee that the path returned already exists. [#19631] + +- Removed deprecated functions ``is_path_hidden`` and ``walk_skip_hidden`` + from ``astropy.utils.misc``. [#19679] + +astropy.visualization +^^^^^^^^^^^^^^^^^^^^^ + +- Removed the deprecated ``min_cut`` and ``max_cut`` keywords from the + ``simple_norm`` function and the ``fits2bitmap`` command-line script. + Use the ``vmin`` and ``vmax`` keywords, respectively, to specify the cut + levels. [#18994] + +astropy.wcs +^^^^^^^^^^^ + +- ``SpectralCoord`` conversions through the APE 14 high-level WCS API no + longer emit a warning when neither the WCS nor the input ``SpectralCoord`` + has an observer defined. Warnings are still emitted when only one of the + two has observer information (and the conversion therefore proceeds + without a velocity frame change). [#18902] + +- ``wcs.high_level_objects_to_values`` and ``wcs.values_to_high_level_objects`` + no longer require their ``low_level_wcs`` argument to be a full + ``astropy.wcs.wcsapi.BaseLowLevelWCS`` instance. Any object exposing + ``world_axis_object_classes`` and ``world_axis_object_components`` attributes + is accepted, and ``serialized_classes`` is now optional (treated as ``False`` + if absent). [#19064] + + +Bug Fixes +--------- + +astropy.config +^^^^^^^^^^^^^^ + +- Disabling thread concurrency within ``set_temp_cache`` and ``set_temp_config`` + context managers, ensuring thread safety. [#19559] + +astropy.convolution +^^^^^^^^^^^^^^^^^^^ + +- ``convolve()`` and ``convolve_fft()`` now raise exceptions if the kernel is a + masked array with masked values. [#18363] + +astropy.coordinates +^^^^^^^^^^^^^^^^^^^ + +- ``Galactocentric`` and ``LSR`` now raise an error if their velocities are + initialized with something that does not have velocity units. [#18362] + +- The ``refresh_cache`` parameter of ``EarthLocation.of_site()`` and + ``EarthLocation.get_site_names()`` methods now works. [#18687] + +- Relativistic Doppler shifts are now applied to ``SpectralCoord`` in the correct + direction even if the ``astropy.units.spectral()`` equivalency is enabled. + Previously enabling the equivalency could cause wrong results with the + ``SpectralCoord.to_rest()``, + ``SpectralCoord.with_observer_stationary_relative_to()`` or + ``SpectralCoord.with_radial_velocity_shift()`` methods. [#19001] + +- Fixed a broadcasting bug in ``astropy.uncertainty.distributions.uniform`` where + multi-dimensional inputs (``ndim >= 2``) would raise a ``ValueError``. [#19308] + +- Send a User-Agent header to the OpenStreetMap API in ``EarthLocation.of_address`` + to fix access denied errors. [#19330] + +- Fixed a missing f-string in a TypeError from ``BaseAffineTransform._apply_transform`` that caused ``{data.__class__}`` to appear literally in the error message. [#19403] + +astropy.io.ascii +^^^^^^^^^^^^^^^^ + +- Modify ECSV reader to handle header "meta" entries that are not marked with the + "!!omap" tag, but are otherwise valid YAML types allowed by the ECSV specification. [#19313] + +astropy.io.fits +^^^^^^^^^^^^^^^ + +- Fix ``getdata()``'s lower and upper keywords. [#19023] + +- Fix use of TNULL with float columns in ASCII tables. Undefined values in float + columns are now replaced by NaNs (instead of 0). [#19025] + +- Fix for ``fsspec`` configuration in ``fits.open`` for remote FITS files. ``fits.open`` + now accepts the keyword argument ``fsspec_filesystem``, which is used to configure, + e.g., the buffer block size. Remote FITS file objects are then opened with + ``fsspec.filesystem.open``. [#19294] + +- Fix a bug which caused CompImageHDU to have both SIMPLE and XTENSION keywords when initialized from a PrimaryHDU header. [#19362] + +- Fix support for reading FITS files using image tile compression with UNCOMPRESSED_DATA columns. [#19363] + +- Fixed a bug that caused reading FITS files to not work properly on WASM when relying on the default memmap settings. [#19367] + +- Fixed silent data corruption in ``FITS_rec.__setitem__`` when using negative + slice indices. Assigning to slices like ``data[-2:] = new_rows`` previously + wrote to the wrong rows because negative indices were clamped to 0 instead of + being resolved relative to the array length. [#19404] + +- Fixed a bug that caused compressed FITS files to become corrupted when opened in update mode and modifying the header. [#19416] + +- Fix a bug that caused header verification for CompImageHDU to not work correctly and + in some cases produce corrupt files when the decompressed header was a primary HDU + header not an extension header. [#19438] + +- Prevent creating RICE_1 or PLIO_1 compressed files with (u)int64 data. If + possible without overflow data is converted to (u)int32, otherwise an error is + raised. [#19592] + +- Fix variable-length array logical (``PL``/``QL``) columns being written as 1/0 bytes instead of FITS T/F and read back as raw int8 (84/70) instead of bool. Files written by astropy <= 7.2.0 (which used the legacy 0x00/0x01 encoding) are still readable: such files are detected on read and decoded correctly with an ``AstropyUserWarning``. [#19629] + +- Fix a bug that caused uint64 data to be silently shifted by 2**63 when + round-tripping through compressed FITS files (the FITS BZERO=2**63 offset was + missing at compression time but still applied on read). Extend the existing + RICE_1 and PLIO_1 64-bit-to-32-bit conversion fallback to also cover uint64 + input and HCOMPRESS_1, and fix big-endian 64-bit input being silently truncated + by the conversion check instead of raising. Reject PLIO_1 with unsigned + multi-byte input outright, since the BZERO convention used to store unsigned + FITS data produces negative values that PLIO cannot encode. [#19670] + +- Fix a bug that caused int8 data to be shifted by 128 when round-tripping + through compressed FITS files (the FITS BZERO=-128 offset was missing at + compression time but still applied on read), so writing ``[0, 1]`` and + reading back returned ``[-128, -127]``. [#19738] + +- Added elif to ensure we only write ``ZSIMPLE`` or ``ZTENSION`` as they are + mutually exclusive. Fixed the ordering of the compressed hdu header + keywords so that the ``TFORM`` and ``TTYPE`` columns always come directly after ``TFIELDS``. [#19771] + +astropy.io.misc +^^^^^^^^^^^^^^^ + +- Fixed reading parquet files written by pandas 3.0+, where string columns are + encoded as ``large_string`` rather than ``string`` and previously round-tripped + to ``object`` dtype instead of the expected fixed-width Unicode dtype. [#19658] + +astropy.modeling +^^^^^^^^^^^^^^^^ + +- Bugfix for ``Parameter.value`` accessor when the parameter value has not been set yet. [#19189] + +astropy.nddata +^^^^^^^^^^^^^^ + +- Fixed ``CCDData.read()`` logging a spurious info message when the unit passed by the caller matches the BUNIT value in the FITS header. [#19389] + +astropy.samp +^^^^^^^^^^^^ + +- Fixed an XML External Entity (XXE) vulnerability in SAMP XML-RPC communication by disabling external entity resolution. [#19231] + +astropy.stats +^^^^^^^^^^^^^ + +- Fixed pickling of ``SigmaClip`` objects when Bottleneck is installed. ``_dtype_dispatch`` returned a local closure which cannot be pickled; replaced with a picklable ``_DtypeDispatch`` class. [#19372] + +- Fixed missing pointer dereference in ``fast_sigma_clip.c`` that caused ``mad_buffer`` to be allocated regardless of the ``use_mad_std`` value. [#19457] + +astropy.table +^^^^^^^^^^^^^ + +- Tables can now be joined and stacked also if they contain columns with + user-defined data types such as ``QuadPrecDtype``. [#19199] + +- Fixed ``AssertionError`` when passing ``numpy.bool_`` as the + ``index`` argument to ``Table.to_pandas()`` or ``Table.to_dataframe()``. + The correct ``ValueError`` is now raised instead. [#19358] + +- Fixed table index getting corrupted when a row assignment raises an exception + mid-update. The index is now properly restored to its original state on failure. [#19450] + +- Fixes a problem where deepcopying a MaskedColumn did not correctly create the ``info`` + attribute. This resulted in an inability to print the column after the deepcopy. [#19466] + +astropy.time +^^^^^^^^^^^^ + +- Fixed missing ``goto fail`` in ``create_parser`` in ``parse_times.c`` after setting a ``ValueError`` for invalid parameter array size, preventing execution from continuing with an exception already set. [#19368] + +astropy.units +^^^^^^^^^^^^^ + +- Fixed a bug in the ``np.average`` function when weights with units and a + different shape to the input array were passed and the optionally-returned sum + of the weights was requested. The sum of the weights now has correct units. [#19055] + +- Fixed incorrect unit returned by ``numpy.diff`` when applied to logarithmic quantities (e.g., magnitudes). [#19360] + +astropy.utils +^^^^^^^^^^^^^ + +- ``AttributeError`` will no longer occur when attempting to pickle an unbound ``DataInfo`` instance. [#19141] + +- Pickling ``Masked`` subclasses with an initialized ``info`` attribute no longer fails. [#19142] + +- ``ShapedLikeNDArray.take()`` now raises ``NotImplementedError`` when ``out`` is passed, instead of returning the exception object. [#19351] + +- Ensure that ``utils.masked.get_data_and_mask`` works properly with containers, + returning ``None`` for the mask if ``masked`` is not set (instead of returning + an all-False array). [#19534] + +- The dummy file object used by ``astropy.utils.misc.silence`` to replace + ``sys.stdout``/``sys.stderr`` now implements ``flush()`` and ``isatty()``, + so importing libraries that probe the stream (e.g. IPython 9.13 at import + time) no longer raises ``AttributeError`` under ``silence``. [#19594] + +- Fixed a bug where values returned by ``cache_contents`` could include files and symlinks, instead of just directories. [#19672] + +- Add missing ``stacklevel`` arguments in warnings emitted from ``astropy.utils.data`` APIs [#19700] + +astropy.visualization +^^^^^^^^^^^^^^^^^^^^^ + +- Fix get_coords_overlay to prevent AstropyDeprecationWarnings for non-rectangular frames. [#19801] + +astropy.wcs +^^^^^^^^^^^ + +- Fixed a bug where degree units were hardcoded into the FITS WCS APE 14 ``world_to_pixel`` method. [#19506] + +- Lazily-populated caches on a ``WCS`` (such as the internal + ``world_axis_object_components``/``world_axis_object_classes`` cache) are no + longer included in the pickled state. They are regenerated on demand after + unpickling, which keeps pickling robust even when a cache entry holds a + non-picklable value. + + Fixed a bug where the ``preserve_units`` option passed to the ``WCS`` + constructor was silently reset to ``False`` when a ``WCS`` object was pickled + and unpickled. [#19591] + +- Fix reference-count handling after ``PyList_SetItem`` steals references. [#19720] + +- Made the WCS coordinate transform paths (``pixel_to_world``, ``world_to_pixel``, + ``all_pix2world``, ``wcs_pix2world``, ``wcs_world2pix``, ``mix``) safe to call + concurrently on a shared ``WCS`` instance from multiple threads. [#19819] + + +Performance Improvements +------------------------ + +astropy.io.votable +^^^^^^^^^^^^^^^^^^ + +- Improve performance of Binary parsing by moving converters to Cython + + * **Numeric operations**: 47-57% faster + * **Mixed data types**: 37-45% faster + * **String operations**: 24-35% faster + * **Boolean fields**: 10-20% faster + * **Small overhead operations**: 29-43% faster + + Performance gains are consistent across dataset sizes from 200k to 1M rows. + The most substantial improvements are seen in numeric types with up to 50-60% reduction in processing time. [#18454] + +astropy.stats +^^^^^^^^^^^^^ + +- Vectorized the ``var-width`` mode in ``RipleysKEstimator`` by precomputing the + full distance matrix, replacing a triple nested Python loop. This yields + speedups of several thousand times for typical input sizes. [#19498] + + +Other Changes and Additions +--------------------------- + +- Development-only dependencies, previously defined as user-visible + extras ``dev`` and ``dev_all``, were moved to PEP 735 dependency groups, and + are thus only accessible when building astropy from source. [#18342] + +- The minimum required NumPy version is now 1.25. [#18962] + +- The minimum supported version of numpy is now 2.0. [#18986] + +- Upgraded WCSLIB to version 8.5, fixing NaN handling in ``linp2x()`` and ``linx2p()``. For a full list of changes - see ``astropy/cextern/wcslib/CHANGES``. [#19050] + +- Publishing files to PyPI is now done using the Trusted Publisher mechanism (https://docs.pypi.org/trusted-publishers/). [#19347] + +- Upgraded WCSLIB to version 8.6. For a full list of changes - see ``astropy/cextern/wcslib/CHANGES``. [#19514] + +- Updated the bundled CFITSIO library to 4.6.4. [#19662] + Version 7.2.0 (2025-11-25) ========================== diff --git a/CITATION.cff b/CITATION.cff index 5dc6ddcd5b87..bf449e408403 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -685,6 +685,7 @@ preferred-citation: issue: 2 pages: 20 year: 2022 + doi: 10.3847/1538-4357/ac7c74 identifiers: - description: ADS bibcode type: url diff --git a/LICENSE.rst b/LICENSE.rst index f09379c82ef5..5945355a7190 100644 --- a/LICENSE.rst +++ b/LICENSE.rst @@ -1,4 +1,4 @@ -Copyright (c) 2011-2024, Astropy Developers +Copyright (c) 2011-2026, Astropy Developers All rights reserved. diff --git a/astropy/config/paths.py b/astropy/config/paths.py index 257fe715e172..dbd818c07f71 100644 --- a/astropy/config/paths.py +++ b/astropy/config/paths.py @@ -240,24 +240,25 @@ def find_directory_elements(self, namespace: str) -> _DirectoryElements: case _ as unreachable: assert_never(unreachable) - return replace(de, base_node=self.default_base_node()) + # Default resolution. For backward compatibility, honor a legacy + # ~/./ directory if it exists and the new default + # location does not. This deliberately lives in the default branch so + # it cannot shadow an explicit override or environment variable. + default_de = replace(de, base_node=self.default_base_node()) + legacy_node = self.legacy_default_base_node(namespace) + if legacy_node.is_dir() and not default_de.join().exists(): + return _DirectoryElements(base_node=legacy_node) + return default_de def find_namespaced_node(self, namespace: str) -> Path: - legacy_node = self.legacy_default_base_node(namespace) - preferred_node = self.find_directory_elements(namespace).join() - if legacy_node.is_dir() and not preferred_node.exists(): - # backward compatibility - # legacy_node is checked more strictly, because we only need to bridge the - # gap for previously supported cases - return legacy_node - else: - # we intentionally let through some possibly invalid state, - # like a file occupying the preferred node, where we expect a directory, - # The core reason is that there'll always be a difference between the time - # we look up the location and the time we actually use it, so it's - # impossible to make the look up perfectly safe. In turn, the responsibility - # to raise an exception falls on the function that'll actually try use it. - return preferred_node + # we intentionally let through some possibly invalid state, + # like a file occupying the node where we expect a directory. + # The core reason is that there'll always be a difference between the + # time we look up the location and the time we actually use it, so it's + # impossible to make the look up perfectly safe. In turn, the + # responsibility to raise an exception falls on the function that'll + # actually try to use it. + return self.find_directory_elements(namespace).join() class _TempDirKwargs(TypedDict): @@ -389,7 +390,7 @@ def temporary_cache_dir_path( - all other arguments are keyword-only - ``delete`` is supported even on Python 3.11 - .. versionadded:: 8.0.0 + .. versionadded:: 8.0 Parameters ---------- @@ -448,7 +449,7 @@ def temporary_config_dir_path( - all other arguments are keyword-only - ``delete`` is supported even on Python 3.11 - .. versionadded:: 8.0.0 + .. versionadded:: 8.0 Parameters ---------- @@ -526,8 +527,8 @@ def get_cache_dir( This directory is typically ``$XDG_CONFIG_HOME/``, but can be overwritten with the ``ASTROPY_CONFIG_DIR`` environment variable, or with ``temporary_config_dir_path``. - - .. versionchanged:: 8.0.0 + {post_header} + .. versionchanged:: 8.0 In previous versions, the return value pointed to ``$HOME/.astropy/config`` by default. A new environment variable ``ASTROPY_CONFIG_DIR`` is now supported. Symlinks are no longer created in any situation. @@ -542,7 +543,7 @@ def get_cache_dir( Whether to create the directory (and its parents) if it's missing. Default: True - .. versionadded:: 8.0.0 + .. versionadded:: 8.0 """ _base_get_cache_doc = """\ Determines the cache directory associated with a namespace and, optionally, @@ -551,8 +552,8 @@ def get_cache_dir( This directory is typically ``$XDG_CACHE_HOME/``, but can be overwritten with the ``ASTROPY_CACHE_DIR`` environment variable, or with :func:`temporary_cache_dir_path`. - - .. versionchanged:: 8.0.0 + {post_header} + .. versionchanged:: 8.0 In previous versions, the return value pointed to ``$HOME/.astropy/cache`` by default. A new environment variable ``ASTROPY_CACHE_DIR`` is now supported. Symlinks are no longer created in any situation. @@ -567,7 +568,7 @@ def get_cache_dir( Whether to create the directory (and its parents) if it's missing. Default: True - .. versionadded:: 8.0.0 + .. versionadded:: 8.0 """ _ret_section = """ Returns @@ -577,11 +578,12 @@ def get_cache_dir( """ get_config_dir_path.__doc__ = cleandoc( - _base_get_config_doc + _ret_section.format(rettype="Path", dirtype="cache") + _base_get_config_doc.format(post_header="\n .. versionadded:: 7.0\n") + + _ret_section.format(rettype="Path", dirtype="cache") ) get_config_dir.__doc__ = cleandoc( - _base_get_config_doc + _base_get_config_doc.format(post_header="") + _ret_section.format(rettype="str", dirtype="cache") + """ See Also @@ -591,11 +593,12 @@ def get_cache_dir( ) get_cache_dir_path.__doc__ = cleandoc( - _base_get_cache_doc + _ret_section.format(rettype="Path", dirtype="cache") + _base_get_cache_doc.format(post_header="\n .. versionadded:: 7.0\n") + + _ret_section.format(rettype="Path", dirtype="cache") ) get_cache_dir.__doc__ = cleandoc( - _base_get_cache_doc + _base_get_cache_doc.format(post_header="") + _ret_section.format(rettype="str", dirtype="cache") + """ See Also @@ -702,7 +705,7 @@ class set_temp_config(_SetTempPath): Thread safety is guaranteed since astropy 7.2.1, but concurrency isn't: only a single thread at a time may execute code within this context. - .. versionchanged:: 8.0.0 + .. versionchanged:: 8.0 This function is soft-deprecated. It won't emit deprecation warnings but its use is discouraged for new code, as the exact behavior is hard to predict. Prefer :func:`temporary_config_dir_path` where available. @@ -787,7 +790,7 @@ class set_temp_cache(_SetTempPath): Thread safety is guaranteed since astropy 7.2.1, but concurrency isn't: only a single thread at a time may execute code within this context. - .. versionchanged:: 8.0.0 + .. versionchanged:: 8.0 This function is soft-deprecated. It won't emit deprecation warnings but its use is discouraged for new code, as the exact behavior is hard to predict. Prefer :func:`temporary_cache_dir_path` where available. diff --git a/astropy/config/tests/test_configs.py b/astropy/config/tests/test_configs.py index 16f71c1c268f..5e40cc80ed44 100644 --- a/astropy/config/tests/test_configs.py +++ b/astropy/config/tests/test_configs.py @@ -335,14 +335,21 @@ def test_env_variables_missing_dir(monkeypatch, tmp_path, dirtype): ) @pytest.mark.usefixtures("ignore_config_paths_global_state") def test_env_variables_missing_subdir_and_default( - monkeypatch, tmp_path, dirtype: _DirType, setup + monkeypatch, tmp_path_factory, dirtype: _DirType, setup ): + mock_home_dir = tmp_path_factory.mktemp("MOCK_HOME_LOCAL") + + def mock_home(): + return mock_home_dir + + monkeypatch.setattr(Path, "home", mock_home) + # have the env var point to a writable location, # where an 'astropy' subdir is missing, but can be created silently default_parent_dir = Path.home() / ".astropy" assert not default_parent_dir.exists() - target_dir = tmp_path + target_dir = tmp_path_factory.mktemp("target") env_var = f"XDG_{dirtype.name}_HOME" monkeypatch.setenv(env_var, str(target_dir)) diff --git a/astropy/conftest.py b/astropy/conftest.py index fd8267156a29..d5d8ecd5847b 100644 --- a/astropy/conftest.py +++ b/astropy/conftest.py @@ -84,14 +84,15 @@ def ignore_config_paths_global_state(monkeypatch, tmp_path_factory): pristine_config_finder, ) - # also mock $HOME as it's part of the global state taken into account - # for path detection - mock_home_dir = tmp_path_factory.mktemp("MOCK_HOME") + if "HOME" in os.environ: + # also mock $HOME as it's part of the global state taken into account + # for path detection + mock_home_dir = tmp_path_factory.mktemp("MOCK_HOME") - def mock_home(): - return mock_home_dir + def mock_home(): + return mock_home_dir - monkeypatch.setattr(Path, "home", mock_home) + monkeypatch.setattr(Path, "home", mock_home) yield @@ -105,14 +106,14 @@ def mock_home(): @pytest.fixture(scope="session", autouse=True) def _session_level_cache_dir(tmp_path_factory): - tmp_path = tmp_path_factory.mktemp("cache_") - os.environ["ASTROPY_CACHE_DIR"] = str(tmp_path) + os.environ["ASTROPY_CACHE_DIR"] = str(tmp_path_factory.mktemp("astropy_cache_")) + os.environ["XDG_CACHE_HOME"] = str(tmp_path_factory.mktemp("xdg_cache_")) @pytest.fixture(scope="session", autouse=True) def _session_level_config_dir(tmp_path_factory): - tmp_path = tmp_path_factory.mktemp("config_") - os.environ["ASTROPY_CONFIG_DIR"] = str(tmp_path) + os.environ["ASTROPY_CONFIG_DIR"] = str(tmp_path_factory.mktemp("astropy_config_")) + os.environ["XDG_CONFIG_HOME"] = str(tmp_path_factory.mktemp("xdg_config_")) def pytest_configure(config): diff --git a/astropy/coordinates/angles/formats.py b/astropy/coordinates/angles/formats.py index c7d9d9b170bf..2d075f6170fd 100644 --- a/astropy/coordinates/angles/formats.py +++ b/astropy/coordinates/angles/formats.py @@ -420,19 +420,23 @@ def _decimal_to_sexagesimal_string( # example, if the seconds will round up to 60, we should convert # it to 0 and carry upwards. If the field is hidden (by the # fields kwarg) we round up around the middle, 30.0. - rounding_thresh = 60.0 - (10.0 ** -(8 if precision is None else precision)) + # Builtin round, not NumPy's, which disagrees with the formatting on ties. + ndp = 8 if precision is None else precision - if fields == 3 and values[2] >= rounding_thresh: - values[2] = 0.0 - values[1] += 1.0 - elif fields < 3 and values[2] >= 30.0: - values[1] += 1.0 + if fields == 3: + if round(float(values[2]), ndp) >= 60.0: + values[2] = 0.0 + values[1] += 1.0 + elif fields == 2: + if values[2] >= 30.0: + values[1] += 1.0 + # Rounding the seconds into the minutes here too would round twice. + elif values[1] + values[2] / 60.0 >= 30.0: + values[0] += 1.0 if fields >= 2 and values[1] >= 60.0: values[1] = 0.0 values[0] += 1.0 - elif fields < 2 and values[1] >= 30.0: - values[0] += 1.0 literal = f"{np.copysign(values[0], sign):0{pad}.0f}{sep[0]}" if fields >= 2: diff --git a/astropy/coordinates/baseframe.py b/astropy/coordinates/baseframe.py index 3794b950ea14..805dff528594 100644 --- a/astropy/coordinates/baseframe.py +++ b/astropy/coordinates/baseframe.py @@ -39,6 +39,11 @@ NonRotationTransformationError, NonRotationTransformationWarning, ) +from .representation import ( + BaseDifferential, + BaseRepresentation, + BaseRepresentationOrDifferential, +) from .transformations import ( DynamicMatrixTransform, StaticMatrixTransform, @@ -437,13 +442,13 @@ class BaseCoordinateFrame(MaskableShapedLikeNDArray): where ``{lon}`` and ``{lat}`` are the frame names of the angular components. """ - default_representation: ClassVar[type[r.BaseRepresentation] | None] = None - default_differential: ClassVar[type[r.BaseDifferential] | None] = None + default_representation: ClassVar[type[BaseRepresentation] | None] = None + default_differential: ClassVar[type[BaseDifferential] | None] = None # Specifies special names and units for representation and differential # attributes. frame_specific_representation_info: ClassVar[ - dict[type[r.BaseRepresentationOrDifferential], list[RepresentationMapping]] + dict[type[BaseRepresentationOrDifferential], list[RepresentationMapping]] ] = {} frame_attributes: dict[str, Attribute] = {} diff --git a/astropy/coordinates/builtin_frames/cirs.py b/astropy/coordinates/builtin_frames/cirs.py index 9df0eb0ae9c2..598318a64b8e 100644 --- a/astropy/coordinates/builtin_frames/cirs.py +++ b/astropy/coordinates/builtin_frames/cirs.py @@ -29,6 +29,60 @@ class CIRS(BaseRADecFrame): """ A coordinate or frame in the Celestial Intermediate Reference System (CIRS). + CIRS is a geocentric reference system that serves as the *intermediate* + step between the quasi-inertial `~astropy.coordinates.ICRS` (fixed to + distant quasars) and the Earth-fixed `~astropy.coordinates.ITRS`. It was + introduced by the International Astronomical Union (IAU) 2000 and 2006 + resolutions to replace the older equinox-based "apparent place" systems + with a cleaner, higher-precision framework. + + **Definition** + + CIRS is defined by two fundamental concepts: + + * **Celestial Intermediate Pole (CIP)** — the pole of the system. Its + motion in the `~astropy.coordinates.GCRS` is described by the IAU 2006 + precession model combined with the IAU 2000A nutation model, giving + micro-arcsecond accuracy. + + * **Celestial Intermediate Origin (CIO)** — the origin of right ascension + in CIRS. Unlike the classical vernal equinox (which is defined by the + intersection of the Earth's equator and the ecliptic and therefore + "swings" with precession and nutation), the CIO is a *non-rotating + origin*: it is defined so that it has no rotation component around the + CIP. This cleanly separates the Earth's spin (described by the + **Earth Rotation Angle**, ERA) from its wobble (precession and nutation). + + **Relation to other frames** + + * **ICRS → CIRS**: applying the IAU precession-nutation model rotates the + ICRS axes to the intermediate (CIP/CIO) axes. Astropy performs this + via the ERFA ``apco``/``atciqz`` routines. + + * **CIRS → ITRS**: a single rotation by the Earth Rotation Angle (ERA) + around the CIP axis. ERA is a linear function of UT1 and replaces the + more complex "equation of the equinoxes" needed in the older + equinox-based systems. + + * **CIRS vs. GCRS**: both share the same pole (CIP) and the same + geocentric origin, but they differ in their right-ascension origin. + GCRS uses the equinox; CIRS uses the CIO. The angular offset between + the two origins along the intermediate equator is called the + *equation of the origins* (EO). + + **Astropy implementation** + + Astropy implements CIRS using the `ERFA + `_ library (the open-source version of + IAU SOFA), which provides the IAU 2006/2000A precession-nutation model. + The default ``location`` is the geocentre; supply an + `~astropy.coordinates.EarthLocation` to get the topocentric variant + (which additionally accounts for diurnal aberration). + + For more background see Section 2.6 of `USNO Circular 179 + `_ and the references in the + :ref:`astropy:astropy-coordinates-seealso` section of the documentation. + The frame attributes are listed under **Other Parameters**. """ diff --git a/astropy/coordinates/builtin_frames/galactocentric.py b/astropy/coordinates/builtin_frames/galactocentric.py index ca2d8ef05a97..3e4508e1f2ac 100644 --- a/astropy/coordinates/builtin_frames/galactocentric.py +++ b/astropy/coordinates/builtin_frames/galactocentric.py @@ -362,7 +362,7 @@ def register( plane. Unless you really know what this means, you probably should not change this! - .. versionchanged :: 8.0 + .. versionchanged:: 8.0 ``galcen_v_sun`` is now a `~astropy.coordinates.CartesianRepresentation` rather than a `~astropy.coordinates.CartesianDifferential`. diff --git a/astropy/coordinates/builtin_frames/lsr.py b/astropy/coordinates/builtin_frames/lsr.py index 59b9b3163643..0d80b6a5f923 100644 --- a/astropy/coordinates/builtin_frames/lsr.py +++ b/astropy/coordinates/builtin_frames/lsr.py @@ -33,7 +33,7 @@ The velocity of the solar system barycenter with respect to the LSR, in Galactic cartesian velocity components. - .. versionchanged :: 8.0 + .. versionchanged:: 8.0 ``v_bary`` is now a `~astropy.coordinates.CartesianRepresentation` rather than a `~astropy.coordinates.CartesianDifferential`. """ diff --git a/astropy/coordinates/matrix_utilities.py b/astropy/coordinates/matrix_utilities.py index 8678aaa988f1..c463ea31f277 100644 --- a/astropy/coordinates/matrix_utilities.py +++ b/astropy/coordinates/matrix_utilities.py @@ -215,7 +215,7 @@ def is_rotation(matrix, allow_improper=False, atol=None): See Also -------- - astopy.coordinates.matrix_utilities.is_O3 : + astropy.coordinates.matrix_utilities.is_O3 : For the less restrictive check that a matrix is in the group O(3). Notes diff --git a/astropy/coordinates/representation/base.py b/astropy/coordinates/representation/base.py index 66512bbc575d..495777ee3020 100644 --- a/astropy/coordinates/representation/base.py +++ b/astropy/coordinates/representation/base.py @@ -153,7 +153,7 @@ class BaseRepresentationOrDifferential(MaskableShapedLikeNDArray): """Name of the representation or differential. When a subclass is defined, by default, the name is the lower-cased name of the - class with with any trailing 'representation' or 'differential' removed. (E.g., + class with any trailing 'representation' or 'differential' removed. (E.g., 'spherical' for `~astropy.coordinates.SphericalRepresentation` or `~astropy.coordinates.SphericalDifferential`.) @@ -948,7 +948,7 @@ def represent_as(self, other_class, differential_class=None): # The default is to convert via cartesian coordinates new_rep = other_class.from_cartesian(self.to_cartesian()) else: - new_rep = self + new_rep = self.without_differentials() new_rep._differentials = self._re_represent_differentials( new_rep, differential_class diff --git a/astropy/coordinates/tests/data/sites/astropy/download/url/2a464ec894e62c3f19c6aa05f3df3fdc/url b/astropy/coordinates/tests/data/sites/astropy/download/url/2a464ec894e62c3f19c6aa05f3df3fdc/url deleted file mode 100644 index 7ecd138065ec..000000000000 --- a/astropy/coordinates/tests/data/sites/astropy/download/url/2a464ec894e62c3f19c6aa05f3df3fdc/url +++ /dev/null @@ -1 +0,0 @@ -http://data.astropy.org/coordinates/sites.json \ No newline at end of file diff --git a/astropy/coordinates/tests/data/sites/astropy/download/url/2a464ec894e62c3f19c6aa05f3df3fdc/contents b/astropy/coordinates/tests/data/sites/astropy/download/url/e0b0a015ce4cf06dd43a7004c20f2fb7/contents similarity index 100% rename from astropy/coordinates/tests/data/sites/astropy/download/url/2a464ec894e62c3f19c6aa05f3df3fdc/contents rename to astropy/coordinates/tests/data/sites/astropy/download/url/e0b0a015ce4cf06dd43a7004c20f2fb7/contents diff --git a/astropy/coordinates/tests/data/sites/astropy/download/url/e0b0a015ce4cf06dd43a7004c20f2fb7/url b/astropy/coordinates/tests/data/sites/astropy/download/url/e0b0a015ce4cf06dd43a7004c20f2fb7/url new file mode 100644 index 000000000000..486856fbf061 --- /dev/null +++ b/astropy/coordinates/tests/data/sites/astropy/download/url/e0b0a015ce4cf06dd43a7004c20f2fb7/url @@ -0,0 +1 @@ +http://www.astropy.org/astropy-data/coordinates/sites.json \ No newline at end of file diff --git a/astropy/coordinates/tests/test_formatting.py b/astropy/coordinates/tests/test_formatting.py index b210a528bfde..deae5064d716 100644 --- a/astropy/coordinates/tests/test_formatting.py +++ b/astropy/coordinates/tests/test_formatting.py @@ -152,6 +152,42 @@ def test_sexagesimal_rounding_up(): assert a.to_string(fields=1, precision=5) == "4d" +@pytest.mark.parametrize( + "angle, precision, expected", + [ + # Just below half a unit in the last digit shown: no carry. + ("1d2m59.49999s", 0, "1d02m59s"), + ("1d2m59.94s", 1, "1d02m59.9s"), + ("1d2m59.994s", 2, "1d02m59.99s"), + # At or above it: the seconds carry into the minutes, and on into + # the degrees. + ("1d2m59.5s", 0, "1d03m00s"), + ("1d2m59.96s", 1, "1d03m00.0s"), + ("1d59m59.5s", 0, "2d00m00s"), + ("-1d2m59.49999s", 0, "-1d02m59s"), + ], +) +def test_sexagesimal_seconds_round_at_half_a_unit(angle, precision, expected): + assert Angle(angle).to_string(precision=precision) == expected + + +@pytest.mark.parametrize( + "angle, expected", + [ + # Below half a degree of the next one: no carry. The seconds must not + # be rounded into the minutes first, or these would round twice. + ("285d29m41.76s", "285d"), + ("285d29m59.9s", "285d"), + ("-285d29m41.76s", "-285d"), + # Half a degree or more: carry. + ("285d30m", "286d"), + ("285d30m00.1s", "286d"), + ], +) +def test_sexagesimal_degrees_round_once(angle, expected): + assert Angle(angle).to_string(fields=1) == expected + + def test_to_string_scalar(): a = Angle(1.113355, unit=u.deg) assert isinstance(a.to_string(), str) diff --git a/astropy/coordinates/tests/test_frames.py b/astropy/coordinates/tests/test_frames.py index 8c8c85302c9a..bcb931730858 100644 --- a/astropy/coordinates/tests/test_frames.py +++ b/astropy/coordinates/tests/test_frames.py @@ -1,6 +1,7 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst import re +import typing from copy import deepcopy import numpy as np @@ -40,6 +41,7 @@ CartesianDifferential, ) from astropy.coordinates.tests.helper import skycoord_equal +from astropy.tests.helper import CI, IS_CRON from astropy.tests.helper import assert_quantity_allclose as assert_allclose from astropy.time import Time from astropy.units import allclose @@ -1778,3 +1780,16 @@ def test_insert(): # Insert length=2 array at end of array c = c0.insert(2, c3) assert skycoord_equal(c, ICRS([1, 2, 10, 20] * u.deg, [3, 4, 30, 40] * u.deg)) + + +@pytest.mark.skipif( + CI and not IS_CRON, + reason="Docs-only concern, skip in regular CI", +) +def test_base_coordinate_frame_annotations(): + # Ensure annotations resolve at runtime without NameError + hints = typing.get_type_hints(BaseCoordinateFrame) + assert "default_representation" in hints + assert "default_differential" in hints + assert "frame_specific_representation_info" in hints + assert "frame_attributes" in hints diff --git a/astropy/coordinates/tests/test_name_resolve.py b/astropy/coordinates/tests/test_name_resolve.py index e9dad23d09a0..093d62421cdb 100644 --- a/astropy/coordinates/tests/test_name_resolve.py +++ b/astropy/coordinates/tests/test_name_resolve.py @@ -148,7 +148,7 @@ def test_name_resolve_cache(tmp_path): target_name = "castor" (temp_cache_dir := tmp_path / "cache").mkdir() with paths.set_temp_cache(temp_cache_dir): - assert not get_cached_urls() # sanity check + assert not get_cached_urls(on_missing="ignore") # sanity check icrs = get_icrs_coordinates(target_name, cache=True) urls = get_cached_urls() assert len(urls) == 1 diff --git a/astropy/coordinates/tests/test_representation.py b/astropy/coordinates/tests/test_representation.py index f23a9835e29e..b5ee09863b04 100644 --- a/astropy/coordinates/tests/test_representation.py +++ b/astropy/coordinates/tests/test_representation.py @@ -1964,6 +1964,34 @@ def test_represent_as(self): rep1.represent_as("name") assert "use frame object" in str(excinfo.value) + def test_represent_as_same_class_does_not_modify_differential(self): + diff = SphericalCosLatDifferential( + d_lon_coslat=1 * u.mas / u.yr, + d_lat=2 * u.mas / u.yr, + d_distance=3 * u.km / u.s, + ) + rep = SphericalRepresentation( + lon=15 * u.deg, + lat=30 * u.deg, + distance=1 * u.pc, + differentials=diff, + ) + + new_rep = rep.represent_as(SphericalRepresentation, SphericalDifferential) + + assert new_rep is not rep + assert rep.differentials["s"] is diff + assert isinstance(new_rep.differentials["s"], SphericalDifferential) + assert_allclose_quantity( + new_rep.differentials["s"].d_lon * np.cos(rep.lat), + diff.d_lon_coslat, + ) + assert_allclose_quantity(new_rep.differentials["s"].d_lat, diff.d_lat) + assert_allclose_quantity( + new_rep.differentials["s"].d_distance, + diff.d_distance, + ) + @pytest.mark.parametrize( "sph_diff,usph_diff", [ diff --git a/astropy/coordinates/tests/test_sky_coord.py b/astropy/coordinates/tests/test_sky_coord.py index d2244b48246b..9aff246b9d6b 100644 --- a/astropy/coordinates/tests/test_sky_coord.py +++ b/astropy/coordinates/tests/test_sky_coord.py @@ -24,6 +24,7 @@ Attribute, BaseCoordinateFrame, CartesianRepresentation, + Distance, EarthLocation, Galactic, Latitude, @@ -1897,6 +1898,27 @@ def test_apply_space_motion(): c2.apply_space_motion(new_obstime=t2) +def test_apply_space_motion_after_display(): + """Regression test for source-coordinate mutation in gh-18334.""" + coord = SkyCoord( + ra=66.42197 * u.deg, + dec=-70.003723 * u.deg, + distance=Distance(parallax=22.76407875 * u.mas), + pm_ra_cosdec=144.91354358 * u.mas / u.yr, + pm_dec=5.44564809 * u.mas / u.yr, + obstime="J2000", + ) + new_obstime = Time("2027-01-01") + original_diff = coord.frame.data.differentials["s"] + + result_before = coord.apply_space_motion(new_obstime) + str(coord) + result_after = coord.apply_space_motion(new_obstime) + + assert coord.frame.data.differentials["s"] is original_diff + assert skycoord_equal(result_before, result_after) + + def test_custom_frame_skycoord(): # also regression check for the case from #7069 diff --git a/astropy/coordinates/tests/test_solar_system.py b/astropy/coordinates/tests/test_solar_system.py index 781e5c590357..368441d1a993 100644 --- a/astropy/coordinates/tests/test_solar_system.py +++ b/astropy/coordinates/tests/test_solar_system.py @@ -24,7 +24,6 @@ from astropy.tests.helper import CI, assert_quantity_allclose from astropy.time import Time from astropy.units import allclose as quantity_allclose -from astropy.utils import minversion from astropy.utils.compat.optional_deps import HAS_JPLEPHEM, HAS_SKYFIELD from astropy.utils.data import download_file, get_pkg_data_filename @@ -396,10 +395,8 @@ def test_ephemeris_wrong_input(ephemeris, expected_error): get_body("earth", Time("1960-01-12 00:00"), ephemeris=ephemeris) -# jplephem<2.23 leaves the file open (a ResourceWarning is emitted) -@pytest.mark.filterwarnings( - "error" if minversion("jplephem", "2.23") else "ignore", category=ResourceWarning -) +# jplephem failure to open file emits a ResourceWarning +@pytest.mark.filterwarnings("ignore", category=ResourceWarning) @pytest.mark.skipif(not HAS_JPLEPHEM, reason="requires jplephem") def test_ephemeris_local_file_not_ephemeris(): with pytest.raises(ValueError, match="^file starts"): diff --git a/astropy/cosmology/_src/flrw/base.py b/astropy/cosmology/_src/flrw/base.py index 4f1a641bc193..ab90228eff47 100644 --- a/astropy/cosmology/_src/flrw/base.py +++ b/astropy/cosmology/_src/flrw/base.py @@ -975,7 +975,7 @@ def angular_diameter_distance( Parameters ---------- - z1, z2 : Quantity-like ['redshift'], array-like + z, z2 : Quantity-like ['redshift'], array-like Input redshifts. If one argument ``z`` is given, the distance :math:`d_A(0, z)` is returned. If two arguments ``z1, z2`` are given, the distance :math:`d_A(z_1, z_2)` is returned. diff --git a/astropy/cosmology/_src/tests/flrw/test_base.py b/astropy/cosmology/_src/tests/flrw/test_base.py index 01a77803f30b..48cc07ebbe4c 100644 --- a/astropy/cosmology/_src/tests/flrw/test_base.py +++ b/astropy/cosmology/_src/tests/flrw/test_base.py @@ -75,7 +75,8 @@ def setup_class(self): ) @pytest.fixture(scope="class") - def nonflatcosmo(self): + @classmethod + def nonflatcosmo(cls): """A non-flat cosmology used in equivalence tests.""" return LambdaCDM(70, 0.4, 0.8) diff --git a/astropy/cosmology/_src/tests/funcs/test_comparison.py b/astropy/cosmology/_src/tests/funcs/test_comparison.py index 80999a7bc81d..8ecb0cd55f2a 100644 --- a/astropy/cosmology/_src/tests/funcs/test_comparison.py +++ b/astropy/cosmology/_src/tests/funcs/test_comparison.py @@ -33,11 +33,13 @@ class ComparisonFunctionTestBase(ToFromTestMixinBase): """ @pytest.fixture(scope="class") - def cosmo(self): + @classmethod + def cosmo(cls): return Planck18 @pytest.fixture(scope="class") - def cosmo_eqvxflat(self, cosmo): + @classmethod + def cosmo_eqvxflat(cls, cosmo): if isinstance(cosmo, FlatCosmologyMixin): return cosmo.nonflat @@ -51,23 +53,27 @@ def cosmo_eqvxflat(self, cosmo): {k for k, _ in convert_registry._readers.keys()} - {"astropy.cosmology"} ), ) - def format(self, request): + @classmethod + def format(cls, request): return request.param @pytest.fixture(scope="class") - def xfail_cant_autoidentify(self, format): + @classmethod + def xfail_cant_autoidentify(cls, format): """`pytest.fixture` form of method ``can_autoidentify`.""" - if not self.can_autodentify(format): + if not cls.can_autodentify(format): pytest.xfail("cannot autoidentify") @pytest.fixture(scope="class") - def converted(self, to_format, format): + @classmethod + def converted(cls, to_format, format): if format == "astropy.model": # special case Model return to_format(format, method="comoving_distance") return to_format(format) @pytest.fixture(scope="class") - def pert_cosmo(self, cosmo): + @classmethod + def pert_cosmo(cls, cosmo): # change one parameter p, v = next(iter(cosmo.parameters.items())) return cosmo.clone( @@ -75,7 +81,8 @@ def pert_cosmo(self, cosmo): ) @pytest.fixture(scope="class") - def pert_cosmo_eqvxflat(self, pert_cosmo): + @classmethod + def pert_cosmo_eqvxflat(cls, pert_cosmo): if isinstance(pert_cosmo, FlatCosmologyMixin): return pert_cosmo.nonflat @@ -84,7 +91,8 @@ def pert_cosmo_eqvxflat(self, pert_cosmo): ) @pytest.fixture(scope="class") - def pert_converted(self, pert_cosmo, format): + @classmethod + def pert_converted(cls, pert_cosmo, format): if format == "astropy.model": # special case Model return pert_cosmo.to_format(format, method="comoving_distance") return pert_cosmo.to_format(format) @@ -94,7 +102,8 @@ class Test_parse_format(ComparisonFunctionTestBase): """Test functions ``_parse_format``.""" @pytest.fixture(scope="class") - def converted(self, to_format, format): + @classmethod + def converted(cls, to_format, format): if format == "astropy.model": # special case Model return to_format(format, method="comoving_distance") diff --git a/astropy/cosmology/_src/tests/io/base.py b/astropy/cosmology/_src/tests/io/base.py index 5ca1d4203bb6..26c3385ba6eb 100644 --- a/astropy/cosmology/_src/tests/io/base.py +++ b/astropy/cosmology/_src/tests/io/base.py @@ -36,16 +36,19 @@ class ToFromTestMixinBase(IOTestBase): """ @pytest.fixture(scope="class") - def from_format(self): + @classmethod + def from_format(cls): """Convert to Cosmology using ``Cosmology.from_format()``.""" return Cosmology.from_format @pytest.fixture(scope="class") - def to_format(self, cosmo): + @classmethod + def to_format(cls, cosmo): """Convert Cosmology instance using ``.to_format()``.""" return cosmo.to_format - def can_autodentify(self, format): + @staticmethod + def can_autodentify(format): """Check whether a format can auto-identify.""" return format in Cosmology.from_format.registry._identifiers @@ -61,12 +64,14 @@ class ReadWriteTestMixinBase(IOTestBase): """ @pytest.fixture(scope="class") - def read(self): + @classmethod + def read(cls): """Read Cosmology instance using ``Cosmology.read()``.""" return Cosmology.read @pytest.fixture(scope="class") - def write(self, cosmo): + @classmethod + def write(cls, cosmo): """Write Cosmology using ``.write()``.""" return cosmo.write @@ -95,7 +100,8 @@ class IODirectTestBase(IOTestBase): """ @pytest.fixture(scope="class", autouse=True) - def setup(self): + @classmethod + def setup(cls): """Setup and teardown for tests.""" @dataclass_decorator @@ -115,14 +121,16 @@ def __init__( _COSMOLOGY_CLASSES.pop(CosmologyWithKwargs.__qualname__, None) @pytest.fixture(scope="class", params=cosmo_instances) - def cosmo(self, request): + @classmethod + def cosmo(cls, request): """Cosmology instance.""" if isinstance(request.param, str): # CosmologyWithKwargs return _COSMOLOGY_CLASSES[request.param](Tcmb0=3) return request.param @pytest.fixture(scope="class") - def cosmo_cls(self, cosmo): + @classmethod + def cosmo_cls(cls, cosmo): """Cosmology classes.""" return cosmo.__class__ @@ -146,21 +154,23 @@ class ToFromDirectTestBase(IODirectTestBase, ToFromTestMixinBase): """ @pytest.fixture(scope="class") - def from_format(self): + @classmethod + def from_format(cls): """Convert to Cosmology using function ``from``.""" def use_from_format(*args, **kwargs): kwargs.pop("format", None) # specific to Cosmology.from_format - return self.functions["from"](*args, **kwargs) + return cls.functions["from"](*args, **kwargs) return use_from_format @pytest.fixture(scope="class") - def to_format(self, cosmo): + @classmethod + def to_format(cls, cosmo): """Convert Cosmology to format using function ``to``.""" def use_to_format(*args, **kwargs): - return self.functions["to"](cosmo, *args, **kwargs) + return cls.functions["to"](cosmo, *args, **kwargs) return use_to_format @@ -184,20 +194,22 @@ class ReadWriteDirectTestBase(IODirectTestBase, ToFromTestMixinBase): """ @pytest.fixture(scope="class") - def read(self): + @classmethod + def read(cls): """Read Cosmology from file using function ``read``.""" def use_read(*args, **kwargs): kwargs.pop("format", None) # specific to Cosmology.from_format - return self.functions["read"](*args, **kwargs) + return cls.functions["read"](*args, **kwargs) return use_read @pytest.fixture(scope="class") - def write(self, cosmo): + @classmethod + def write(cls, cosmo): """Write Cosmology to file using function ``write``.""" def use_write(*args, **kwargs): - return self.functions["write"](cosmo, *args, **kwargs) + return cls.functions["write"](cosmo, *args, **kwargs) return use_write diff --git a/astropy/cosmology/_src/tests/io/test_connect.py b/astropy/cosmology/_src/tests/io/test_connect.py index 8d83768e5fb0..72e042b0b42b 100644 --- a/astropy/cosmology/_src/tests/io/test_connect.py +++ b/astropy/cosmology/_src/tests/io/test_connect.py @@ -138,11 +138,13 @@ class TestCosmologyReadWrite(ReadWriteTestMixin): """Test the classes CosmologyRead/Write.""" @pytest.fixture(scope="class", params=cosmo_instances) - def cosmo(self, request): + @classmethod + def cosmo(cls, request): return getattr(cosmology.realizations, request.param) @pytest.fixture(scope="class") - def cosmo_cls(self, cosmo): + @classmethod + def cosmo_cls(cls, cosmo): return cosmo.__class__ # ============================================================== @@ -270,11 +272,13 @@ class TestCosmologyToFromFormat(ToFromFormatTestMixin): """Test Cosmology[To/From]Format classes.""" @pytest.fixture(scope="class", params=cosmo_instances) - def cosmo(self, request): + @classmethod + def cosmo(cls, request): return getattr(cosmology.realizations, request.param) @pytest.fixture(scope="class") - def cosmo_cls(self, cosmo): + @classmethod + def cosmo_cls(cls, cosmo): return cosmo.__class__ # ============================================================== diff --git a/astropy/cosmology/_src/tests/io/test_json.py b/astropy/cosmology/_src/tests/io/test_json.py index d6bc745eb253..4f04f6329a82 100644 --- a/astropy/cosmology/_src/tests/io/test_json.py +++ b/astropy/cosmology/_src/tests/io/test_json.py @@ -95,7 +95,8 @@ class ReadWriteJSONTestMixin(ReadWriteTestMixinBase): """ @pytest.fixture(scope="class", autouse=True) - def register_and_unregister_json(self): + @classmethod + def register_and_unregister_json(cls): """Setup & teardown for JSON read/write tests.""" # Register readwrite_registry.register_reader("json", Cosmology, read_json, force=True) diff --git a/astropy/cosmology/_src/tests/io/test_model.py b/astropy/cosmology/_src/tests/io/test_model.py index 1047649861e6..4fb0a64a93ef 100644 --- a/astropy/cosmology/_src/tests/io/test_model.py +++ b/astropy/cosmology/_src/tests/io/test_model.py @@ -32,7 +32,8 @@ class ToFromModelTestMixin(ToFromTestMixinBase): """ @pytest.fixture(scope="class") - def method_name(self, cosmo): + @classmethod + def method_name(cls, cosmo): # get methods, ignoring private and dunder methods = get_redshift_methods(cosmo, include_private=False, include_z2=True) diff --git a/astropy/cosmology/_src/tests/io/test_yaml.py b/astropy/cosmology/_src/tests/io/test_yaml.py index 0e2a70ceceac..4ddfc8f6f6be 100644 --- a/astropy/cosmology/_src/tests/io/test_yaml.py +++ b/astropy/cosmology/_src/tests/io/test_yaml.py @@ -168,7 +168,8 @@ def setup_class(self): self.functions = {"to": to_yaml, "from": from_yaml} @pytest.fixture(scope="class", autouse=True) - def setup(self): + @classmethod + def setup(cls): """ Setup and teardown for tests. This overrides from super because `ToFromDirectTestBase` adds a custom diff --git a/astropy/cosmology/_src/tests/parameter/test_parameter.py b/astropy/cosmology/_src/tests/parameter/test_parameter.py index e7b615748023..a1646a694578 100644 --- a/astropy/cosmology/_src/tests/parameter/test_parameter.py +++ b/astropy/cosmology/_src/tests/parameter/test_parameter.py @@ -225,22 +225,26 @@ def teardown_class(self): _COSMOLOGY_CLASSES.pop(cls.__qualname__, None) @pytest.fixture(scope="class", params=["Example1", "Example2"]) - def cosmo_cls(self, request): + @classmethod + def cosmo_cls(cls, request): """Cosmology class.""" - return self.classes[request.param] + return cls.classes[request.param] @pytest.fixture(scope="class") - def cosmo(self, cosmo_cls): + @classmethod + def cosmo(cls, cosmo_cls): """Cosmology instance""" return cosmo_cls() @pytest.fixture(scope="class") - def param(self, cosmo_cls): + @classmethod + def param(cls, cosmo_cls): """Get Parameter 'param' from cosmology class.""" return cosmo_cls.parameters["param"] @pytest.fixture(scope="class") - def param_cls(self, param): + @classmethod + def param_cls(cls, param): """Get Parameter class from cosmology class.""" return type(param) diff --git a/astropy/cosmology/_src/tests/test_core.py b/astropy/cosmology/_src/tests/test_core.py index 66b36eb9afe0..6f3dcc262150 100644 --- a/astropy/cosmology/_src/tests/test_core.py +++ b/astropy/cosmology/_src/tests/test_core.py @@ -119,9 +119,10 @@ def cls_args(self): return tuple(self._cls_args.values()) @pytest.fixture(scope="class") - def cosmo_cls(self): + @classmethod + def cosmo_cls(cls): """The Cosmology class as a :func:`pytest.fixture`.""" - return self.cls + return cls.cls @pytest.fixture(scope="function") # ensure not cached. def ba(self): @@ -131,9 +132,14 @@ def ba(self): return ba @pytest.fixture(scope="class") - def cosmo(self, cosmo_cls): + @classmethod + def cosmo(cls, cosmo_cls): """The cosmology instance with which to test.""" - ba = inspect.signature(self.cls).bind(*self.cls_args, **self.cls_kwargs) + # `cls_args` is a @property on the test class, which doesn't fire + # when accessed via cls (only via self), so dereference _cls_args + # directly here. + cls_args = tuple(cls._cls_args.values()) + ba = inspect.signature(cls.cls).bind(*cls_args, **cls.cls_kwargs) ba.apply_defaults() return cosmo_cls(*ba.args, **ba.kwargs) diff --git a/astropy/cosmology/_src/tests/test_units.py b/astropy/cosmology/_src/tests/test_units.py index 72497562de19..2c67498f4a9f 100644 --- a/astropy/cosmology/_src/tests/test_units.py +++ b/astropy/cosmology/_src/tests/test_units.py @@ -172,7 +172,8 @@ class Test_with_redshift: """Test `astropy.cosmology.units.with_redshift`.""" @pytest.fixture(scope="class") - def cosmo(self): + @classmethod + def cosmo(cls): """Test cosmology.""" return Planck13.clone(Tcmb0=3 * u.K) diff --git a/astropy/io/ascii/core.py b/astropy/io/ascii/core.py index de6d3ec92e4c..f941842f4b78 100644 --- a/astropy/io/ascii/core.py +++ b/astropy/io/ascii/core.py @@ -56,7 +56,7 @@ def _check_multidim_table(table: Table, max_ndim: int | None) -> None: if nd_names: raise ValueError( f"column(s) with dimension > {max_ndim} " - "cannot be be written with this format, try using 'ecsv' " + "cannot be written with this format, try using 'ecsv' " "(Enhanced CSV) format" ) diff --git a/astropy/io/ascii/latex.py b/astropy/io/ascii/latex.py index b9e3c902c48a..9e25eef66863 100644 --- a/astropy/io/ascii/latex.py +++ b/astropy/io/ascii/latex.py @@ -8,6 +8,7 @@ :Author: Tom Aldcroft (aldcroft@head.cfa.harvard.edu) """ +import functools import re from collections.abc import Generator from re import Pattern @@ -94,17 +95,78 @@ def process_lines(self, lines: list[str]) -> list[str]: return [lin.strip() for lin in lines] +@functools.cache +def _latex_split_regex(delimiter: str) -> Pattern[str]: + return re.compile( + rf""" + \\. # backslash-escaped char, consumed so it is never + # seen as a brace or delimiter below + | \{{ # opening brace + | \}} # closing brace + | {re.escape(delimiter)} # the column delimiter + """, + re.DOTALL | re.VERBOSE, + ) + + +def _split_outside_braces(line: str, delimiter: str = "&") -> list[str]: + r"""Split ``line`` on ``delimiter`` only where it is outside any ``{...}`` group. + + A character preceded by a backslash is taken literally, so ``\{`` and ``\}`` + do not open or close a group and ``\&`` is not a separator. An unmatched + ``}`` is ignored rather than driving the nesting depth negative. + """ + vals = [] + start = 0 + depth = 0 + for match in _latex_split_regex(delimiter).finditer(line): + # regex match: an escaped pair (\&), a brace, or the delimiter + text = match[0] + if text == "{": + depth += 1 + elif text == "}": + depth = max(depth - 1, 0) + elif text == delimiter and depth == 0: + vals.append(line[start : match.start()]) + start = match.end() + vals.append(line[start:]) + return vals + + class LatexSplitter(core.BaseSplitter): - """Split LaTeX table data. Default delimiter is `&`.""" + """Split LaTeX table data. Default delimiter is `&`. + + The delimiter is only recognised outside of ``{...}`` groups and when not + escaped as ``\\&``, so a value such as ``\\cite{2013A&A...558A..33A}`` is + kept intact. + """ delimiter = "&" def __call__(self, lines: list[str]) -> Generator[list[str], None, None]: + # LaTeX does not require a trailing \\ on the last row of a table, but + # process_line insists on it, so add one there (after dropping any + # trailing comment) before the lines are processed. last_line = RE_COMMENT.split(lines[-1])[0].strip() if not last_line.endswith(r"\\"): lines[-1] = last_line + r"\\" - return super().__call__(lines) + return self._split_lines(lines) + + def _split_lines(self, lines: list[str]) -> Generator[list[str], None, None]: + """Split lines for LaTeX tables. + + Same as `core.BaseSplitter.__call__` but split with `_split_outside_braces`. + instead of line.split(self.delimiter). + """ + if self.process_line: + lines = (self.process_line(x) for x in lines) + for line in lines: + vals = _split_outside_braces(line, self.delimiter) + if self.process_val: + yield [self.process_val(x) for x in vals] + else: + yield vals def process_line(self, line: str) -> str: """Remove whitespace at the beginning or end of line. Also remove @@ -431,7 +493,9 @@ class AASTexHeaderSplitter(LatexSplitter): """ def __call__(self, lines: list[str]) -> Generator[list[str], None, None]: - return super(LatexSplitter, self).__call__(lines) + # Skip the trailing-\\ handling in LatexSplitter.__call__; a \tablehead + # line does not end with \\. + return self._split_lines(lines) def process_line(self, line: str) -> str: """extract column names from tablehead.""" diff --git a/astropy/io/ascii/tests/test_ecsv.py b/astropy/io/ascii/tests/test_ecsv.py index adcf219e17ac..8cba4b4499b1 100644 --- a/astropy/io/ascii/tests/test_ecsv.py +++ b/astropy/io/ascii/tests/test_ecsv.py @@ -231,13 +231,20 @@ def test_write_read_roundtrip(format_engine): assert np.all(t[name] == t2[name]) -def test_write_read_roundtrip_empty_table(tmp_path): +def test_write_read_roundtrip_empty_table(tmp_path, format_engine): + # A table with no columns has no CSV data section, so every reader must + # round-trip it from the header alone. # see https://github.com/astropy/astropy/issues/13191 + # and https://github.com/astropy/astropy/issues/19895 (meta-only, "ecsv" engines) sfile = tmp_path / "x.ecsv" - Table().write(sfile) - t = Table.read(sfile) - assert len(t) == 0 - assert len(t.colnames) == 0 + t = Table() + t.meta["foo"] = 1 + t.meta["bar"] = "example" + t.write(sfile, **patch_format_write(format_engine)) + t2 = Table.read(sfile, **format_engine) + assert len(t2) == 0 + assert len(t2.colnames) == 0 + assert t2.meta == t.meta def test_bad_delimiter(): diff --git a/astropy/io/ascii/tests/test_read.py b/astropy/io/ascii/tests/test_read.py index 30f34fb2acdc..26d28d359269 100644 --- a/astropy/io/ascii/tests/test_read.py +++ b/astropy/io/ascii/tests/test_read.py @@ -1707,7 +1707,7 @@ def test_latex_no_trailing_backslash(): assert np.all(dat["c"] == ["c", "e"]) -def text_aastex_no_trailing_backslash(): +def test_aastex_no_trailing_backslash(): lines = r""" \begin{deluxetable}{ccc} \tablehead{\colhead{a} & \colhead{b} & \colhead{c}} @@ -1720,8 +1720,100 @@ def text_aastex_no_trailing_backslash(): """ dat = ascii.read(lines, format="aastex") assert dat.colnames == ["a", "b", "c"] - assert np.all(dat["a"] == ["1", r"3\%"]) - assert np.all(dat["c"] == ["c", "e"]) + assert np.all(dat["a"] == ["1", "2", r"3\%"]) + assert np.all(dat["c"] == ["c", "d", "e"]) + + +def test_latex_ampersand_in_braces(): + """ + Ampersand inside a {...} group is not a column separator (issue #6360). + """ + lines = r""" +\begin{table} +\begin{tabular}{ccc} +First & Second & Ref\\ +1 & 2 & \cite{other_ref}\\ +11 & 22 & \cite{2013A&A...558A..33A}\\ +\end{tabular} +\end{table} +""" + dat = ascii.read(lines, format="latex") + assert dat.colnames == ["First", "Second", "Ref"] + assert np.all(dat["First"] == [1, 11]) + assert np.all(dat["Second"] == [2, 22]) + assert np.all(dat["Ref"] == [r"\cite{other_ref}", r"\cite{2013A&A...558A..33A}"]) + + +def test_latex_ampersand_in_braces_header_and_nested(): + """ + Braced ampersand in a header cell, nested braces, and a braced group that + spans the whole cell (outer braces are stripped as before). + """ + lines = r""" +\begin{tabular}{ccc} +{A & B} & \textbf{\cite{A&B}} & C\\ +1 & \textbf{\cite{X&Y}} & {x & y}\\ +\end{tabular} +""" + dat = ascii.read(lines, format="latex") + assert dat.colnames == ["A & B", r"\textbf{\cite{A&B}}", "C"] + assert dat[r"\textbf{\cite{A&B}}"][0] == r"\textbf{\cite{X&Y}}" + assert dat["C"][0] == "x & y" + + +def test_aastex_ampersand_in_braces(): + r""" + Braced ampersand in an AASTex \colhead{} and in a data cell. + """ + lines = r""" +\begin{deluxetable}{cc} +\tablehead{\colhead{\cite{A&B}} & \colhead{Ref}} +\startdata +1 & \cite{2013A&A...558A..33A}\\ +\enddata +\end{deluxetable} +""" + dat = ascii.read(lines, format="aastex") + assert dat.colnames == [r"\cite{A&B}", "Ref"] + assert dat["Ref"][0] == r"\cite{2013A&A...558A..33A}" + + +def test_latex_escaped_braces_and_ampersand(): + r""" + Escaped \{ and \} do not open or close a group, and escaped \& is not a + separator. + """ + lines = r""" +\begin{tabular}{cc} +a & b\\ +x \{ & y\\ +\} x & y\\ +x \& y & z\\ +\end{tabular} +""" + dat = ascii.read(lines, format="latex") + assert dat.colnames == ["a", "b"] + assert np.all(dat["a"] == [r"x \{", r"\} x", r"x \& y"]) + assert np.all(dat["b"] == ["y", "y", "z"]) + + +def test_latex_process_val_none_keeps_ampersand(): + """ + With the documented ``process_val`` hook disabled, values are the raw split + text with any braced ampersand intact. + """ + lines = r""" +\begin{tabular}{cc} +a & b\\ +\cite{A&B} & xy\\ +\end{tabular} +""" + reader = ascii.get_reader(reader_cls=ascii.Latex) + reader.data.splitter.process_val = None + dat = reader.read(lines) + assert dat.colnames == ["a", "b"] + assert dat["a"][0] == r"\cite{A&B} " + assert dat["b"][0] == " xy" @pytest.mark.parametrize("encoding", ["utf8", "latin1", "cp1252"]) diff --git a/astropy/io/fits/_logical_helpers.py b/astropy/io/fits/_logical_helpers.py new file mode 100644 index 000000000000..3ce6ebe9078c --- /dev/null +++ b/astropy/io/fits/_logical_helpers.py @@ -0,0 +1,76 @@ +"""Helpers for handling FITS logical (``'L'``) variable-length array data.""" + +import numpy as np + +_VALID_LOGICAL_BYTES = (ord("T"), ord("F"), 0) + + +def _logical_to_fits_bytes(row): + """Convert a logical-VLA row to its FITS L wire-format bytes. + + Returns an int8 array containing ord('T') / ord('F'). Bool inputs map + to T/F; numeric inputs treat zero as False and non-zero as True. + Bytes input (|S1, e.g. from reading with ``logical_as_bytes=True``) + is viewed as int8 verbatim so that NULL (b'\\x00') survives. + """ + arr = np.asarray(row) + if arr.dtype.kind == "S": + return arr.view(np.int8) + if arr.dtype == bool: + return np.where(arr, ord("T"), ord("F")).astype(np.int8) + return np.where(arr == 0, ord("F"), ord("T")).astype(np.int8) + + +def _logical_vla_heap_has_null(raw_data, field, heap_offset): + """Return True if the heap unambiguously contains NULL bytes. + + Used to decide whether to warn that NULL values would be silently + converted to False when reading a logical VLA column without + ``logical_as_bytes=True``. To avoid a misleading warning on an + all-zero heap (which under the FITS standard means all-NULL but + under the astropy <= 7.2.0 legacy encoding means all-False), this + requires both a 0x00 byte AND at least one ord('T') / ord('F') + byte. The legacy-detection heuristic in + ``_detect_legacy_logical_vla_heap`` separately catches files with + a 0x01 byte; the only case this still treats as ambiguous is a + heap containing nothing but 0x00, where the read value (``False``) + is correct under either interpretation. + """ + has_null = False + has_tf = False + for idx in range(len(field)): + offset = int(field[idx, 1]) + heap_offset + count = int(field[idx, 0]) + if not count: + continue + chunk = raw_data[offset : offset + count] + if not has_null and (chunk == 0).any(): + has_null = True + if not has_tf and ((chunk == ord("T")) | (chunk == ord("F"))).any(): + has_tf = True + if has_null and has_tf: + return True + return False + + +def _detect_legacy_logical_vla_heap(raw_data, field, heap_offset): + """Heuristically detect a logical VLA column written by astropy <= 7.2.0. + + astropy <= 7.2.0 stored bool VLA values as 0x00/0x01 bytes instead of the + FITS L wire format ord('T') (0x54) / ord('F') (0x46). A column is + classified as legacy when its heap region contains the byte 0x01 and no + bytes other than 0x00 / 0x01 (those values cannot occur in a correctly + written L column, where only 0x00, 0x46, 0x54 are valid). Heaps + containing only 0x00 are ambiguous but indistinguishable in either + interpretation. + """ + bufs = [] + for idx in range(len(field)): + offset = int(field[idx, 1]) + heap_offset + count = int(field[idx, 0]) + if count: + bufs.append(raw_data[offset : offset + count].view(np.uint8)) + if not bufs: + return False + unique = np.unique(np.concatenate(bufs)) + return bool(np.all(unique <= 1)) and 1 in unique diff --git a/astropy/io/fits/diff.py b/astropy/io/fits/diff.py index c4111c919f27..5a118150c828 100644 --- a/astropy/io/fits/diff.py +++ b/astropy/io/fits/diff.py @@ -720,13 +720,6 @@ def __init__( A list of header keywords whose comments should be ignored in the comparison. May contain wildcard strings as with ignore_keywords. - numdiffs : int, optional - The number of pixel/table values to output when reporting HDU data - differences. Though the count of differences is the same either - way, this allows controlling the number of different values that - are kept in memory or output. If a negative value is given, then - numdiffs is treated as unlimited (default: 10). - rtol : float, optional The relative difference to allow when comparing two float values either in header values, image arrays, or table columns diff --git a/astropy/io/fits/fitsrec.py b/astropy/io/fits/fitsrec.py index ec3302eca203..8c23bea0f20e 100644 --- a/astropy/io/fits/fitsrec.py +++ b/astropy/io/fits/fitsrec.py @@ -12,6 +12,12 @@ from astropy.utils.compat import chararray, get_chararray from astropy.utils.exceptions import AstropyUserWarning +from ._logical_helpers import ( + _VALID_LOGICAL_BYTES, + _detect_legacy_logical_vla_heap, + _logical_to_fits_bytes, + _logical_vla_heap_has_null, +) from .column import ( _VLF, ASCII2NUMPY, @@ -381,7 +387,17 @@ def from_columns( if arr is None: # The input column had an empty array, so just use the fill - # value + # value. For a binary-table logical ('L') column the fill + # byte is 0x00, which the FITS standard reserves for NULL + # (undefined); a column created without data should instead + # default to False (b'F'), matching the ``field[:] = ord("F")`` + # default applied below when an explicit bool array is given. + recformat = column.format.recformat + if ( + not isinstance(recformat, _FormatP) + and recformat[-2:] == FITS2NUMPY["L"] + ): + _get_recarray_field(data, idx)[:] = ord("F") continue n = min(len(arr), nrows) @@ -1345,6 +1361,26 @@ def _scale_back(self, update_heap_pointers=True): needs_update = field != current_as_bool raw_field[needs_update] = np.choose(field[needs_update], choices) + # Validate the on-disk bytes of a fixed-length logical ('L') + # column: only b'T', b'F', and b'\x00' are legal. This catches + # invalid bytes assigned directly into a logical_as_bytes view, + # which aliases the raw data and so bypasses the validation applied + # to |S1 column input at construction time. + if ( + _bool + and not isinstance(recformat, _FormatP) + and not isinstance(self._coldefs, _AsciiColDefs) + ): + invalid = ~np.isin(raw_field, _VALID_LOGICAL_BYTES) + if invalid.any(): + bad = ", ".join( + repr(bytes([int(b)])) for b in np.unique(raw_field[invalid]) + ) + raise ValueError( + f"FITS logical ('L') column {name!r} contains invalid " + f"byte(s) {bad}; only b'T', b'F', and b'\\x00' are allowed." + ) + # Store the updated heapsize self._heapsize = heapsize @@ -1467,76 +1503,6 @@ def tolist(self): return [list(row) for row in zip(*column_lists)] -def _logical_to_fits_bytes(row): - """Convert a logical-VLA row to its FITS L wire-format bytes. - - Returns an int8 array containing ord('T') / ord('F'). Bool inputs map - to T/F; numeric inputs treat zero as False and non-zero as True. - Bytes input (|S1, e.g. from reading with ``logical_as_bytes=True``) - is viewed as int8 verbatim so that NULL (b'\\x00') survives. - """ - arr = np.asarray(row) - if arr.dtype.kind == "S": - return arr.view(np.int8) - if arr.dtype == bool: - return np.where(arr, ord("T"), ord("F")).astype(np.int8) - return np.where(arr == 0, ord("F"), ord("T")).astype(np.int8) - - -def _logical_vla_heap_has_null(raw_data, field, heap_offset): - """Return True if the heap unambiguously contains NULL bytes. - - Used to decide whether to warn that NULL values would be silently - converted to False when reading a logical VLA column without - ``logical_as_bytes=True``. To avoid a misleading warning on an - all-zero heap (which under the FITS standard means all-NULL but - under the astropy <= 7.2.0 legacy encoding means all-False), this - requires both a 0x00 byte AND at least one ord('T') / ord('F') - byte. The legacy-detection heuristic in - ``_detect_legacy_logical_vla_heap`` separately catches files with - a 0x01 byte; the only case this still treats as ambiguous is a - heap containing nothing but 0x00, where the read value (``False``) - is correct under either interpretation. - """ - has_null = False - has_tf = False - for idx in range(len(field)): - offset = int(field[idx, 1]) + heap_offset - count = int(field[idx, 0]) - if not count: - continue - chunk = raw_data[offset : offset + count] - if not has_null and (chunk == 0).any(): - has_null = True - if not has_tf and ((chunk == ord("T")) | (chunk == ord("F"))).any(): - has_tf = True - if has_null and has_tf: - return True - return False - - -def _detect_legacy_logical_vla_heap(raw_data, field, heap_offset): - """Heuristically detect a logical VLA column written by astropy <= 7.2.0. - - astropy <= 7.2.0 stored bool VLA values as 0x00/0x01 bytes instead of the - FITS L wire format ord('T') (0x54) / ord('F') (0x46). A column is - classified as legacy when its heap region contains the byte 0x01 and no - bytes other than 0x00 / 0x01 — those values cannot occur in a correctly - written L column (only 0x00, 0x46, 0x54 are valid). Heaps containing - only 0x00 are ambiguous but indistinguishable in either interpretation. - """ - bufs = [] - for idx in range(len(field)): - offset = int(field[idx, 1]) + heap_offset - count = int(field[idx, 0]) - if count: - bufs.append(raw_data[offset : offset + count].view(np.uint8)) - if not bufs: - return False - unique = np.unique(np.concatenate(bufs)) - return bool(np.all(unique <= 1)) and 1 in unique - - def _get_recarray_field(array, key): """ Compatibility function for using the recarray base class's field method. diff --git a/astropy/io/fits/hdu/compressed/_tiled_compression.py b/astropy/io/fits/hdu/compressed/_tiled_compression.py index 24a76d9e570b..e7ae2fc7a624 100644 --- a/astropy/io/fits/hdu/compressed/_tiled_compression.py +++ b/astropy/io/fits/hdu/compressed/_tiled_compression.py @@ -5,11 +5,13 @@ """ import sys +import warnings from math import prod import numpy as np from astropy.io.fits.hdu.base import BITPIX2DTYPE +from astropy.utils.exceptions import AstropyUserWarning from ._codecs import PLIO1, Gzip1, Gzip2, HCompress1, NoCompress, Rice1 from ._quantization import DITHER_METHODS, QuantizationFailedException, Quantize @@ -541,6 +543,50 @@ def compress_image_data( if not isinstance(image_data, np.ndarray): raise TypeError("Image data must be a numpy.ndarray") + # PLIO_1 encodes only non-negative integers, but astropy stores unsigned + # FITS data via the BZERO=2**(N-1) convention which produces negative + # values for the lower half of the range. Reject unsigned multi-byte + # input to avoid corrupted writes. + if ( + compression_type == "PLIO_1" + and image_data.dtype.kind == "u" + and image_data.dtype.itemsize >= 2 + ): + raise ValueError( + "PLIO_1 compression does not support unsigned integers larger than 8 bits" + ) + + if ( + compression_type in ("RICE_1", "PLIO_1", "HCOMPRESS_1") + and image_data.dtype.kind in ("i", "u") + and image_data.dtype.itemsize == 8 + ): + # numpy's same_value check does not byteswap before comparing values, + # so non-native source data silently truncates instead of raising. + # Convert source to native byte order first. + native_source = image_data.astype( + image_data.dtype.newbyteorder("="), copy=False + ) + new_dt = f"{image_data.dtype.kind}4" + try: + image_data = native_source.astype(new_dt, casting="same_value") + compressed_header["ZBITPIX"] = 32 + if image_data.dtype.kind == "u": + # The input image header carries BZERO=2**63 for the original + # uint64 storage; after converting to uint32 the offset must + # be 2**31 so the FITS reader reconstructs the right values. + compressed_header["BZERO"] = 2**31 + warnings.warn( + f"{compression_type} compression doesn't support 64 integers, " + "data has been converted to 32 bits", + AstropyUserWarning, + ) + except ValueError: + raise ValueError( + f"{compression_type} compression doesn't support 64 integers, " + "but data cannot be converted to 32 bits without overflow", + ) + _check_compressed_header(compressed_header) # TODO: This implementation is memory inefficient as it generates all the @@ -565,10 +611,20 @@ def compress_image_data( tile_data = image_data[tile_slices] if tile_data.dtype.kind == "u": - if tile_data.dtype.itemsize == 4: + if tile_data.dtype.itemsize == 8: + # Subtract 2**63 in wrap-around uint64 arithmetic, then + # reinterpret the bits as int64. This is equivalent to flipping + # the high bit and matches the FITS BZERO=2**63 convention. + tile_data = (tile_data - np.uint64(2**63)).view(np.int64) + elif tile_data.dtype.itemsize == 4: tile_data = (tile_data.astype(np.int64) - 2**31).astype(np.int32) elif tile_data.dtype.itemsize == 2: tile_data = (tile_data.astype(np.int32) - 2**15).astype(np.int16) + elif tile_data.dtype.kind == "i" and tile_data.dtype.itemsize == 1: + # FITS BITPIX=8 storage is unsigned, so int8 input is recorded via + # BZERO=-128 and the stored bytes must be shifted by +128. Without + # this the BZERO=-128 applied on read offsets every value by 128. + tile_data = (tile_data.astype(np.int16) + 128).astype(np.uint8) settings = _update_tile_settings(settings, compression_type, tile_data.shape) diff --git a/astropy/io/fits/hdu/compressed/header.py b/astropy/io/fits/hdu/compressed/header.py index 06c9abdff997..e903ddaeaae3 100644 --- a/astropy/io/fits/hdu/compressed/header.py +++ b/astropy/io/fits/hdu/compressed/header.py @@ -308,8 +308,10 @@ def _image_header_to_empty_bintable( except (AttributeError, KeyError): naxis_comment = "dimension of original image" + # To ensure that the TFIELDS exists (and therefore the after="TFIELDS") + # produces the correct ordering, we need to set it first, before TTYPE1 + bintable.header.set("TFIELDS", 1, "number of fields in each row", after="GCOUNT") # Set the label for the first column in the table - bintable.header.set( "TTYPE1", "COMPRESSED_DATA", "label for field 1", after="TFIELDS" ) @@ -342,7 +344,7 @@ def _image_header_to_empty_bintable( # floating point image has 'COMPRESSED_DATA', # 'GZIP_COMPRESSED_DATA', 'ZSCALE', and 'ZZERO' columns (unless using # lossless compression, per CFITSIO) - ncols = 4 + bintable.header["TFIELDS"] = 4 ttype2 = "GZIP_COMPRESSED_DATA" @@ -385,7 +387,6 @@ def _image_header_to_empty_bintable( cols = ColDefs([col1, col2, col3, col4]) else: # default table has just one 'COMPRESSED_DATA' column - ncols = 1 after = "TFORM1" # Create the ColDefs object for the table @@ -396,9 +397,7 @@ def _image_header_to_empty_bintable( # image HDU, the data type of the image data and the number of # dimensions in the image data array. bintable.header.set("NAXIS1", cols.dtype.itemsize, "width of table in bytes") - bintable.header.set( - "TFIELDS", ncols, "number of fields in each row", after="GCOUNT" - ) + bintable.header.set( "ZIMAGE", True, "extension contains compressed image", after=after ) @@ -528,17 +527,19 @@ def _image_header_to_empty_bintable( ) quantize_method = DEFAULT_QUANTIZE_METHOD - if quantize_method == NO_DITHER: - zquantiz_comment = "No dithering during quantization" - else: - zquantiz_comment = "Pixel Quantization Algorithm" - - bintable.header.set( - "ZQUANTIZ", - QUANTIZE_METHOD_NAMES[quantize_method], - zquantiz_comment, - after="ZVAL" + str(idx), - ) + # A few FITS readers (e.g. DS9, fv) incorrectly treat the + # presence of ZQUANTIZ as meaning that the data are quantized, + # regardless of its value. Omit the keyword entirely when no + # dithering is used, to maximize compatibility; readers + # (including astropy) already treat a missing ZQUANTIZ as + # NO_DITHER. + if quantize_method != NO_DITHER: + bintable.header.set( + "ZQUANTIZ", + QUANTIZE_METHOD_NAMES[quantize_method], + "Pixel Quantization Algorithm", + after="ZVAL" + str(idx), + ) else: # If the ZQUANTIZ keyword is missing the default is to assume # no dithering, rather than whatever DEFAULT_QUANTIZE_METHOD @@ -583,6 +584,21 @@ def _image_header_to_empty_bintable( image_header.comments["SIMPLE"], before="ZBITPIX", ) + # Only move the XTENSION card from the image header to the + # table header as ZTENSION card if this was not a primary header. + # There are cases when the skeleton header inherits the XTENSION card from + # the super class initialization and the SIMPLE card from the incoming header, + # so we want to protect against both existing simultanenously. + elif "XTENSION" in image_header: + # Since we only handle compressed IMAGEs, ZTENSION should + # always be IMAGE, even if the caller has passed in a header + # for some other type of extension. + bintable.header.set( + "ZTENSION", + "IMAGE", + image_header.comments["XTENSION"], + before="ZBITPIX", + ) # Move EXTEND card from the image header to the # table header as ZEXTEND card. @@ -602,20 +618,6 @@ def _image_header_to_empty_bintable( image_header.comments["BLOCKED"], ) - # Move XTENSION card from the image header to the - # table header as ZTENSION card. - - # Since we only handle compressed IMAGEs, ZTENSION should - # always be IMAGE, even if the caller has passed in a header - # for some other type of extension. - if "XTENSION" in image_header: - bintable.header.set( - "ZTENSION", - "IMAGE", - image_header.comments["XTENSION"], - before="ZBITPIX", - ) - # Move PCOUNT and GCOUNT cards from image header to the table # header as ZPCOUNT and ZGCOUNT cards. diff --git a/astropy/io/fits/hdu/compressed/tests/conftest.py b/astropy/io/fits/hdu/compressed/tests/conftest.py index 6b4990f71076..f5acee9a27eb 100644 --- a/astropy/io/fits/hdu/compressed/tests/conftest.py +++ b/astropy/io/fits/hdu/compressed/tests/conftest.py @@ -38,7 +38,10 @@ def _expand(*params): ALL_INTEGER_DTYPES = [ "".join(ele) - for ele in _expand([("<", ">"), ("i",), ("2", "4")], [("<", ">"), ("u",), ("1",)]) + for ele in _expand( + [("<", ">"), ("i",), ("1", "2", "4")], + [("<", ">"), ("u",), ("1", "2", "4")], + ) ] ALL_FLOAT_DTYPES = ["".join(ele) for ele in _expand([("<", ">"), ("f",), ("4", "8")])] diff --git a/astropy/io/fits/hdu/compressed/tests/test_compressed.py b/astropy/io/fits/hdu/compressed/tests/test_compressed.py index 7b85529d1ffb..94ac673c5d46 100644 --- a/astropy/io/fits/hdu/compressed/tests/test_compressed.py +++ b/astropy/io/fits/hdu/compressed/tests/test_compressed.py @@ -19,6 +19,7 @@ from astropy.io.fits.hdu.compressed import ( COMPRESSION_TYPES, DITHER_SEED_CHECKSUM, + NO_DITHER, SUBTRACTIVE_DITHER_1, ) from astropy.io.fits.tests.conftest import FitsTestCase @@ -197,6 +198,26 @@ def test_subtractive_dither_seed(self): assert comp_header["ZDITHER0"] == csum assert np.all(hdul[1].data == array) + def test_no_dither_no_zquantiz(self): + """ + Regression test for https://github.com/astropy/astropy/issues/20136 + + ZQUANTIZ should not be written to the header at all when no + quantization is used, since some FITS readers (e.g. DS9, fv) + incorrectly treat its mere presence as meaning the data are + quantized, regardless of its value. + """ + + array = np.arange(100.0).reshape(10, 10) + hdu = fits.CompImageHDU(data=array, quantize_method=NO_DITHER) + outfile = self.temp("test.fits") + hdu.writeto(outfile) + + with fits.open(outfile) as hdul: + comp_header = hdul[1]._bintable.header + assert "ZQUANTIZ" not in comp_header + assert "ZDITHER0" not in comp_header + def test_disable_image_compression(self): with fits.open(self.data("comp.fits"), disable_image_compression=True) as hdul: # The compressed image HDU should show up as a BinTableHDU, but @@ -305,7 +326,7 @@ def test_open_scaled_in_update_mode_compressed(self): # Try reshaping the data, then closing and reopening the file; let's # see if all the changes are preserved properly - hdul[1].data.shape = (42, 10) + hdul[1].data = hdul[1].data.reshape(42, 10) hdul.close() hdul = fits.open(self.temp("scale.fits")) @@ -1406,7 +1427,6 @@ def test_hdu_lazy_loading(tmp_path): "ZNAME3": "NOISEBIT", "ZVAL3": -32, "ZCMPTYPE": "RICE_1", - "ZQUANTIZ": "NO_DITHER", }, id="quantize_level_w_data", ), @@ -1420,7 +1440,6 @@ def test_hdu_lazy_loading(tmp_path): "ZNAME3": "NOISEBIT", "ZVAL3": -32, "ZCMPTYPE": "RICE_1", - "ZQUANTIZ": "NO_DITHER", }, id="quantize_level_wo_data", ), @@ -1480,7 +1499,7 @@ def test_reserved_keywords_stripped(tmp_path): # # See also https://github.com/astropy/astropy/issues/18067 - data = np.arange(6).reshape((2, 3)) + data = np.arange(6, dtype="int32").reshape((2, 3)) hdu = fits.CompImageHDU(data) hdu.writeto(tmp_path / "compressed.fits") @@ -1573,3 +1592,63 @@ def test_compimghdu_with_primary_header_no_dual_keywords(tmp_path): bintable_header = hdul[1].header assert "ZSIMPLE" in bintable_header assert "ZTENSION" not in bintable_header + + +def test_compressed_hdu_header_order(): + """Test that the headers cards end up in the correct order for the + compressed image HDU.""" + + rng = np.random.default_rng(seed=5301753) + header = fits.Header() + header["a"] = "b" + header["c"] = "d" + nx, ny = 52, 57 + compressed_hdu = fits.CompImageHDU( + data=rng.poisson(1000, size=(ny, nx)).astype(np.int16), + header=header, + ) + + expected_header = [ + ("XTENSION", "BINTABLE"), + ("BITPIX", 8), + ("NAXIS", 2), + ("NAXIS1", 8), + ("NAXIS2", 57), + ("PCOUNT", 3003), + ("GCOUNT", 1), + ("TFIELDS", 1), + ("TTYPE1", "COMPRESSED_DATA"), + ("TFORM1", "1PB(55)"), + ("ZIMAGE", True), + ("ZTENSION", "IMAGE"), + ("ZBITPIX", 16), + ("ZNAXIS", 2), + ("ZNAXIS1", 52), + ("ZNAXIS2", 57), + ("ZPCOUNT", 0), + ("ZGCOUNT", 1), + ("ZTILE1", 52), + ("ZTILE2", 1), + ("ZCMPTYPE", "RICE_1"), + ("ZNAME1", "BLOCKSIZE"), + ("ZVAL1", 32), + ("ZNAME2", "BYTEPIX"), + ("ZVAL2", 2), + ("EXTNAME", "COMPRESSED_IMAGE"), + ("A", "b"), + ("C", "d"), + ] + hdulist = fits.HDUList([fits.PrimaryHDU(), compressed_hdu]) + buffer = io.BytesIO() + hdulist.writeto(buffer) + buffer.seek(0) + + hdulist = fits.open(buffer, disable_image_compression=True) + + actual_header = hdulist[1].header.cards + for actual, expected in zip(actual_header, expected_header): + actual_key, actual_value, _ = actual + expected_key, expected_value = expected + assert actual_key == expected_key + if actual_key != "CHECKSUM": + assert actual_value == expected_value diff --git a/astropy/io/fits/hdu/compressed/tests/test_fitsio.py b/astropy/io/fits/hdu/compressed/tests/test_fitsio.py index 09c20e5c9e23..46e6abb9e148 100644 --- a/astropy/io/fits/hdu/compressed/tests/test_fitsio.py +++ b/astropy/io/fits/hdu/compressed/tests/test_fitsio.py @@ -10,13 +10,16 @@ """ import os +from contextlib import nullcontext import numpy as np import pytest from astropy.io import fits +from astropy.utils import NumpyRNGContext +from astropy.utils.exceptions import AstropyUserWarning -from .conftest import _expand, fitsio_param_to_astropy_param +from .conftest import COMPRESSION_TYPES, _expand, fitsio_param_to_astropy_param # This is so that tox can force this file to be run, and not be silently # skipped on CI, but in all other test runs it's skipped if fitsio isn't present. @@ -126,10 +129,29 @@ def fitsio_compressed_file_path( ): pytest.xfail("fitsio won't write these") + if compression_type == "HCOMPRESS_1" and "u2" in dtype: + # cfitsio's HCOMPRESS encoder underestimates the output buffer size + # for uint16 data on certain tile configurations, raising "encode: + # output buffer too small". astropy's encoder handles the same combos. + pytest.xfail("cfitsio HCOMPRESS encoder buffer-size bug for uint16 input") + + if compression_type == "HCOMPRESS_1" and "i1" in dtype: + # Same buffer-sizing limitation in cfitsio applies to int8 input. + pytest.xfail("cfitsio HCOMPRESS encoder buffer-size bug for int8 input") + if compression_type == "PLIO_1" and "f" in dtype: # fitsio fails with a compression error pytest.xfail("fitsio fails to write these") + if ( + compression_type == "PLIO_1" + and np.dtype(dtype).kind == "u" + and np.dtype(dtype).itemsize >= 2 + ): + # PLIO can't represent the BZERO-shifted unsigned values; cfitsio + # rejects 4/8-byte cases outright and segfaults on 2-byte ones. + pytest.xfail("PLIO_1 cannot encode unsigned multi-byte integers") + if compression_type == "NOCOMPRESS": pytest.xfail("fitsio does not support NOCOMPRESS") @@ -164,6 +186,13 @@ def astropy_compressed_file_path( compression_type, param, dtype = comp_param_dtype original_data = base_original_data.astype(dtype) + if ( + compression_type == "PLIO_1" + and np.dtype(dtype).kind == "u" + and np.dtype(dtype).itemsize >= 2 + ): + pytest.xfail("PLIO_1 cannot encode unsigned multi-byte integers") + tmp_path = tmp_path_factory.mktemp("astropy") filename = tmp_path / f"{compression_type}_{dtype}.fits" @@ -240,3 +269,216 @@ def test_compress( with fits.open(astropy_compressed_file_path) as hdul: np.testing.assert_allclose(data, hdul[1].data, rtol=rtol, atol=atol) + + +@pytest.mark.parametrize("kind", ["i", "u"]) +@pytest.mark.parametrize( + "nbytes,overflow", [(2, False), (4, False), (8, False), (8, True)] +) +@pytest.mark.parametrize("compression_type", COMPRESSION_TYPES) +def test_decompress_integers(nbytes, overflow, compression_type, kind, tmp_path): + if kind == "u" and compression_type == "PLIO_1" and nbytes >= 2: + pytest.skip( + "PLIO_1 cannot encode unsigned multi-byte integers (covered elsewhere)" + ) + + testfile = tmp_path / "test.fits.fz" + data = np.random.poisson(1000, size=(52, 57)).astype(f"{kind}{nbytes}") + if overflow: + # push past the 32-bit limit so the conversion fallback fails + data += np.iinfo(np.int32).max if kind == "i" else np.uint64(2**32) + data_hdu = fits.PrimaryHDU(data=data) + compressed_hdu = fits.CompImageHDU(data=data, compression_type=compression_type) + + if compression_type in ("RICE_1", "PLIO_1", "HCOMPRESS_1") and nbytes == 8: + test_func = pytest.raises if overflow else pytest.warns + ctx = test_func( + ValueError if overflow else AstropyUserWarning, + match=f"{compression_type} compression doesn't support 64 integers.*", + ) + nbytes = 4 + else: + ctx = nullcontext() + + with ctx: + compressed_hdu.writeto(testfile) + + if overflow: + # nothing more to test + return + + with fits.open(testfile, disable_image_compression=True) as hdul: + assert hdul[1].header["ZCMPTYPE"] == compression_type + if compression_type == "RICE_1": + assert hdul[1].header["ZNAME2"] == "BYTEPIX" + assert hdul[1].header["ZVAL2"] == nbytes + + with fits.open(testfile) as hdul: + np.testing.assert_array_equal(data, hdul[1].data) + assert hdul[1].data.dtype.kind == np.dtype(data.dtype).kind + assert hdul[1].data.dtype.itemsize == nbytes + + if compression_type != "NOCOMPRESS" and nbytes != 8: + # fitsio does not support NOCOMPRESS or 64-bit data + fts = fitsio.FITS(testfile) + data2 = fts[1].read() + np.testing.assert_array_equal(data, data2) + + +INTEGER_DTYPES_FULL_RANGE = [ + "i1", + "i2", + "i4", + "i8", + "u1", + "u2", + "u4", + "u8", +] + + +def _full_range_integer_data(dtype, shape=(32, 32)): + info = np.iinfo(dtype) + sentinels = np.array( + [ + info.min, + info.min + 1, + -1 if info.min < 0 else 0, + 0, + 1, + info.max - 1, + info.max, + ], + dtype=dtype, + ) + with NumpyRNGContext(0): + data = np.random.randint( + info.min, + info.max + 1, + size=shape, + dtype=dtype.lstrip("<>"), + ).astype(dtype) + data.ravel()[: sentinels.size] = sentinels + return data + + +@pytest.mark.parametrize("dtype", INTEGER_DTYPES_FULL_RANGE) +@pytest.mark.parametrize("compression_type", COMPRESSION_TYPES) +def test_integer_full_range_roundtrip(compression_type, dtype, tmp_path): + """Cross-check exact round-tripping of boundary-spanning integer data + between astropy and fitsio for every standard FITS integer dtype and + every compression algorithm that supports it.""" + data = _full_range_integer_data(dtype) + np_dtype = np.dtype(dtype) + info = np.iinfo(dtype) + astropy_path = tmp_path / "astropy.fits" + hdu = fits.CompImageHDU(data=data, compression_type=compression_type) + + # 64-bit integer data with full-range sentinels overflows the 32-bit + # conversion that RICE_1 and HCOMPRESS_1 fall back to. (PLIO_1 + i8 also + # lands here; PLIO_1 + u8 is caught by the unsigned-multi-byte rejection + # below.) For these combos cfitsio also rejects the input outright, so + # cross-check that fitsio raises too. + if ( + compression_type in ("RICE_1", "HCOMPRESS_1") + and np_dtype.kind in ("i", "u") + and np_dtype.itemsize == 8 + ) or ( + compression_type == "PLIO_1" and np_dtype.kind == "i" and np_dtype.itemsize == 8 + ): + with pytest.raises( + ValueError, + match=( + f"{compression_type} compression doesn't support 64 integers, " + "but data cannot be converted to 32 bits without overflow" + ), + ): + hdu.writeto(astropy_path) + if compression_type == "HCOMPRESS_1": + with pytest.raises( + OSError, + match=r"writing T(U)?LONGLONG to compressed image is not supported", + ): + with fitsio.FITS(tmp_path / "fitsio.fits", "rw") as fts: + fts.write(data, compress=compression_type) + return + + # PLIO_1 cannot encode unsigned multi-byte integers because the FITS + # BZERO=2**(N-1) offset astropy applies to unsigned data produces negative + # values that PLIO rejects. Astropy raises a clean ValueError for all + # unsigned itemsize >= 2; cfitsio raises only for itemsize >= 4 and + # segfaults on itemsize == 2 with full-range data, so the cross-check + # only runs for the 4/8-byte cases. + if compression_type == "PLIO_1" and np_dtype.kind == "u" and np_dtype.itemsize >= 2: + with pytest.raises( + ValueError, + match=r"PLIO_1 compression does not support unsigned integers", + ): + hdu.writeto(astropy_path) + if np_dtype.itemsize >= 4: + with pytest.raises( + ValueError, + match=r"Unsigned 4/8-byte integers currently not allowed", + ): + with fitsio.FITS(tmp_path / "fitsio.fits", "rw") as fts: + fts.write(data, compress=compression_type) + return + + # PLIO_1 also can't encode signed values outside [0, 2**24 - 1]. int8 is + # exempt because the BZERO=-128 transform leaves the stored bytes in + # [0, 255], which PLIO_1 encodes fine. + is_int8 = np_dtype.kind == "i" and np_dtype.itemsize == 1 + if compression_type == "PLIO_1" and ( + (not is_int8 and info.min < 0) or info.max > 2**24 - 1 + ): + with pytest.raises( + ValueError, + match=r"data out of range for PLIO compression", + ): + hdu.writeto(astropy_path) + return + + # 1. astropy writes a compressed file. + hdu.writeto(astropy_path) + + # 2. astropy reads its own compressed file. + with fits.open(astropy_path) as hdul: + rt = hdul[1].data + assert rt.dtype.kind == np_dtype.kind + assert rt.dtype.itemsize == np_dtype.itemsize + np.testing.assert_array_equal(rt, data) + + # fitsio doesn't support NOCOMPRESS, so the cross-checks stop here. + # cfitsio also refuses to read or write GZIP-compressed 64-bit integer + # images even though the FITS Tile Compression Convention permits them, + # so astropy's standard-compliant output cannot be cross-validated for + # those combinations. + if compression_type == "NOCOMPRESS" or ( + compression_type in ("GZIP_1", "GZIP_2") and np_dtype.itemsize == 8 + ): + return + + # 3. fitsio reads astropy's compressed file. + with fitsio.FITS(astropy_path) as fts: + rt_fitsio = fts[1].read() + assert rt_fitsio.dtype.kind == np_dtype.kind + assert rt_fitsio.dtype.itemsize == np_dtype.itemsize + np.testing.assert_array_equal(rt_fitsio, data) + + # 4. astropy reads a file fitsio compressed. + fitsio_path = tmp_path / "fitsio.fits" + with fitsio.FITS(fitsio_path, "rw") as fts: + fts.write(data, compress=compression_type) + with fits.open(fitsio_path) as hdul: + rt_astropy = hdul[1].data + assert rt_astropy.dtype.kind == np_dtype.kind + assert rt_astropy.dtype.itemsize == np_dtype.itemsize + np.testing.assert_array_equal(rt_astropy, data) diff --git a/astropy/io/fits/hdu/compressed/tests/test_tiled_compression.py b/astropy/io/fits/hdu/compressed/tests/test_tiled_compression.py index b72336403f9a..695130b9c5ed 100644 --- a/astropy/io/fits/hdu/compressed/tests/test_tiled_compression.py +++ b/astropy/io/fits/hdu/compressed/tests/test_tiled_compression.py @@ -203,6 +203,12 @@ def test_roundtrip_high_D( np.count_nonzero(np.array(shape[:2]) % tile_shape[:2]) != 0 ): pytest.xfail("HCOMPRESS requires 2D tiles.") + if ( + compression_type == "PLIO_1" + and np.dtype(dtype).kind == "u" + and np.dtype(dtype).itemsize >= 2 + ): + pytest.xfail("PLIO_1 cannot encode unsigned multi-byte integers") random = numpy_rng.uniform(high=255, size=shape) # Set first value to be exactly zero as zero values require special treatment # for SUBTRACTIVE_DITHER_2 diff --git a/astropy/io/fits/hdu/table.py b/astropy/io/fits/hdu/table.py index 1aa418196554..5d93db1b5ab2 100644 --- a/astropy/io/fits/hdu/table.py +++ b/astropy/io/fits/hdu/table.py @@ -718,12 +718,19 @@ def _clear_table_keywords(self, index=None): def _populate_table_keywords(self): """Populate the new table definition keywords from the header.""" + # Ensure that TFIELDS is written so that we can append the other + # keywords after it + self._header.set("TFIELDS", len(self.columns), after="GCOUNT") + + # Put the table keywords in order after TFIELDS + after = "TFIELDS" for idx, column in enumerate(self.columns): for keyword, attr in KEYWORD_TO_ATTRIBUTE.items(): val = getattr(column, attr) if val is not None: keyword = keyword + str(idx + 1) - self._header[keyword] = val + self._header.set(keyword, val, after=after) + after = keyword class TableHDU(_TableBaseHDU): @@ -1528,17 +1535,31 @@ def _as_bigendian_pieces(data): # the .str will be '|V' where is the total bytes per element. if field_dtype != dtype_big.fields[name][0]: offset = field_offset - itemsize = field_dtype.base.itemsize - # Reorder every element of a possible sub-array. - for i in range(math.prod(field_dtype.shape)): - indices[offset : offset + itemsize] = indices[ - offset : offset + itemsize + # For complex types, swap each float component independently + # rather than reversing all bytes of the complex number + # (which would swap real and imaginary parts). + if field_dtype.base.kind == "c": + swap_itemsize = field_dtype.base.itemsize // 2 + else: + swap_itemsize = field_dtype.base.itemsize + # Reorder every element of a possible sub-array, + # and every component of complex numbers. + n_swaps = field_dtype.itemsize // swap_itemsize + for i in range(n_swaps): + indices[offset : offset + swap_itemsize] = indices[ + offset : offset + swap_itemsize ][::-1] - offset += itemsize + offset += swap_itemsize # Reordering the data by indexing makes copies, so work in pieces. + # Each piece except the last must span a whole number of 4-byte words: + # _calculate_datasum_with_heap feeds the pieces to the 32-bit FITS + # checksum one at a time, so a piece ending mid-word would misalign the + # running checksum and yield a wrong DATASUM. Rounding the row count down + # to a multiple of four keeps every piece length (rows * itemsize) a + # multiple of four bytes. data_bytes = data.view(np.ndarray)[..., np.newaxis].view(np.ubyte) - step = max(65536 // len(indices), 1) + step = max(65536 // len(indices) // 4 * 4, 4) for piece in np.split(data_bytes, range(0, len(data), step)): if len(piece): yield piece[..., indices].ravel() diff --git a/astropy/io/fits/scripts/fitsheader.py b/astropy/io/fits/scripts/fitsheader.py index 1d6f9c7691b2..f877233e95f0 100644 --- a/astropy/io/fits/scripts/fitsheader.py +++ b/astropy/io/fits/scripts/fitsheader.py @@ -285,7 +285,7 @@ def print_headers_traditional(args): print( formatter.parse(args.extensions, args.keyword, args.compressed), end="" ) - except OSError as e: + except Exception as e: log.error(str(e)) finally: if formatter: @@ -309,7 +309,7 @@ def print_headers_as_table(args): tbl = formatter.parse(args.extensions, args.keyword, args.compressed) if tbl: tables.append(tbl) - except OSError as e: + except Exception as e: log.error(str(e)) # file not found or unreadable finally: if formatter: @@ -353,7 +353,7 @@ def print_headers_as_comparison(args): else: tbl = table.Table([[filename]], names=("filename",)) tables.append(tbl) - except OSError as e: + except Exception as e: log.error(str(e)) # file not found or unreadable finally: if formatter: diff --git a/astropy/io/fits/scripts/fitsinfo.py b/astropy/io/fits/scripts/fitsinfo.py index cce9611b120a..b5158597939b 100644 --- a/astropy/io/fits/scripts/fitsinfo.py +++ b/astropy/io/fits/scripts/fitsinfo.py @@ -48,7 +48,7 @@ def fitsinfo(filename): """ try: fits.info(filename) - except OSError as e: + except Exception as e: log.error(str(e)) diff --git a/astropy/io/fits/tests/test_checksum.py b/astropy/io/fits/tests/test_checksum.py index ca3de2fb08d7..a521db286745 100644 --- a/astropy/io/fits/tests/test_checksum.py +++ b/astropy/io/fits/tests/test_checksum.py @@ -160,6 +160,39 @@ def test_binary_table_data(self): assert "DATASUM" in hdul[1].header assert hdul[1].header["DATASUM"] == "1062205743" + def test_checksum_byteswapped_table_spanning_multiple_chunks(self): + """ + Regression test for #19990. + + For a byte-swapped (little-endian) binary table large enough to be + checksummed in more than one chunk, the DATASUM is accumulated one + chunk at a time. Each chunk must be a whole number of 4-byte words, + otherwise the running 32-bit checksum is misaligned and the DATASUM + is valid in memory but fails to verify once the file is read back. + """ + nrows = 8000 + cols = fits.ColDefs( + [ + fits.Column(name="a", format="J", array=np.arange(nrows, dtype="i4")), + fits.Column(name="b", format="D", array=np.linspace(0, 1, nrows)), + # An 11A column makes the row 4 + 8 + 11 = 23 bytes, which is + # not a multiple of 4, so the chunk boundaries fall mid-word. + fits.Column(name="s", format="11A", array=np.array(["xy"] * nrows)), + ] + ) + tbhdu = fits.BinTableHDU.from_columns(cols) + # The table must span more than one checksum chunk to exercise the bug. + assert tbhdu.data.nbytes > 65536 + tbhdu.add_checksum() + assert tbhdu.verify_checksum() == 1 + assert tbhdu.verify_datasum() == 1 + tbhdu.writeto(self.temp("tmp.fits"), overwrite=True) + with fits.open(self.temp("tmp.fits"), checksum=True) as hdul: + assert tbhdu.data.dtype.itemsize % 4 != 0 + assert hdul[1].verify_checksum() == 1 + assert hdul[1].verify_datasum() == 1 + assert comparerecords(tbhdu.data, hdul[1].data) + def test_variable_length_table_data(self): c1 = fits.Column( name="var", diff --git a/astropy/io/fits/tests/test_connect.py b/astropy/io/fits/tests/test_connect.py index 96f2956a0be8..be30d603af94 100644 --- a/astropy/io/fits/tests/test_connect.py +++ b/astropy/io/fits/tests/test_connect.py @@ -1082,6 +1082,15 @@ def test_info_attributes_with_no_mixins(tmp_path): assert t2["col0"].meta["a"] == {"b": "c"} +def test_round_trip_complex(tmp_path): + """FITS portion of https://github.com/astropy/astropy/issues/19775""" + filename = tmp_path / "test.fits" + t = Table({"a": [1 + 4.3j, 2.2 + 6.1j, 3 + 7.2j]}) + t.write(filename, format="fits") + t2 = Table.read(filename, format="fits") + assert np.allclose(t["a"], t2["a"], rtol=1e-5, atol=1e-8) + + @pytest.mark.parametrize("method", ["set_cols", "names", "class"]) def test_round_trip_masked_table_serialize_mask(tmp_path, method): """ diff --git a/astropy/io/fits/tests/test_core.py b/astropy/io/fits/tests/test_core.py index 23a09609b919..99a4c0a44679 100644 --- a/astropy/io/fits/tests/test_core.py +++ b/astropy/io/fits/tests/test_core.py @@ -2,6 +2,7 @@ import errno import gzip +import http.client import io import mmap import os @@ -10,7 +11,7 @@ import sys import urllib.request import zipfile -from unittest.mock import patch +from unittest.mock import Mock, patch import numpy as np import pytest @@ -709,24 +710,20 @@ def test_open_from_url(self): @pytest.mark.remote_data(source="astropy") def test_open_from_remote_url(self): - for dataurl in (conf.dataurl, conf.dataurl_mirror): - remote_url = f"{dataurl}/allsky/allsky_rosat.fits" - try: - with urllib.request.urlopen(remote_url) as urlobj: - with fits.open(urlobj) as fits_handle: - assert len(fits_handle) == 1 - - for mode in ("ostream", "append", "update"): - with pytest.raises(ValueError): - with urllib.request.urlopen(remote_url) as urlobj: - with fits.open(urlobj, mode=mode) as fits_handle: - assert len(fits_handle) == 1 - except (urllib.error.HTTPError, urllib.error.URLError): - continue - else: - break - else: - raise Exception("Could not download file") + remote_url = f"{conf.dataurl}/allsky/allsky_rosat.fits" + with ( + urllib.request.urlopen(remote_url) as urlobj, + fits.open(urlobj) as fits_handle, + ): + assert len(fits_handle) == 1 + + @pytest.mark.parametrize("mode", ("ostream", "append", "update")) + def test_open_url_invalid_mode(self, mode): + urlobj = http.client.HTTPResponse(Mock()) + with pytest.raises( + ValueError, match=f"Mode {mode} not supported for HTTPResponse" + ): + fits.open(urlobj, mode=mode) def test_open_gzipped(self): gzip_file = self._make_gzip_file() diff --git a/astropy/io/fits/tests/test_fitsheader.py b/astropy/io/fits/tests/test_fitsheader.py index c7bb620a47da..298912848a93 100644 --- a/astropy/io/fits/tests/test_fitsheader.py +++ b/astropy/io/fits/tests/test_fitsheader.py @@ -3,6 +3,7 @@ import pytest from astropy import __version__ as version +from astropy.io import fits from astropy.io.fits.scripts import fitsheader from .conftest import FitsTestCase @@ -29,6 +30,18 @@ def test_file_exists(self, capsys): ) assert err == "" + def test_missing_optional_dependency(self, caplog, monkeypatch): + # Regression test for #19852: fitsheader on a .Z file used to raise an + # uncaught ``ModuleNotFoundError`` when the optional ``uncompresspy`` + # dependency was not installed, producing a long traceback instead of a + # clean error message. + monkeypatch.setattr(fits.file, "HAS_UNCOMPRESSPY", False) + Zfile = self.data("lzw.fits.Z") + + fitsheader.main([Zfile]) + + assert "optional package uncompresspy is necessary" in caplog.records[0].message + def test_by_keyword(self, capsys): fitsheader.main(["-k", "NAXIS", self.data("arange.fits")]) out, err = capsys.readouterr() diff --git a/astropy/io/fits/tests/test_fitsinfo.py b/astropy/io/fits/tests/test_fitsinfo.py index 20106954a63f..d2450b27a1e2 100644 --- a/astropy/io/fits/tests/test_fitsinfo.py +++ b/astropy/io/fits/tests/test_fitsinfo.py @@ -3,6 +3,7 @@ import pytest from astropy import __version__ as version +from astropy.io import fits from astropy.io.fits.scripts import fitsinfo from .conftest import FitsTestCase @@ -21,6 +22,18 @@ def test_version(self, capsys): assert out == f"fitsinfo {version}" assert e.value.code == 0 + def test_missing_optional_dependency(self, caplog, monkeypatch): + # Regression test for #19852: fitsinfo on a .Z file used to raise an + # uncaught ``ModuleNotFoundError`` when the optional ``uncompresspy`` + # dependency was not installed, producing a long traceback instead of a + # clean error message. + monkeypatch.setattr(fits.file, "HAS_UNCOMPRESSPY", False) + Zfile = self.data("lzw.fits.Z") + + fitsinfo.main([Zfile]) + + assert "optional package uncompresspy is necessary" in caplog.records[0].message + def test_onefile(self, capsys): fitsinfo.main([self.data("arange.fits")]) out, err = capsys.readouterr() diff --git a/astropy/io/fits/tests/test_header.py b/astropy/io/fits/tests/test_header.py index 7a5a5e1ac709..f7634366f187 100644 --- a/astropy/io/fits/tests/test_header.py +++ b/astropy/io/fits/tests/test_header.py @@ -2636,18 +2636,18 @@ def test_newlines_in_commentary(self): h.set("HISTORY", "abc\ndef") test_cards = [ - "HISTORY File modified by user 'wilma' with fv on 2013-04-22T21:42:18 " - "HISTORY File modified by user ' fred' with fv on 2013-04-23T11:16:29 " - "HISTORY File modified by user ' fred' with fv on 2013-11-04T16:59:14 " - "HISTORY File modified by user 'wilma' with fv on 2013-04-22T21:42:18\nFile modif" - "HISTORY ied by user 'wilma' with fv on 2013-04-23T11:16:29\nFile modified by use" - "HISTORY r ' fred' with fv on 2013-11-04T16:59:14 " - "HISTORY File modified by user 'wilma' with fv on 2013-04-22T21:42:18\nFile modif" - "HISTORY ied by user 'wilma' with fv on 2013-04-23T11:16:29\nFile modified by use" - "HISTORY r ' fred' with fv on 2013-11-04T16:59:14\nFile modified by user 'wilma' " - "HISTORY with fv on 2013-04-22T21:42:18\nFile modif\nied by user 'wilma' with fv " - "HISTORY on 2013-04-23T11:16:29\nFile modified by use\nr ' fred' with fv on 2013-1" - "HISTORY 1-04T16:59:14 " + "HISTORY File modified by user 'wilma' with fv on 2013-04-22T21:42:18 ", + "HISTORY File modified by user ' fred' with fv on 2013-04-23T11:16:29 ", + "HISTORY File modified by user ' fred' with fv on 2013-11-04T16:59:14 ", + "HISTORY File modified by user 'wilma' with fv on 2013-04-22T21:42:18\nFile modif", + "HISTORY ied by user 'wilma' with fv on 2013-04-23T11:16:29\nFile modified by use", + "HISTORY r ' fred' with fv on 2013-11-04T16:59:14 ", + "HISTORY File modified by user 'wilma' with fv on 2013-04-22T21:42:18\nFile modif", + "HISTORY ied by user 'wilma' with fv on 2013-04-23T11:16:29\nFile modified by use", + "HISTORY r ' fred' with fv on 2013-11-04T16:59:14\nFile modified by user 'wilma' ", + "HISTORY with fv on 2013-04-22T21:42:18\nFile modif\nied by user 'wilma' with fv ", + "HISTORY on 2013-04-23T11:16:29\nFile modified by use\nr ' fred' with fv on 2013-1", + "HISTORY 1-04T16:59:14 ", ] for card_image in test_cards: diff --git a/astropy/io/fits/tests/test_logical_helpers.py b/astropy/io/fits/tests/test_logical_helpers.py new file mode 100644 index 000000000000..336de848e662 --- /dev/null +++ b/astropy/io/fits/tests/test_logical_helpers.py @@ -0,0 +1,84 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +import numpy as np +from numpy.testing import assert_array_equal + +from astropy.io.fits._logical_helpers import ( + _detect_legacy_logical_vla_heap, + _logical_to_fits_bytes, + _logical_vla_heap_has_null, +) + + +class TestLogicalToFitsBytes: + def test_S1_viewed_as_int8(self): + row = np.array([b"T", b"\x00", b"F"], dtype="S1") + out = _logical_to_fits_bytes(row) + assert out.dtype == np.int8 + assert_array_equal(out, [ord("T"), 0, ord("F")]) + + def test_bool_input(self): + out = _logical_to_fits_bytes(np.array([True, False, True])) + assert_array_equal(out, [ord("T"), ord("F"), ord("T")]) + + def test_numeric_nonzero_is_true(self): + out = _logical_to_fits_bytes(np.array([0, 1, -3, 7])) + assert_array_equal(out, [ord("F"), ord("T"), ord("T"), ord("T")]) + + +class TestLogicalVlaHeapHasNull: + @staticmethod + def _heap(rows): + """Build a (raw_data, field, heap_offset) triple from per-row bytes.""" + offsets, counts, blob = [], [], b"" + for r in rows: + offsets.append(len(blob)) + counts.append(len(r)) + blob += r + field = np.array(list(zip(counts, offsets)), dtype=np.int64) + raw_data = np.frombuffer(blob, dtype=np.uint8) + return raw_data, field, 0 + + def test_mixed_null_and_tf(self): + raw, field, off = self._heap([b"T\x00F", b"T"]) + assert _logical_vla_heap_has_null(raw, field, off) is True + + def test_only_nulls_is_ambiguous(self): + # All-zero heap could be all-NULL or pre-fix all-False; not flagged. + raw, field, off = self._heap([b"\x00\x00", b"\x00"]) + assert _logical_vla_heap_has_null(raw, field, off) is False + + def test_only_tf_no_null(self): + raw, field, off = self._heap([b"TF", b"FT"]) + assert _logical_vla_heap_has_null(raw, field, off) is False + + def test_empty_rows_skipped(self): + raw, field, off = self._heap([b"", b"T\x00"]) + assert _logical_vla_heap_has_null(raw, field, off) is True + + +class TestDetectLegacyLogicalVlaHeap: + @staticmethod + def _heap(blob): + raw_data = np.frombuffer(blob, dtype=np.uint8) + field = np.array([[len(blob), 0]], dtype=np.int64) + return raw_data, field, 0 + + def test_legacy_zeros_and_ones(self): + raw, field, off = self._heap(b"\x00\x01\x01\x00") + assert _detect_legacy_logical_vla_heap(raw, field, off) is True + + def test_modern_TF_bytes_not_legacy(self): + raw, field, off = self._heap(b"TFT\x00") + assert _detect_legacy_logical_vla_heap(raw, field, off) is False + + def test_all_zero_not_legacy_no_anchor(self): + # Without a 0x01 anchor we cannot distinguish all-NULL from all-False; + # the function returns False so the modern decoder runs. + raw, field, off = self._heap(b"\x00\x00\x00") + assert _detect_legacy_logical_vla_heap(raw, field, off) is False + + def test_no_rows_returns_false(self): + raw = np.empty(0, dtype=np.uint8) + field = np.empty((0, 2), dtype=np.int64) + assert _detect_legacy_logical_vla_heap(raw, field, 0) is False diff --git a/astropy/io/fits/tests/test_table.py b/astropy/io/fits/tests/test_table.py index 21746c67dbce..292d48136db6 100644 --- a/astropy/io/fits/tests/test_table.py +++ b/astropy/io/fits/tests/test_table.py @@ -288,6 +288,47 @@ def test_binary_table(self): t.close() + def test_table_header_order(self): + # Make sure the table keywords are in the right order and are above other + # header keywords + table = Table({"col1": ["foo", "bar"], "col2": ["test1", "test2"]}) + header = fits.Header() + header["a"] = "b" + header["c"] = "d" + table_hdu = fits.BinTableHDU(table, header=header) + table_hdu.add_checksum() + + actual_header = table_hdu.header.cards + + expected_header = [ + ("XTENSION", "BINTABLE", "binary table extension"), + ("BITPIX", 8, "array data type"), + ("NAXIS", 2, "number of array dimensions"), + ("NAXIS1", 8, "length of dimension 1"), + ("NAXIS2", 2, "length of dimension 2"), + ("PCOUNT", 0, "number of group parameters"), + ("GCOUNT", 1, "number of groups"), + ("TFIELDS", 2, "number of table fields"), + ("TTYPE1", "col1", ""), + ("TFORM1", "3A", ""), + ("TTYPE2", "col2", ""), + ("TFORM2", "5A", ""), + ("A", "b", ""), + ("C", "d", ""), + ( + "CHECKSUM", + "XIamYIUlXIalXIUl", + "HDU checksum updated 2026-05-18T16:19:52", + ), + ("DATASUM", "2478295628", "data unit checksum updated 2026-05-18T16:19:52"), + ] + for actual, expected in zip(actual_header, expected_header): + actual_key, actual_value, _ = actual + expected_key, expected_value, _ = expected + assert actual_key == expected_key + if actual_key != "CHECKSUM": + assert actual_value == expected_value + def test_ascii_table(self): # ASCII table a = fits.open(self.data("ascii.fits")) @@ -2835,6 +2876,70 @@ def test_logical_as_bytes(self, tmp_path): with fits.open(out_path) as hdul: assert hdul[1].data["flag"].tolist() == [True, False, False] + def test_logical_nrows_fill_then_assign(self, tmp_path): + """A logical ('L') column created via ``from_columns(..., nrows=N)`` + without an input array must default to False (b'F'), not NULL + (b'\\x00'). Otherwise a row later assigned ``False`` is left as the + zero-fill byte and silently stored as NULL, because ``_scale_back`` + cannot distinguish an untouched NULL from an explicit False once both + appear as raw 0x00 with a cached bool of ``False``. Regression test + for the breakage introduced by logical-NULL support in 8.0.0. + """ + hdu = fits.BinTableHDU.from_columns([fits.Column("FLAG", "L")], nrows=4) + for i, value in enumerate([True, False, True, False]): + hdu.data[i]["FLAG"] = value + path = tmp_path / "flags.fits" + hdu.writeto(path) + + # Raw bytes: the False rows must be b'F', never NULL (b'\x00'). + with fits.open(path, logical_as_bytes=True) as hdul: + raw = hdul[1].data["FLAG"] + assert raw.tobytes() == b"TFTF" + + # A normal read must therefore neither warn about NULL nor mangle + # the values. + with warnings.catch_warnings(): + warnings.simplefilter("error", AstropyUserWarning) + with fits.open(path) as hdul: + assert hdul[1].data["FLAG"].tolist() == [True, False, True, False] + + # Rows left untouched after creation also default to False, not NULL. + hdu = fits.BinTableHDU.from_columns([fits.Column("FLAG", "L")], nrows=3) + hdu.data[0]["FLAG"] = True + partial_path = tmp_path / "partial.fits" + hdu.writeto(partial_path) + with fits.open(partial_path, logical_as_bytes=True) as hdul: + assert hdul[1].data["FLAG"].tobytes() == b"TFF" + + def test_logical_as_bytes_invalid_byte_rejected_on_write(self, tmp_path): + """Bytes assigned into a logical ('L') column read with + ``logical_as_bytes=True`` alias the raw data directly, bypassing the + construction-time validation. Writing a column whose raw bytes are not + one of the FITS L wire-format values b'T', b'F', b'\\x00' therefore + raises ``ValueError`` at write time. + """ + src = np.array([b"T", b"\x00", b"F"], dtype="S1") + path = tmp_path / "src.fits" + fits.BinTableHDU.from_columns([fits.Column("flag", "L", array=src)]).writeto( + path + ) + + # Valid wire-format bytes round-trip without error. + with fits.open(path, logical_as_bytes=True) as hdul: + hdul[1].data["flag"][0] = b"\x00" + hdul[1].data["flag"][2] = b"T" + out = tmp_path / "ok.fits" + hdul.writeto(out) + with fits.open(out, logical_as_bytes=True) as hdul: + assert hdul[1].data["flag"].tobytes() == b"\x00\x00T" + + # An invalid byte is rejected on write. + for bad in (b"X", b"1", b"t"): + with fits.open(path, logical_as_bytes=True) as hdul: + hdul[1].data["flag"][0] = bad + with pytest.raises(ValueError, match="only b'T', b'F'"): + hdul.writeto(tmp_path / f"bad_{bad}.fits") + def test_missing_tnull(self): """Regression test for https://aeon.stsci.edu/ssb/trac/pyfits/ticket/197""" diff --git a/astropy/io/misc/ecsv.py b/astropy/io/misc/ecsv.py index 6eb9fc7e00c0..ef8d2e32466d 100644 --- a/astropy/io/misc/ecsv.py +++ b/astropy/io/misc/ecsv.py @@ -741,6 +741,12 @@ def read_data( """ from astropy.table import Table + # A table with no columns has no CSV data section to read: the comment + # lines that follow the header would otherwise be misparsed as the CSV + # data header (see #19895). Return an empty table immediately. + if not header.cols: + return Table() + engine = ECSVEngine.engines[engine_name]() # Get the engine-specific kwargs for reading the CSV data. diff --git a/astropy/io/votable/dataorigin.py b/astropy/io/votable/dataorigin.py index 25e4ff868a5f..05c914b2117f 100644 --- a/astropy/io/votable/dataorigin.py +++ b/astropy/io/votable/dataorigin.py @@ -27,8 +27,11 @@ For more information, please see :ref:`DataOrigin documentation `. """ +import warnings + import astropy.io.votable.tree -from astropy.utils.decorators import deprecated_attribute +from astropy.utils.decorators import deprecated +from astropy.utils.exceptions import AstropyDeprecationWarning __all__ = [ "DataOrigin", @@ -129,8 +132,7 @@ def standardID(self) -> list: @standardID.setter def standardID(self, value: list): """Compatibility with previous version""" - if not self.service_protocol: - self.service_protocol = value + self.service_protocol = value def __str__(self) -> str: s = [] @@ -197,7 +199,10 @@ class DatasetOrigin: list of ```` used by DataOrigin (default: None) """ - _INFO_MAPPING = ("editor", "ivoid") # obsolete INFO + # obsolete INFO names kept for backward compatibility, mapped to their + # current equivalent in DATAORIGIN_INFO + _DEPRECATED_INFO = {"editor": "journal", "ivoid": "data_ivoid"} + _INFO_MAPPING = tuple(_DEPRECATED_INFO) # obsolete INFO def __init__(self, votable_element: astropy.io.votable.tree.Element = None): """ @@ -208,14 +213,12 @@ def __init__(self, votable_element: astropy.io.votable.tree.Element = None): votable_element: astropy.io.votable.tree.Element, optional indicates the VOTable element """ - self.data_ivoid = None self.citation = None self.reference_url = None self.resource_version = None self.rights_uri = None self.rights = None self.creator = None - self.journal = None self.article = None self.cites = None self.is_derived_from = None @@ -224,12 +227,30 @@ def __init__(self, votable_element: astropy.io.votable.tree.Element = None): self.last_update_date = None self.__vo_elt = votable_element self.infos = [] - self.ivoid = deprecated_attribute( - name="ivoid", alternative="data_ivoid", since="8.0" - ) - self.editor = deprecated_attribute( - name="editor", alternative="journal", since="8.0" - ) + self.data_ivoid = None + self.journal = None + + @property + @deprecated("8.0", alternative="data_ivoid") + def ivoid(self): + """Compatibility with previous version (renamed to ``data_ivoid``).""" + return self.data_ivoid + + @ivoid.setter + @deprecated("8.0", alternative="data_ivoid") + def ivoid(self, value): + self.data_ivoid = value + + @property + @deprecated("8.0", alternative="journal") + def editor(self): + """Compatibility with previous version (renamed to ``journal``).""" + return self.journal + + @editor.setter + @deprecated("8.0", alternative="journal") + def editor(self, value): + self.journal = value def get_votable_element(self) -> astropy.io.votable.tree.Element: """ @@ -321,7 +342,7 @@ def __extract_generic_info( if info_name == dataset_info: dataset_origin.infos.append(info) att = getattr(dataset_origin, dataset_info) - if att is None or isinstance(att, property): + if att is None: setattr(dataset_origin, dataset_info, [info.value]) else: att.append(info.value) @@ -577,6 +598,16 @@ def add_data_origin_info( ValueError ``info_name`` is an unknown DataOrigin name. """ + if info_name in DatasetOrigin._INFO_MAPPING: + new_name = DatasetOrigin._DEPRECATED_INFO[info_name] + warnings.warn( + f"The {info_name!r} DataOrigin INFO name is deprecated since " + f"version 8.0, use {new_name!r} instead.", + AstropyDeprecationWarning, + stacklevel=2, + ) + info_name = new_name + if info_name in DATAORIGIN_INFO: if not isinstance( vot_element, diff --git a/astropy/io/votable/src/.gitignore b/astropy/io/votable/src/.gitignore index 970d78fd3f93..3fc66339e375 100644 --- a/astropy/io/votable/src/.gitignore +++ b/astropy/io/votable/src/.gitignore @@ -1 +1,2 @@ !*.c +fast_converters.c diff --git a/astropy/io/votable/src/fast_converters.pyx b/astropy/io/votable/src/fast_converters.pyx index 55f746aabcc6..03bba7d1b6e4 100644 --- a/astropy/io/votable/src/fast_converters.pyx +++ b/astropy/io/votable/src/fast_converters.pyx @@ -105,7 +105,7 @@ cdef inline void swap_bytes_16(unsigned char* data) noexcept nogil: temp = data[0]; data[0] = data[1]; data[1] = temp def fast_binparse_double(const unsigned char[::1] data, - int offset=0, double null_value=0.0, bint has_custom_null=False): + int offset=0, float64_t null_value=0.0, bint has_custom_null=False): """Parse 8-byte double from big-endian VOTable data.""" cdef float64_t value cdef unsigned char temp_data[8] @@ -128,7 +128,7 @@ def fast_binparse_double(const unsigned char[::1] data, return value, is_null def fast_binparse_float(const unsigned char[::1] data, - int offset=0, float null_value=0.0, bint has_custom_null=False): + int offset=0, float32_t null_value=0.0, bint has_custom_null=False): """Parse 4-byte float.""" cdef float32_t value cdef unsigned char temp_data[4] @@ -151,7 +151,7 @@ def fast_binparse_float(const unsigned char[::1] data, return value, is_null def fast_binparse_long(const unsigned char[::1] data, - int offset=0, long null_value=0, bint has_custom_null=False): + int offset=0, int64_t null_value=0, bint has_custom_null=False): """Parse 8-byte signed integer.""" cdef int64_t value cdef unsigned char temp_data[8] @@ -172,7 +172,7 @@ def fast_binparse_long(const unsigned char[::1] data, return value, is_null def fast_binparse_int(const unsigned char[::1] data, - int offset=0, int null_value=0, bint has_custom_null=False): + int offset=0, int32_t null_value=0, bint has_custom_null=False): """Parse 4-byte signed int.""" cdef int32_t value cdef unsigned char temp_data[4] @@ -193,7 +193,7 @@ def fast_binparse_int(const unsigned char[::1] data, return value, is_null def fast_binparse_short(const unsigned char[::1] data, int offset=0, - short null_value=0, bint has_custom_null=False): + int16_t null_value=0, bint has_custom_null=False): """Parse 2-byte signed short.""" cdef int16_t value cdef unsigned char temp_data[2] diff --git a/astropy/io/votable/tests/test_converter.py b/astropy/io/votable/tests/test_converter.py index 49811370171c..7e832aa5d330 100644 --- a/astropy/io/votable/tests/test_converter.py +++ b/astropy/io/votable/tests/test_converter.py @@ -334,3 +334,42 @@ def test_gemini_v1_2(): == "http://www.cadc-ccda.hia-iha.nrc-cnrc.gc.ca/data/pub/GEMINI/" "S20120515S0064?runid=bx9b1o8cvk1qesrt" ) + + +def test_overflow_binary_converter(): + """Regression for #20302: overflow for binary conversion + We test that we can parse a binary votable that contains all numeric types with + extreme values. + """ + content = b""" + + + + + + + + + + + + + + + + + + + + + + + + AACAAIAAAACAAAAAAAAAAP9/////7////////wj/f/9/////f/////////9/f///f+////////8IgAPoAAAAKgAAAAJUC+QAQEj1w0AFvwqLFFdp + + +
+
+
""" + table = parse_single_table(io.BytesIO(content)) + assert list(table.to_table()["bit_column"]) == [False, True, True] diff --git a/astropy/io/votable/tests/test_dataorigin.py b/astropy/io/votable/tests/test_dataorigin.py index 615433497cf6..00cc146492cd 100644 --- a/astropy/io/votable/tests/test_dataorigin.py +++ b/astropy/io/votable/tests/test_dataorigin.py @@ -5,6 +5,7 @@ import astropy.io.votable import astropy.io.votable.dataorigin as dataorigin from astropy.table import Column, Table +from astropy.utils.exceptions import AstropyDeprecationWarning def __generate_votable_test(): @@ -77,6 +78,12 @@ def test_dataorigin(): assert len(str(do)) > 1 assert (do.origin[0].get_votable_element()) is not None assert do.origin[0].journal[0] == __TEST_JOURNAL + assert do.origin[0].data_ivoid[0] == __TEST_DATA_IVOID + # the deprecated names stay backward compatible and redirect to the new ones + with pytest.warns(AstropyDeprecationWarning, match="data_ivoid"): + assert do.origin[0].ivoid[0] == __TEST_DATA_IVOID + with pytest.warns(AstropyDeprecationWarning, match="journal"): + assert do.origin[0].editor[0] == __TEST_JOURNAL dores = dataorigin.extract_data_origin(vot.resources[0]) dot = dataorigin.extract_data_origin(vot.resources[0].tables[0]) @@ -99,6 +106,48 @@ def test_dataorigin(): assert len(resource.creator) > 0 +def test_dataorigin_deprecated_attributes(): + # The 1.2 names ``ivoid`` and ``editor`` were renamed to ``data_ivoid`` and + # ``journal``. They must still work for backward compatibility while emitting + # a deprecation warning that points at the new name. + origin = dataorigin.DatasetOrigin() + origin.data_ivoid = ["ivo://id"] + origin.journal = ["AJ"] + + with pytest.warns(AstropyDeprecationWarning, match="data_ivoid"): + assert origin.ivoid == ["ivo://id"] + with pytest.warns(AstropyDeprecationWarning, match="journal"): + assert origin.editor == ["AJ"] + + # the deprecated setters must redirect to the new attributes + with pytest.warns(AstropyDeprecationWarning, match="data_ivoid"): + origin.ivoid = ["ivo://other"] + assert origin.data_ivoid == ["ivo://other"] + + with pytest.warns(AstropyDeprecationWarning, match="journal"): + origin.editor = ["ApJ"] + assert origin.journal == ["ApJ"] + + +def test_add_data_origin_info_deprecated_names(): + # Passing an obsolete INFO name to add_data_origin_info must emit a + # deprecation warning and store the value under the current name. + vot = __generate_votable_test() + with pytest.warns(AstropyDeprecationWarning, match="data_ivoid"): + dataorigin.add_data_origin_info(vot.resources[0], "ivoid", __TEST_DATA_IVOID) + with pytest.warns(AstropyDeprecationWarning, match="journal"): + dataorigin.add_data_origin_info(vot.resources[0], "editor", __TEST_JOURNAL) + + # the obsolete names are translated to the current vocabulary + names = [info.name for info in vot.resources[0].infos] + assert "data_ivoid" in names and "journal" in names + assert "ivoid" not in names and "editor" not in names + + do = dataorigin.extract_data_origin(vot) + assert do.origin[0].data_ivoid[0] == __TEST_DATA_IVOID + assert do.origin[0].journal[0] == __TEST_JOURNAL + + def test_dataorigin_unsupported_input_error(): table = Table.read(__generate_votable_test()) diff --git a/astropy/logger.py b/astropy/logger.py index 5dc5d806cf27..d10b9c30cd2c 100644 --- a/astropy/logger.py +++ b/astropy/logger.py @@ -229,7 +229,7 @@ def _showwarning(self, *args, **kwargs): # name. The module.__file__ is the original source file name. mod_name = None mod_path = Path(mod_path).with_suffix("") - for mod in sys.modules.values(): + for mod in list(sys.modules.values()): try: # Believe it or not this can fail in some cases: # https://github.com/astropy/astropy/issues/2671 @@ -455,8 +455,6 @@ def log_to_list(self, filter_level=None, filter_origin=None): Parameters ---------- - filename : str - The file to log messages to. filter_level : str If set, any log messages less important than ``filter_level`` will not be output to the file. Note that this is in addition to the diff --git a/astropy/modeling/_fitting_parallel.py b/astropy/modeling/_fitting_parallel.py index 374de6731836..44ca5e84ff27 100644 --- a/astropy/modeling/_fitting_parallel.py +++ b/astropy/modeling/_fitting_parallel.py @@ -382,7 +382,7 @@ def parallel_fit_dask( The fitter to use in the fitting process. data : `numpy.ndarray` or `dask.array.core.Array` The N-dimensional data to fit. - data_units : `astropy.units.Unit` + data_unit : `astropy.units.Unit` Units for the data array, for when the data array is not a ``Quantity`` instance. weights : `numpy.ndarray`, `dask.array.core.Array` or `astropy.nddata.NDUncertainty` diff --git a/astropy/modeling/bounding_box.py b/astropy/modeling/bounding_box.py index 72a7992e4c95..54a355092a9a 100644 --- a/astropy/modeling/bounding_box.py +++ b/astropy/modeling/bounding_box.py @@ -494,11 +494,9 @@ def _evaluate( ---------- evaluate : Callable callable which takes in the valid inputs to evaluate model - valid_inputs : list of numpy arrays - The inputs reduced to just those inputs which are all inside - their respective bounding box intervals - valid_index : numpy array - array of all indices inside the bounding box + inputs : list of numpy arrays + The inputs to reduce to those inside their respective bounding + box intervals input_shape : tuple The shape that all inputs have be reshaped/broadcasted into fill_value : float @@ -554,11 +552,9 @@ def evaluate(self, evaluate: Callable, inputs, fill_value): ---------- evaluate : callable callable which takes in the valid inputs to evaluate model - valid_inputs : list - The inputs reduced to just those inputs which are all inside - their respective bounding box intervals - valid_index : array_like - array of all indices inside the bounding box + inputs : list + The inputs to reduce to those inside their respective bounding + box intervals fill_value : float The value which will be assigned to inputs which are outside the bounding box @@ -816,7 +812,7 @@ def fix_inputs(self, model, fixed_inputs: dict, _keep_ignored=False) -> Self: The new model for which this will be a bounding_box fixed_inputs : dict Dictionary of inputs which have been fixed by this bounding box. - keep_ignored : bool + _keep_ignored : bool Keep the ignored inputs of the bounding box (internal argument only) """ new = self.copy() diff --git a/astropy/modeling/functional_models.py b/astropy/modeling/functional_models.py index 7b1fd5e2ef44..16e4448dc992 100644 --- a/astropy/modeling/functional_models.py +++ b/astropy/modeling/functional_models.py @@ -383,8 +383,10 @@ def __init__( eig_vals, eig_vecs = np.linalg.eig(cov_matrix) if not NUMPY_LT_2_5 or eig_vals.dtype.kind == "c": # in numpy 2.5+, return values are *always* complex - assert np.all(eig_vals.imag == 0) - assert np.all(eig_vecs.imag == 0) + if np.any(eig_vals.imag != 0) or np.any(eig_vecs.imag != 0): + raise TypeError( + "Expected cov_matrix's eigen values to be real. Complex values were found." + ) eig_vals = eig_vals.real eig_vecs = eig_vecs.real diff --git a/astropy/modeling/parameters.py b/astropy/modeling/parameters.py index 74798ffab6a2..49994760720e 100644 --- a/astropy/modeling/parameters.py +++ b/astropy/modeling/parameters.py @@ -241,6 +241,7 @@ def __init__( # use this to convert to and from the public unit defined for the # parameter. self._internal_unit = None + self._internal_value = None if not self._model_required: if self._default is not None: self.value = self._default @@ -779,6 +780,12 @@ def _wrapper(value, raw_unit=None, orig_unit=None): orig_unit is the value after the ufunc has been applied it is assumed ufunc(raw_unit) == orig_unit """ + # Make sure value is ufunc compatible + # parameters are expected to be real floats + # If the value is `None` this will result in a NaN + if not isinstance(value, Quantity): + value = np.float64(value) + if orig_unit is not None: return ufunc(value) * orig_unit elif raw_unit is not None: diff --git a/astropy/modeling/separable.py b/astropy/modeling/separable.py index e180603eace4..422b82143de5 100644 --- a/astropy/modeling/separable.py +++ b/astropy/modeling/separable.py @@ -293,7 +293,8 @@ def _separable(transform): transform : `astropy.modeling.Model` A transform (usually a compound model). - Returns : + Returns + ------- is_separable : ndarray of dtype np.bool An array of shape (transform.n_outputs,) of boolean type Each element represents the separablity of the corresponding output. diff --git a/astropy/modeling/tests/test_parameters.py b/astropy/modeling/tests/test_parameters.py index 4231d22dcc6f..4f1caeddbf24 100644 --- a/astropy/modeling/tests/test_parameters.py +++ b/astropy/modeling/tests/test_parameters.py @@ -743,6 +743,10 @@ def test_value(self): assert not isinstance(param.value, np.ndarray) assert param.value == 1 + param = Parameter(name="test", setter=setter1, getter=getter1) + assert not isinstance(param.value, np.ndarray) + assert np.isnan(param.value) + def test_raw_value(self): param = Parameter(name="test", default=[1, 2, 3, 4]) @@ -848,6 +852,11 @@ def test_param_repr_oneline(self): param = Parameter(name="test", default=[1, 2, 3, 4] * u.m) assert param_repr_oneline(param) == "[1., 2., 3., 4.] m" + def test_param_repr_getter_no_value(self): + """Regression test for #19963""" + param = Parameter(name="test", getter=np.deg2rad, setter=np.rad2deg) + assert repr(param) == "Parameter('test', value=nan)" + def test_getter_setter(self): msg = "setter and getter must both be input" with pytest.raises(ValueError, match=msg): diff --git a/astropy/nddata/blocks.py b/astropy/nddata/blocks.py index a0cf6619b8c5..ecfe651f917d 100644 --- a/astropy/nddata/blocks.py +++ b/astropy/nddata/blocks.py @@ -207,7 +207,8 @@ def block_replicate(data, block_size, conserve_sum=True): data = np.repeat(data, block_size[i], axis=i) if conserve_sum: - # in-place division can fail due to dtype casting rule - data = data / np.prod(block_size) + # in-place division can fail due to dtype casting rule; + # cast to Python int to avoid promoting float16/float32/complex64 under NEP 50 + data = data / int(np.prod(block_size)) return data diff --git a/astropy/nddata/nduncertainty.py b/astropy/nddata/nduncertainty.py index fc7c37eb1d64..8a897aafa8b4 100644 --- a/astropy/nddata/nduncertainty.py +++ b/astropy/nddata/nduncertainty.py @@ -556,10 +556,6 @@ def _propagate_collapse(self, numpy_op, axis=None): numpy_op : function Numpy operation like `np.sum` or `np.max` to use in the collapse - subtract : bool, optional - If ``True``, propagate for subtraction, otherwise propagate for - addition. - axis : tuple, optional Axis on which to compute collapsing operations. """ diff --git a/astropy/nddata/tests/test_blocks.py b/astropy/nddata/tests/test_blocks.py index 92096f5e7d15..0fd1fad73fbf 100644 --- a/astropy/nddata/tests/test_blocks.py +++ b/astropy/nddata/tests/test_blocks.py @@ -171,3 +171,21 @@ def test_block_size_len(self): data = np.arange(5) with pytest.raises(ValueError): block_replicate(data, (2, 2)) + + @pytest.mark.parametrize( + ("in_dtype", "expected_dtype"), + [ + (np.float16, np.float16), + (np.float32, np.float32), + (np.float64, np.float64), + (np.complex64, np.complex64), + (np.complex128, np.complex128), + (np.int32, np.float64), + ], + ) + def test_conserve_sum_dtype(self, in_dtype, expected_dtype): + # Regression test for #20360: conserve_sum=True should preserve float/complex dtypes + data = np.ones((2, 2), dtype=in_dtype) + result = block_replicate(data, 2, conserve_sum=True) + assert result.dtype == expected_dtype + assert block_replicate(data, 2, conserve_sum=False).dtype == in_dtype diff --git a/astropy/nddata/tests/test_utils.py b/astropy/nddata/tests/test_utils.py index b0a1d3319468..dd9430dba08b 100644 --- a/astropy/nddata/tests/test_utils.py +++ b/astropy/nddata/tests/test_utils.py @@ -19,6 +19,7 @@ subpixel_indices, ) from astropy.tests.helper import assert_quantity_allclose +from astropy.utils.compat.optional_deps import HAS_MATPLOTLIB from astropy.wcs import WCS, Sip from astropy.wcs.utils import proj_plane_pixel_area @@ -700,6 +701,41 @@ def test_crpix_maps_to_crval(self): w.all_pix2world(*w.wcs.crpix, 1), w.wcs.crval, rtol=0.0, atol=1e-6 * pscale ) + @pytest.mark.skipif(not HAS_MATPLOTLIB, reason="requires matplotlib") + @pytest.mark.parametrize( + "position, size", + [ + ((2, 2), (4, 4)), # even-sized cutout + ((2, 2), (3, 3)), # odd-sized cutout + ((2.1, 1.9), (3, 5)), # non-integer position, asymmetric size + ], + ) + def test_plot_on_original(self, position, size): + """ + Regression test to ensure plot_on_original draws a rectangle + that matches the actual pixel extent of the cutout. + """ + import matplotlib.pyplot as plt + + cutout = Cutout2D(self.data, position, size) + + fig, ax = plt.subplots() + cutout.plot_on_original(ax=ax) + patch = ax.patches[0] + + # The rectangle should span from (xmin - 0.5) to (xmax + 0.5) + # and (ymin - 0.5) to (ymax + 0.5) in pixel coordinates. + x0, y0 = patch.get_xy() + width = patch.get_width() + height = patch.get_height() + + assert_allclose(x0, cutout.xmin_original - 0.5) + assert_allclose(y0, cutout.ymin_original - 0.5) + assert_allclose(width, cutout.xmax_original - cutout.xmin_original + 1) + assert_allclose(height, cutout.ymax_original - cutout.ymin_original + 1) + + plt.close(fig) + def test_cutout_with_nddata_as_input(self): # This is essentially a copy/paste of test_skycoord with the # input a ccd with wcs attribute instead of passing the diff --git a/astropy/nddata/utils.py b/astropy/nddata/utils.py index c4a85beb27b2..f617a2502af5 100644 --- a/astropy/nddata/utils.py +++ b/astropy/nddata/utils.py @@ -824,11 +824,19 @@ def plot_on_original(self, ax=None, fill=False, **kwargs): if ax is None: ax = plt.gca() - height, width = self.shape - hw, hh = width / 2.0, height / 2.0 - pos_xy = self.position_original - np.array([hw, hh]) + xmin = self.xmin_original - 0.5 + xmax = self.xmax_original + 0.5 + + ymin = self.ymin_original - 0.5 + ymax = self.ymax_original + 0.5 + + pos_xy = (xmin, ymin) + width = xmax - xmin + height = ymax - ymin + patch = mpatches.Rectangle(pos_xy, width, height, angle=0.0, **kwargs) ax.add_patch(patch) + return ax @staticmethod diff --git a/astropy/samp/__init__.py b/astropy/samp/__init__.py index cc5c8db9cc38..3861d180f4b5 100644 --- a/astropy/samp/__init__.py +++ b/astropy/samp/__init__.py @@ -1,5 +1,9 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst """ +.. warning:: + ``astropy.samp`` was deprecated in version 8.0 and will be removed in a future version; + please use ``pyvo.samp`` instead. + This subpackage provides classes to communicate with other applications via the `Simple Application Messaging Protocol (SAMP) `_. @@ -9,7 +13,10 @@ (INAF - Istituto Nazionale di Astrofisica). """ +import warnings + from astropy import config as _config +from astropy.utils.exceptions import AstropyDeprecationWarning from .client import * from .constants import * @@ -19,6 +26,17 @@ from .integrated_client import * from .utils import * +warnings.warn( + "astropy.samp was deprecated in version 8.0 " + "and will be removed in a future version; " + "please use pyvo.samp instead.", + AstropyDeprecationWarning, +) + +# Clean up namespace +del warnings +del AstropyDeprecationWarning + class Conf(_config.ConfigNamespace): """ diff --git a/astropy/stats/histogram.py b/astropy/stats/histogram.py index e5b6bc6de7db..c4fcf59a4788 100644 --- a/astropy/stats/histogram.py +++ b/astropy/stats/histogram.py @@ -61,14 +61,17 @@ def calculate_bin_edges( bins : ndarray Histogram bin edges """ - # if range is specified, we need to truncate the data for - # the bin-finding routines - if range is not None: - a = a[(a >= range[0]) & (a <= range[1])] - # if bins is a string, first compute bin edges with the desired heuristic if isinstance(bins, str): + # Astropy's bin-width estimators (knuth, scott, freedman, blocks) + # compute bin widths from the data distribution, so they should only + # consider values within the requested range. Filter `a` here. + # + # Weights are not supported for these estimators (see the + # NotImplementedError below), so they do not need to be filtered. a = np.asarray(a).ravel() + if range is not None: + a = a[(a >= range[0]) & (a <= range[1])] # TODO: if weights is specified, we need to modify things. # e.g. we could use point measures fitness for Bayesian blocks @@ -99,7 +102,8 @@ def calculate_bin_edges( bins[-1] = range[1] elif np.ndim(bins) == 0: - # Number of bins was given + # Number of bins was given. Delegate range and weight handling + # to NumPy's histogram_bin_edges() bins = np.histogram_bin_edges(a, bins, range=range, weights=weights) return bins diff --git a/astropy/stats/src/compute_bounds.c b/astropy/stats/src/compute_bounds.c index 59fcb2a21d98..7808b0c09184 100644 --- a/astropy/stats/src/compute_bounds.c +++ b/astropy/stats/src/compute_bounds.c @@ -72,7 +72,7 @@ void compute_sigma_clipped_bounds( } } - if (new_count == count) { + if (new_count == count || new_count == 0) { return; } diff --git a/astropy/stats/tests/test_histogram.py b/astropy/stats/tests/test_histogram.py index 0c0432f51803..ef3bbdf72832 100644 --- a/astropy/stats/tests/test_histogram.py +++ b/astropy/stats/tests/test_histogram.py @@ -48,7 +48,10 @@ def test_freedman_bin_width(): # data with too small IQR test_x = [1, 2, 3] + [4] * 100 + [5, 6, 7] with pytest.raises(ValueError, match=r"Please use another bin method"): - with pytest.warns(RuntimeWarning, match=r"divide by zero encountered"): + with pytest.warns( + RuntimeWarning, + match=r"divide by zero encountered|invalid value encountered in ceil", + ): freedman_bin_width(test_x, return_bins=True) # data with small IQR but not too small @@ -182,3 +185,11 @@ def test_histogram_badargs(): # bad bins arg gives ValueError with pytest.raises(ValueError): histogram(x, bins="bad_argument") + + +def test_histogram_range_weights(): + data = np.array([-10.0, 2.5, 5.0, 7.5, 50.0]) + weights = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + hist, edges = histogram(data, bins=5, range=(0, 10), weights=weights) + assert_allclose(hist, [0.0, 2.0, 3.0, 4.0, 0.0]) + assert_allclose(edges, [0.0, 2.0, 4.0, 6.0, 8.0, 10.0]) diff --git a/astropy/stats/tests/test_sigma_clipping.py b/astropy/stats/tests/test_sigma_clipping.py index c71d324c8f0d..8fb6bba6ef3a 100644 --- a/astropy/stats/tests/test_sigma_clipping.py +++ b/astropy/stats/tests/test_sigma_clipping.py @@ -135,6 +135,21 @@ def test_sigma_clip_mean(): assert_equal(sobj1(data, axis=0), sobj2(data, axis=0)) +def test_sigma_clip_fast_all_rejected(): + # Regression test for gh-20331: when an iteration of the fast C + # implementation rejects every remaining value, the next iteration + # must not run on an empty buffer. + data = np.array([[0.0, 0.0, 1000.0], [1.0, 2.0, 3.0]]) + sobj1 = SigmaClip(sigma=2, maxiters=3, cenfunc="mean", stdfunc="mad_std") + sobj2 = SigmaClip(sigma=2, maxiters=3, cenfunc=np.nanmean, stdfunc=mad_std) + result1, lower1, upper1 = sobj1(data, axis=1, return_bounds=True) + result2 = sobj2(data, axis=1) + assert_equal(result1.mask, [[True, True, True], [False, False, False]]) + assert_equal(result1.mask, result2.mask) + assert np.all(np.isfinite(lower1)) + assert np.all(np.isfinite(upper1)) + + def test_sigma_clip_invalid_cenfunc_stdfunc(): with pytest.raises(ValueError): SigmaClip(cenfunc="invalid") diff --git a/astropy/table/_dataframes.py b/astropy/table/_dataframes.py index 4047ba11ac54..95299e2b0961 100644 --- a/astropy/table/_dataframes.py +++ b/astropy/table/_dataframes.py @@ -35,13 +35,24 @@ INTEGER_DTYPE_KINDS = frozenset({"u", "i"}) -def _numpy_to_pandas_dtype(dtype: np.dtype) -> str: - """Convert a numpy dtype to a pandas dtype string, handling nullable integers.""" - dtype_name = dtype.name - # Special case needed for uint -> UInt - if dtype_name.startswith("uint"): - return "UInt" + dtype_name.removeprefix("uint") - return dtype.name.title() +def _pandas_nullable_int_array(data, mask): + """Make a pandas nullable integer array from integer ``data`` and ``mask``. + + The obvious ways of getting a masked integer column into pandas (letting + pandas apply the mask and then casting to a nullable dtype, or passing a + masked array to ``Series(..., dtype="Int64")``) both go through a float64 + intermediate. That silently corrupts integers which are not exactly + representable as float64, i.e. those above 2**53, such as Gaia source ids + (gh-14442). Building the nullable array from the integer values and the + mask directly avoids the float intermediate entirely. + """ + from pandas.arrays import IntegerArray + + data = np.asarray(data) + if not data.dtype.isnative: + data = data.byteswap().view(data.dtype.newbyteorder("=")) + # copy=True since ``data`` and ``mask`` are generally views on the table. + return IntegerArray(data, np.asarray(mask), copy=True) def _encode_mixins(tbl: Table) -> Table: @@ -256,18 +267,21 @@ def to_df( # Convert to dataframe df_native = df_nw.to_native() - # Fix pandas-like nullable integers + # Fix pandas-like nullable integers. These are rebuilt from the original + # integer data and mask rather than cast from the masked (hence float64) + # column in ``df_native``, which would corrupt values above 2**53. if backend_impl.is_pandas_like() and tbl.has_masked_columns and use_nullable_int: for name in masked_cols: - dtype = array[name].dtype - if (dtype := array[name].dtype).kind not in INTEGER_DTYPE_KINDS: + if array[name].dtype.kind not in INTEGER_DTYPE_KINDS: continue - df_native[name] = df_native[name].astype(_numpy_to_pandas_dtype(dtype)) + df_native[name] = _pandas_nullable_int_array( + array[name].data, array[name].mask + ) # Pandas-like index if index: - df_native.set_index(index, inplace=True, drop=True) + df_native = df_native.set_index(index, drop=True) return df_native @@ -301,7 +315,7 @@ def from_df( index_name = str(df.index.name or "index") while index_name in df.columns: index_name = "_" + index_name + "_" - df.reset_index(index_name, inplace=True, drop=False) + df = df.reset_index(index_name, drop=False) # Narwhals layer, must convert to eager df_nw = nw.from_native(df) @@ -424,18 +438,15 @@ def to_pandas( if isinstance(column, MaskedColumn) and np.any(column.mask): if column.dtype.kind in ["i", "u"]: - pd_dtype = column.dtype.name - if use_nullable_int: - # Convert int64 to Int64, uint32 to UInt32, etc for nullable types - pd_dtype = pd_dtype.replace("i", "I").replace("u", "U") - else: + if not use_nullable_int: from pandas.errors import IntCastingNaNError raise IntCastingNaNError( "Cannot convert masked integer columns to DataFrame without using nullable integers. " f"Set use_nullable_int=True or remove the offending column: {name}." ) - out[name] = Series(out[name], dtype=pd_dtype) + # Convert int64 to Int64, uint32 to UInt32, etc for nullable types + out[name] = Series(_pandas_nullable_int_array(out[name], column.mask)) elif column.dtype.kind not in ["f", "c"]: out[name] = column.astype(object).filled(np.nan) diff --git a/astropy/table/bst.py b/astropy/table/bst.py index 40f97ec68a8f..c7eeab6061b1 100644 --- a/astropy/table/bst.py +++ b/astropy/table/bst.py @@ -451,22 +451,26 @@ def same_prefix(self, val): return [x for node in nodes for x in node.data] def _range(self, lower, upper, op1, op2, node, lst): + # In-order traversal (left, node, right) so that matching nodes + # are collected in ascending key order. + if lower < node.key and node.left is not None: + self._range(lower, upper, op1, op2, node.left, lst) if op1(lower, node.key) and op2(upper, node.key): lst.append(node) if upper > node.key and node.right is not None: self._range(lower, upper, op1, op2, node.right, lst) - if lower < node.key and node.left is not None: - self._range(lower, upper, op1, op2, node.left, lst) return lst def _same_prefix(self, val, node, lst): + # In-order traversal (left, node, right) so that matching nodes + # are collected in ascending key order. prefix = node.key[: len(val)] + if prefix >= val and node.left is not None: + self._same_prefix(val, node.left, lst) if prefix == val: lst.append(node) if prefix <= val and node.right is not None: self._same_prefix(val, node.right, lst) - if prefix >= val and node.left is not None: - self._same_prefix(val, node.left, lst) return lst def __repr__(self): diff --git a/astropy/table/column.py b/astropy/table/column.py index a7bd40bbee14..dd275a64145c 100644 --- a/astropy/table/column.py +++ b/astropy/table/column.py @@ -9,7 +9,7 @@ from numpy import ma from astropy.units import Quantity, StructuredUnit, Unit -from astropy.utils.compat import NUMPY_LT_2_5 +from astropy.utils.compat import NUMPY_LT_2_5, NUMPY_LT_2_6 from astropy.utils.console import color_print from astropy.utils.data_info import BaseColumnInfo, dtype_info_name from astropy.utils.exceptions import AstropyDeprecationWarning @@ -1701,6 +1701,31 @@ def __deepcopy__(self, memo=None): return out + def __array_wrap__(self, out_arr, context=None, return_scalar=False): + out_arr = super().__array_wrap__(out_arr, context, return_scalar) + + if NUMPY_LT_2_6: + # Workaround for a numpy.ma bug, fixed upstream by + # https://github.com/numpy/numpy/pull/32423: MaskedArray's + # _update_from copied the input's raw `_fill_value` into the + # output without checking it was still valid for the output's + # dtype. Mirror the fix numpy made to _update_from here. + fill_value = getattr(out_arr, "_fill_value", None) + out_dtype = getattr(out_arr, "dtype", None) + if ( + fill_value is not None + and out_dtype is not None + and out_dtype != self.dtype + ): + try: + out_arr._fill_value = ma.core._check_fill_value( + fill_value, out_dtype + ) + except (TypeError, ValueError, OverflowError): + out_arr._fill_value = None + + return out_arr + @property def fill_value(self): return self.get_fill_value() # defer to native ma.MaskedArray method diff --git a/astropy/table/groups.py b/astropy/table/groups.py index 55c670770d05..a2d352fe746d 100644 --- a/astropy/table/groups.py +++ b/astropy/table/groups.py @@ -215,7 +215,11 @@ def __getitem__(self, item): mask[i0:i1] = True out = parent[mask] out.groups._keys = parent.groups.keys[item] - out.groups._indices = np.concatenate([[0], np.cumsum(i1s - i0s)]) + out.groups._indices = ( + np.array([], dtype=int) # No selected groups so no indices + if len(out) == 0 + else np.concatenate([[0], np.cumsum(i1s - i0s)]) + ) return out @@ -223,7 +227,11 @@ def __repr__(self): return f"<{self.__class__.__name__} indices={self.indices}>" def __len__(self): - return len(self.indices) - 1 + _len = len(self.indices) + if _len == 1: + # Should never happen, indices should either have length = 0 or length >= 2. + raise RuntimeError("malformed groups.indices with length=1, this is a bug") + return _len - 1 if _len > 0 else 0 class ColumnGroups(BaseGroups): @@ -240,7 +248,11 @@ def indices(self): return self.parent_table.groups.indices else: if self._indices is None: - return np.array([0, len(self.parent_column)]) + # No explicit groups have been defined so default to a single group if + # the column has any rows, otherwise return an empty array of indices to + # match what group_by() does for an empty column. + _len = len(self.parent_column) + return np.array([0, _len]) if _len > 0 else np.array([], dtype=int) else: return self._indices @@ -344,7 +356,11 @@ def key_colnames(self): @property def indices(self): if self._indices is None: - return np.array([0, len(self.parent_table)]) + # No explicit groups have been defined so default to a single group if + # the table has any rows, otherwise return an empty array of indices to + # match what group_by() does for an empty table. + _len = len(self.parent_table) + return np.array([0, _len]) if _len > 0 else np.array([], dtype=int) else: return self._indices diff --git a/astropy/table/operations.py b/astropy/table/operations.py index f1b71da10fb6..d77bdb9b1f2d 100644 --- a/astropy/table/operations.py +++ b/astropy/table/operations.py @@ -713,7 +713,7 @@ def vstack(tables, join_type="outer", metadata_conflicts="warn"): tables = _get_list_of_tables(tables) # validates input if len(tables) == 1: - return tables[0] # no point in stacking a single table + return tables[0].copy() out = _vstack(tables, join_type, metadata_conflicts) diff --git a/astropy/table/pprint.py b/astropy/table/pprint.py index 4d0d59581c91..8208a8128f2b 100644 --- a/astropy/table/pprint.py +++ b/astropy/table/pprint.py @@ -58,7 +58,7 @@ def get_auto_format_func( Parameters ---------- - col_name : object, optional + col : object, optional Hashable object to identify column like id or name. Default is None. possible_string_format_functions : func, optional diff --git a/astropy/table/sorted_array.py b/astropy/table/sorted_array.py index ea179302c5f6..eb8af8fe2c1d 100644 --- a/astropy/table/sorted_array.py +++ b/astropy/table/sorted_array.py @@ -93,7 +93,7 @@ def _get_key_slice(self, i, begin, end): def find_pos(self, key, data, exact=False): """ - Return the index of the largest key in data greater than or + Return the index of the first key in data greater than or equal to the given key, data pair. Parameters @@ -190,27 +190,40 @@ def range( argument corresponds to an inclusive lower bound, and the second argument to an inclusive upper bound. """ - # Find initial positions for lower and upper bounds. Just like a slice object, - # None values for `lower` or `upper` correspond to no bound in that direction. - lower_pos = 0 if lower is None else self.find_pos(lower, 0) - upper_pos = len(self.row_index) if upper is None else self.find_pos(upper, 0) - - if lower_pos == len(self.row_index): - return [] - - lower_bound = tuple(col[lower_pos] for col in self.cols) - if not bounds[0] and lower_bound == lower: - lower_pos += 1 # data[lower_pos] > lower - - # data[lower_pos] >= lower - # data[upper_pos] >= upper - if upper_pos < len(self.row_index): - upper_bound = tuple(col[upper_pos] for col in self.cols) - if not bounds[1] and upper_bound == upper: - upper_pos -= 1 # data[upper_pos] < upper - elif upper_bound > upper: - upper_pos -= 1 # data[upper_pos] <= upper - return self.row_index[lower_pos : upper_pos + 1] + n = len(self.row_index) + + def bisect_left(key): + # Position of the first entry whose key is >= ``key``. + return self.find_pos(key, 0) + + def bisect_right(key): + # Position of the first entry whose key is > ``key`` (i.e. just + # past the last entry equal to ``key``). + if self.unique: + pos = self.find_pos(key, 0) + if pos < n and tuple(col[pos] for col in self.cols) == key: + pos += 1 + return pos + # For a non-unique index the stored keys include the row number, so + # searching with a row number larger than any real row lands just + # past the last entry equal to ``key`` (and thus past all duplicates). + return self.find_pos(key, n) + + # Compute a half-open range [lower_pos, upper_pos). Just like a slice + # object, a None value for `lower` or `upper` means no bound in that + # direction. Using bisect_left/bisect_right ensures all entries that + # share a value with an inclusive bound are included, even duplicates. + if lower is None: + lower_pos = 0 + else: + lower_pos = bisect_left(lower) if bounds[0] else bisect_right(lower) + + if upper is None: + upper_pos = n + else: + upper_pos = bisect_right(upper) if bounds[1] else bisect_left(upper) + + return self.row_index[lower_pos:upper_pos] def remove(self, key: tuple, data: int) -> bool: """ diff --git a/astropy/table/table.py b/astropy/table/table.py index ecbc8c33c814..fc4def1aa85d 100644 --- a/astropy/table/table.py +++ b/astropy/table/table.py @@ -3829,7 +3829,11 @@ def copy(self, copy_data=True): def __deepcopy__(self, memo=None): out = self.copy(False) for name in out.colnames: - out.columns.__setitem__(name, deepcopy(self[name]), validated=True) + new_col = deepcopy(self[name]) + out.columns.__setitem__(name, new_col, validated=True) + # The deep-copied column dropped its link to the original parent + # table, so re-link it to the copied table (#20087). + out._set_col_parent_table_and_mask(new_col) out.meta = deepcopy(self.meta) return out diff --git a/astropy/table/tests/test_column.py b/astropy/table/tests/test_column.py index 321543cd1abe..d5818318ed6e 100644 --- a/astropy/table/tests/test_column.py +++ b/astropy/table/tests/test_column.py @@ -458,6 +458,25 @@ def test_insert_masked_multidim(self): with pytest.raises(ValueError): c1 = c.insert(1, [100, 200], mask=[True, False, True]) + def test_masked_string_ufunc_dtype_change_fill_value(self): + """ + Regression test for a ufunc that changes the dtype of a + MaskedColumn (e.g. np.strings.find applied to a string column, + which returns an int array). + See https://github.com/astropy/astropy/issues/20257 + """ + col = table.MaskedColumn( + ["foo", "bar", "baz"], mask=False, fill_value="N/A", dtype="U3" + ) + assert col.fill_value == "N/A" + assert col.dtype.kind == "U" + # The following used to blow up with TypeError + result = np.strings.find(col, "foo") + + assert result.fill_value != "N/A" + assert result.dtype.kind == "i" + assert result[0] == 0 # "foo" is found at index 0 + def test_mask_on_non_masked_table(self): """ When table is not masked and trying to set mask on column then diff --git a/astropy/table/tests/test_df.py b/astropy/table/tests/test_df.py index 47ba7956df43..8fe4d0159916 100644 --- a/astropy/table/tests/test_df.py +++ b/astropy/table/tests/test_df.py @@ -454,6 +454,28 @@ def test_nullable_int(self, backend, use_legacy_pandas_api, unsigned, bits): assert_array_equal(t2["col0"].mask, [False, True]) assert_array_equal(t2["col0"], c) + @pytest.mark.parametrize("unsigned", ["u", ""]) + def test_nullable_int_no_float_intermediate( + self, backend, use_legacy_pandas_api, unsigned + ): + """Masked 64-bit ints above 2**53 must not be converted via float64. + + Regression test for gh-14442, where masked Gaia source ids came back + from ``to_pandas()`` silently rounded to the nearest float64. + """ + np_dtype = f"{unsigned}int64" + # Values that a float64 (53-bit mantissa) cannot represent exactly. + values = [np.iinfo(np_dtype).max, 2**62 + 1, 3] + c = MaskedColumn(np.array(values, dtype=np_dtype), mask=[False, False, True]) + t = Table([c]) + + df = self._to_dataframe(t, backend, use_legacy_pandas_api) + + t2 = self._from_dataframe(df, backend, use_legacy_pandas_api) + assert str(t2["col0"].dtype) == np_dtype + assert_array_equal(t2["col0"].mask, [False, False, True]) + assert_array_equal(t2["col0"][:2], values[:2]) + @pytest.mark.parametrize("ndim", [1, 2, 3]) def test_nd_columns(self, backend, use_legacy_pandas_api, ndim): """Test handling of multidimensional columns.""" diff --git a/astropy/table/tests/test_groups.py b/astropy/table/tests/test_groups.py index e5ca374a461e..75bc706d6076 100644 --- a/astropy/table/tests/test_groups.py +++ b/astropy/table/tests/test_groups.py @@ -581,10 +581,25 @@ def test_table_aggregate_reduceat_empty(): }, masked=masked, ) - tga = tg.group_by("action").groups.aggregate(np.sum) + # Test default groups for empty table + assert len(tg.groups) == 0 + assert list(tg.groups) == [] + + # Test explicit grouping by one column + grouped = tg.group_by("action") + assert len(grouped.groups) == 0 + assert list(grouped.groups) == [] + tga = grouped.groups.aggregate(np.sum) assert tga.pformat() == ["action duration", "------ --------"] +def test_groups_len_malformed_indices_raises_runtime_error(): + tg = Table({"a": [1, 2], "b": [3, 4]}).group_by("a") + tg.groups._indices = np.array([1], dtype=int) + with pytest.raises(RuntimeError, match="malformed groups.indices"): + len(tg.groups) + + def test_column_aggregate(T1): """ Aggregate a single table column @@ -604,6 +619,17 @@ def test_column_aggregate_f8(): assert tga.pformat() == [" a ", "---", "0.0", "1.0"] +def test_table_group_select_empty(): + """Test selecting no groups returns a table with empty keys and no indices""" + tg = Table({"a": [1, 2], "b": [3, 4]}).group_by("a") + tgs = tg.groups[[]] + assert len(tgs) == 0 + assert len(tgs.groups) == 0 + assert len(tgs.groups.keys) == 0 + assert len(tgs.groups.indices) == 0 + assert tgs.groups.keys.colnames == ["a"] + + def test_table_filter(): """ Table groups filtering diff --git a/astropy/table/tests/test_index.py b/astropy/table/tests/test_index.py index 7cddbd81f6c8..06ddf033972c 100644 --- a/astropy/table/tests/test_index.py +++ b/astropy/table/tests/test_index.py @@ -1122,3 +1122,21 @@ def test_index_not_corrupted_on_failed_row_assignment(engine): # No ghost entry for the attempted new value with pytest.raises(KeyError): t.loc[99] + + +def test_loc_range_sorted_after_add_row(engine): + """Regression test: a range query must return rows in ascending key order, + also for rows added after the index was created. + + The ``BST`` engine previously collected nodes in node-right-left order, + so after ``add_row`` the tree was no longer a degenerate chain and + ``t.loc[lower:upper]`` returned rows in scrambled order. + """ + t = Table([[1, 5, 9], [10, 50, 90]], names=("a", "b")) + t.add_index("a", engine=engine) + for a, b in [(7, 70), (3, 30), (8, 80), (2, 20), (6, 60), (4, 40)]: + t.add_row((a, b)) + + assert t.loc[2:8]["a"].tolist() == [2, 3, 4, 5, 6, 7, 8] + assert t.loc[2:8]["b"].tolist() == [20, 30, 40, 50, 60, 70, 80] + assert t.loc[:]["a"].tolist() == [1, 2, 3, 4, 5, 6, 7, 8, 9] diff --git a/astropy/table/tests/test_operations.py b/astropy/table/tests/test_operations.py index bd0541b6e392..2d0c875a513b 100644 --- a/astropy/table/tests/test_operations.py +++ b/astropy/table/tests/test_operations.py @@ -1449,11 +1449,23 @@ def test_col_meta_merge_outer(self, operation_table_type): assert out["c"].info.format == "%6s" assert out["c"].info.description == "t2_c" - def test_vstack_one_table(self, operation_table_type): + @pytest.mark.parametrize("as_list", [False, True]) + def test_vstack_one_table(self, operation_table_type, as_list): + """Regression tests for issues #3313 and #18910.""" self._setup(operation_table_type) - """Regression test for issue #3313""" - assert (self.t1 == table.vstack(self.t1)).all() - assert (self.t1 == table.vstack([self.t1])).all() + self.t1.meta["my_special_value"] = 42 + + tables = [self.t1] if as_list else self.t1 + out = table.vstack(tables) + + assert type(out) is type(self.t1) + assert out is not self.t1 + assert (self.t1 == out).all() + + out["a"][0] = 10 + out.meta = {"my_special_value": 17} + assert self.t1["a"][0] == 0 + assert self.t1.meta["my_special_value"] == 42 @pytest.mark.parametrize("empty_table1", [False, True]) @pytest.mark.parametrize("empty_table2", [False, True]) diff --git a/astropy/table/tests/test_table.py b/astropy/table/tests/test_table.py index ed74eda09a0e..780827a8fa5c 100644 --- a/astropy/table/tests/test_table.py +++ b/astropy/table/tests/test_table.py @@ -2473,6 +2473,40 @@ def test_deepcopy_object_column(data): assert t3.meta["test"] is not t1.meta["test"] +@pytest.mark.parametrize("masked", [False, True], ids=["unmasked", "masked"]) +def test_deepcopy_rename_columns(masked): + # Regression test for #20087 for unmasked and masked tables: renaming + # columns of a deep-copied Table must keep column mapping in sync. + t = Table({"px": [1.0, 2.0], "py": [3.0, 4.0]}, masked=masked) + if masked: + t["px"].mask = [False, True] + + td = copy.deepcopy(t) + + # Deep-copied columns must be linked to the copied table. + for name in td.colnames: + assert td[name].info.parent_table is td + assert td[name].parent_table is td + + td.rename_columns(["px", "py"], ["x", "y"]) + assert td.colnames == ["x", "y"] + assert_array_equal(td["x"].data, [1.0, 2.0]) + assert_array_equal(td["y"].data, [3.0, 4.0]) + if masked: + assert_array_equal(td["x"].mask, [False, True]) + assert_array_equal(td["y"].mask, [False, False]) + + # renaming via the info name setter works too + td["x"].info.name = "xx" + assert td.colnames == ["xx", "y"] + + # the original table is unaffected + assert t.colnames == ["px", "py"] + assert_array_equal(t["px"].data, [1.0, 2.0]) + if masked: + assert_array_equal(t["px"].mask, [False, True]) + + def test_replace_column_qtable(): """Replace existing Quantity column with a new column in a QTable""" a = [1, 2, 3] * u.m diff --git a/astropy/tests/figures/py311-test-image-mpl380-cov.json b/astropy/tests/figures/py311-test-image-mpl380-cov.json index 2fccead9d1fa..d46b5bc1a7aa 100644 --- a/astropy/tests/figures/py311-test-image-mpl380-cov.json +++ b/astropy/tests/figures/py311-test-image-mpl380-cov.json @@ -1,9 +1,9 @@ { - "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_custom_frame": "cceb87beabe25ac4187b2ade40b1d4af551dbb77a35f71b59d41cbfb85a2c769", + "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_custom_frame": "ed4d134ec6599ed1343f94c7099ab5d4d56ace2c950507017e597bc74c84d208", "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_rectangular": "30e13643c770a26b2707745143f50a735daa6f37a6a8258e733ae35338a4a1bb", - "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_nonrectangular": "37de34740cef2897effc9de5e6726ef3955a3449d0fa6a929350301500557b8e", + "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_nonrectangular": "c13d765eb65803590b89bb71adfc08065bb74e3db4eb2ccad62a18b14a83ad20", "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_change_wcs": "c68e961f0a21cc0dcc43c523cee1c564d94bd96c795f976d51e5198fc52f83cc", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tight_layout": "80f167d1d14c8c22f1ffc2f403951e60397a4dcf35b9ecfd3ebba297d3fe0ff7", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tight_layout": "b07a6595053b6931cb70f23d18a27897fd9fa48af173bb87f5f4f7675d560d48", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_image_plot": "4fe4c89d089e8f1f9584584826f25d3c884d4914f76a863e1a624f9ef2295b07", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axes_off": "3580258468fc0ff0fa6d140299b79f1f05c16c3e222447de38228e01983fbdd1", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axisbelow[True]": "5364e39cab14f28b08073b31cce3dae8501c0431b1a49985810b764902747c8e", @@ -12,33 +12,33 @@ "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_contour_overlay": "8679305e8a8b5b3f5b896343f4e16dfc65e9289c31505e3b35ef773d7837c8ea", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_contourf_overlay": "fa928ab57291e8a128009320323ffb8955cd75d28b2a589074defb7b0889b3eb", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_overlay_features_image": "30d5562cb7a2484db0b898bc886253829de884fbf92bc0e6575f091438ec5f32", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_curvilinear_grid_patches_image": "ad0a985a324a73b5bea839a231f7537075306c874c279c6a6ea2cfe16529d815", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_cube_slice_image": "822d96a93eb4ab59b1a0095384bb315b025b481a18821b3c558e4a937c77f489", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_cube_slice_image_lonlat": "224f7e2f7d106ab028584bdc51c73296e277a211b7f3ed36a0452eb4842afa5b", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_plot_coord": "9352d254af8add2918f98fb8a8d6d220bb95ab8096a1c56584d6c80ff3d5de39", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_scatter_coord": "9352d254af8add2918f98fb8a8d6d220bb95ab8096a1c56584d6c80ff3d5de39", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_text_coord": "c6e7575becb6381f45fe549ea7415bd038778406e43b44e8a72f8c6bce82e466", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_plot_line": "74ce73bcbb1dd170ae36d38a1ad4a62c626517a84e05922dab0f989a12a27758", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_changed_axis_units": "056ec7123c67fa7ee6269bec47641bfe4932630a79d02029e71164b522fadacd", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_curvilinear_grid_patches_image": "3be3f50e37e40b67e482ac6a418c95209d3d74db472a6960571c77d9ca3a4651", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_cube_slice_image": "a0d13538d6c6aad4a44d86c094c62f2c74a669050815332a1d2e547dfeab84ef", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_cube_slice_image_lonlat": "a137066f5472305b53cf69e9fa32dd452bba9e7d28d6ce3b9c8d668e85ea6ca9", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_plot_coord": "357c431732a20f850aeb7f395344a787b3828de0c0874e7cc67c36585ebc26cb", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_scatter_coord": "357c431732a20f850aeb7f395344a787b3828de0c0874e7cc67c36585ebc26cb", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_text_coord": "c0c6b1153494509df8a8fb9a041b0749cc0143b2381263b1409969d0a3187f48", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_plot_line": "519133d4b90a83b602b059d8ce6df525236abdad347bb68c1820d67edf8e182a", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_changed_axis_units": "8692bdad0bab9889f22cc725c288c30f659082b537bb42d87748f76b76b706ad", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_minor_ticks": "5224f8fa725901ff74a4558987ceaae0363f98894d2c6a12d8c557db500ff7fe", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_ticks_labels": "f4944296c37a6e8ea9d30126709da3567a25908c1665e8cad10e894dd87cd54d", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_ticks_labels": "98f5d686d52be75b6a8e114a9d0d0deb5db32b486e50dff70f618e54806e9d75", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_no_ticks": "5836f6d36ce6ce89156b251055e5b00b6063a35bff4b7ca1b2da725da14a67ad", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_rcparams": "1af0c66ba343df4f03efc18e707f5b0c2a54dbce7351e0f9024154937a30b371", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_rcparams": "8fde44ec7f3b5190219c0712bb864b41c261c764ca90713ce558b1f17a2d3435", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_angles": "11ef218080920301ada1ef9cea558599ca110ba49e3dc4e9dc547c013b87fcc7", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_angles_non_square_axes": "2ffe1157db233bc80e0eda08a30b916cc56a8ff0a7f34c3a0a1b1037695fe1a5", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_set_coord_type": "ee5e873fd467292f7e76f0890051f33762b399fa3936357797e7338f80e914eb", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_angles_non_square_axes": "5e4275c37e0d70fd04fb763b31bdc0512ebc4c89fe4016a4e5c672b23c0c7eda", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_set_coord_type": "b14d974eb1b498cd1bfd1adf6f425b855d1df67241c28ebc2b57785f2748cca0", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_ticks_regression": "d295b08d88c6f8cd0eb56f9aa9c1892b6e72e6819a0f5b6265f3a29acb0a1545", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axislabels_regression": "431b65a14f107dd47c3ea56240b92e246ba25be682f6487c8e8565b78f1fc0c8", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_noncelestial_angular": "cafe2fe3ca40e358ad8e92040c698c1e7f2913968f226a12b635fc47c0acc063", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axislabels_regression": "d91389c6b91df92f26326648bfd63e0e970c9eea582797d870fff926e98cf126", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_noncelestial_angular": "a4b11994128d6a31a224019e46a3ea9b513d8667714ebc4e19081eecf86c6fb3", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_patches_distortion": "704af668b56837628f11b3dedbba60c0d11fda2e896ddbba9dee0e8acb5b99d7", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_quadrangle": "0223dba7d207f37c644c5c0860f6ef18b11b8013438030c89d88d700ea2ed437", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_beam_shape_from_args": "5bb6436777ab08013e747807f7db670b068229d6fb2b6b250787e44eaac36dab", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_beam_shape_from_header": "d7309b91db792bd42fdccd53ebe0738611c7cec56adb2972f0d684d83c8c75f6", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_scalebar": "29931954f6c7c5131a63bdfabbe4c220ee255d2efde756e31977d13a3c69af98", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_elliptical_frame": "3aee6e8bcb0b1283993d6743683f13077aab179a85540e3dcb301fef8c66aaf7", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_hms_labels": "1047e944e3cb798a39702be44c9612cb27a26e4fd246a5cf0abe047a51985d5c", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_latex_labels": "22c900ff73a60d58fbc29ee61485cad76aa483e3c8efa89bffbdf6d660d0bf9f", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_params": "78bdce5072b3e9ac87b7e76832f3fc889c115aef3f894004a50b7eeba0d32ebd", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_beam_shape_from_args": "71af38927a3d76c39833266f715d0beebcde62427e16c567a010b261b36893c8", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_beam_shape_from_header": "1b91ff2ca8ba76447ca510f511a190e163ee3602563dd27c675f38a5e2cf8c12", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_scalebar": "38e1c901e5b825d36a5a260b24dc5265d4204d41965fe381fef4daa974884e0b", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_elliptical_frame": "332e90410ca2a5b0a4151c325f1878dd800fe3e400fbb14af3a80fd8c3895ee2", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_hms_labels": "c0ef16030e9df22b539db367637d851a1abcfca559f126092a9ddb4210bf3d73", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_latex_labels": "8d30459ca3b04d3135fb0dbe0cefd6d992abdb7187a347d424d57d13090706e4", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_params": "80bbcaf1a84e45a6dda31f4dd94055f5ddb4def32481f057d1fb8a2d269907b7", "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_wcs": "190a14699654f7eae01556013f4fee1bbcddd3b34e62e9e3d92a92f7ce9a7695", "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_wcs_format_unit": "e0eea94f0ca0e37e96d903c3f441ecf0eb20b6870bfbd8a3426ad3e8305d1cee", "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_wcs_get_transform": "73e65438281efd42cda9e459467b18f7695aa50589782d0ce0cfbd298b69cb14", @@ -47,16 +47,16 @@ "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_sliced_low_level_wcs[slices1-custom:pos.helioprojective.lat]": "0a14e5153eb91e2a3a96501f02a21581effc7d6b7ae31e29a402a3abf30d5b12", "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_put_varying_axis_on_bottom_lon[slices0-hpln]": "60c967eef03cc2f038d7915f1b988dfa60852fa7b950b83c6d3f49054862d5d1", "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_put_varying_axis_on_bottom_lon[slices1-hplt]": "0a14e5153eb91e2a3a96501f02a21581effc7d6b7ae31e29a402a3abf30d5b12", - "astropy.visualization.wcsaxes.tests.test_images.test_allsky_labels_wrap": "8908818b526d4a506505da890eff47121b23ee41656dc15dff3e19f7d03094af", - "astropy.visualization.wcsaxes.tests.test_images.test_tickable_gridlines": "9d91bcf571af6dcc1425b6dfd76fb92e3e0180fb9fd0c852f9eba3fb06e079fc", - "astropy.visualization.wcsaxes.tests.test_images.test_overlay_nondegree_unit": "aa9b85520da54dc61d4db2988883ff184fb59cd27c52a04899cb33599fcab4b7", - "astropy.visualization.wcsaxes.tests.test_images.test_nosimplify": "565b8bf068323147ae0fcc72263f258c552285aadcf2fc67786818fccc0b812d", - "astropy.visualization.wcsaxes.tests.test_images.test_custom_formatter": "e04eb07365c6bbc4774a20150c13dadcf72b9c5a664805aff2fffd106fdecd87", + "astropy.visualization.wcsaxes.tests.test_images.test_allsky_labels_wrap": "c518cddf11b21a622367b676afaebfb42093932ca46a9287f8c59e4c373234f2", + "astropy.visualization.wcsaxes.tests.test_images.test_tickable_gridlines": "65127c0f9e786105f1219c576f361f88d5987f4d0d6fb5f555a16a5417c570f3", + "astropy.visualization.wcsaxes.tests.test_images.test_overlay_nondegree_unit": "24b94479a00308811f35e9572c9796adf7f24b993e5468e086f39b3dbd20f079", + "astropy.visualization.wcsaxes.tests.test_images.test_nosimplify": "7691427db965ad2884232cb1017cb2ad703f2bcb172064b1cb5a6db6f3562756", + "astropy.visualization.wcsaxes.tests.test_images.test_custom_formatter": "e5e336f23352c6e75cca8a13ef932b15e6f9f4b097734588797495754e21cceb", "astropy.visualization.wcsaxes.tests.test_images.test_equatorial_arcsec": "2e2ad683a3acdda19dd4353654fa6d399f4b5cd528cd41591a7bcba8776a67ce", "astropy.visualization.wcsaxes.tests.test_images.test_wcs_preserve_units": "464a0503099d3f7381479ddbba0e417011adb6e24301692056b124d608b1067b", - "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_coords_overlay": "0a87473ff8e5b5610f4adac1518c608afcd2178b25584fb42f77bcc594c4f47a", - "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_coords_overlay_auto_coord_meta": "2f737bb70fb1a5452cb0efa79010376614dc559e9aff607f638f044ac6b04448", - "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_direct_init": "1f24c5243bfdf0f30e88afc4f2f5d66db85954cea50a2910af94975ff8765b45", - "astropy.visualization.wcsaxes.tests.test_wcsapi.test_wcsapi_5d_with_names": "c90ae6f3b0f9f407ca7a866fa648dc3ef4bea78369315292bc82f16104ed5736", + "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_coords_overlay": "94d53bee535a009789e31f71443158c765f4401031b75bbbe3935edbad5a39d6", + "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_coords_overlay_auto_coord_meta": "d4a0f6a8bb05133a9fcc325fd2bc3d52e1a93185ed3591dfc900828215dd1f90", + "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_direct_init": "ac5f1e7e04fad78d519d0558009a79fa0ef369c328babbf921467b5b10d4ee43", + "astropy.visualization.wcsaxes.tests.test_wcsapi.test_wcsapi_5d_with_names": "57adce0a9e991840cfd70e8cacfc63e547c4186994c9d2f63b0711a9bbaa0d34", "astropy.visualization.wcsaxes.tests.test_wcsapi.test_wcsapi_2d_celestial_arcsec": "4b743d645a85d7516decbcf4a831c127af5a1800072597c2a1299d17fb186adb" } diff --git a/astropy/tests/figures/py311-test-image-mpldev-cov.json b/astropy/tests/figures/py311-test-image-mpldev-cov.json index 5c5bfba140b9..dc22576c7536 100644 --- a/astropy/tests/figures/py311-test-image-mpldev-cov.json +++ b/astropy/tests/figures/py311-test-image-mpldev-cov.json @@ -1,7 +1,7 @@ { - "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_custom_frame": "5ba2b23bc4107e33eb9ffcfb656dc5b487969443f847497c0a9ab6ad6c4624bf", + "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_custom_frame": "7df863a91ce010794cdee1f71d8dc22370f4713a93e944f32884b3346f2dbcd6", "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_rectangular": "45669dd577a0750602555da53c590da0b70a64d899e38a6b3be1c8c0c393dc52", - "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_nonrectangular": "38145a9457711720f12888ba62aeb28ac06f0c05425856af415949e49ecc0b97", + "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_nonrectangular": "378e4585a4ec759b4df60b5589859d8988205191463c8475d34fdf4f3f602b44", "astropy.visualization.wcsaxes.tests.test_frame.TestFrame.test_update_clip_path_change_wcs": "6a3b3e21d12fb83e24c3307c24fe300e520db4b158d407f6df7fc79dfa2210cf", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tight_layout": "5bd7b10da254a8cee16b4d2304faf3a9d876f9f512f2778f1326aec2d9c10371", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_image_plot": "a10da5ff6a071bb278b51fe811887e978773cf9f69717eeae9ded395e37bccfd", @@ -9,23 +9,23 @@ "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axisbelow[True]": "3492f9ddb93014073086269f862aa7c6183da0c28a3a98afd330aa9e0f27cf09", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axisbelow[False]": "8019d129a842be8d6c91543790f1e7b1933b2b91edfa7885a0d71b0ca8ef00d7", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axisbelow[line]": "75516135332695601d2fe25381c360b03ebf5526c9376bdeab0e618069b1516a", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_contour_overlay": "45eee6ea3948c70ffd6e8758423dea0c90a83bd525420f34687f7981b451e911", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_contourf_overlay": "44d57ebdd3216650be39b019729b532a88ceb95f83f1f8dc664377f4f64a631e", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_contour_overlay": "c3d92fbc94e9396fd814e8ed4e38996558ee90d9adaaaf5d73c831d60d81574b", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_contourf_overlay": "19a5b2448716cd74fcd36a3c171d596507aa63631e0187b55b6421cbcbf2f680", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_overlay_features_image": "bf160601fe1e0bf8996f71c2907422cffaa468eb2755f4fd065dd2b5694af848", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_curvilinear_grid_patches_image": "3af87106f1b0fcf6070d33b748557d897be80531ab39192b227c82a409bef835", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_cube_slice_image": "bd4013e426adde9461a46b2c88b26020fb198bb14d7b4835e4cdeed8ca185678", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_cube_slice_image_lonlat": "eff17af9665669826c8924cb0686f9ef313bdad8a477e12622d8d7d07422efb3", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_plot_coord": "6d5cf6dae41a88239870282530c60f771f6369b600856c81b4991610821c3b3b", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_scatter_coord": "6d5cf6dae41a88239870282530c60f771f6369b600856c81b4991610821c3b3b", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_text_coord": "5ddca4e5ee5a86b6cb29588e5826ab271268f9502be440af5852bf0aa2724d2a", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_plot_line": "2bae09e3199778dcbea8f058e200a245367f7b70072b285cd7fb82635fcd7482", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_curvilinear_grid_patches_image": "b8375ec9b69cbe58d8cbf557244d26ee8c029cecbc3f2f9b674b68825277d1f6", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_cube_slice_image": "6b26921c7898609cfa86ff59e2184c7e0dd5133960061042c554853fc61cee5f", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_cube_slice_image_lonlat": "398a19ac15f5f822ca6e4078b98a39f231adb808cbdff70567083e06db766751", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_plot_coord": "8f8189f785348caaf8c109adc581ece3d071602057d49d99a7ad896bb578e333", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_scatter_coord": "8f8189f785348caaf8c109adc581ece3d071602057d49d99a7ad896bb578e333", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_text_coord": "c07febd6a45e52b461435c5dff7f6db9d11662e6d7ffb0950c40846a6f747137", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_plot_line": "c003af1cd835f1fd7173aacfdc591645661da47d00e6c6992459785313a4f6d5", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_changed_axis_units": "c8b0ac1c48f9beba0261caea8aae466574c296ea088f4439e54497f04f7940f5", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_minor_ticks": "c314eb4ef0f20621c2fc62ba09d0d71afe67baafbdb289d1b79308ea6164291d", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_ticks_labels": "e1cefee39eedc7320e17e64e2c4e711f574c833c9f0155aa431944aa2f9ca899", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_ticks_labels": "a8f77b5ebea7735027ddc085bb5d6d74476093358cfea4e9bfdfc290ed8eff18", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_no_ticks": "7ac52e09ffde02327bf01d233fd7947a74a1a36e9ec273f94ffd04a5d3f699d5", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_rcparams": "132410d077bf5d6ff682f285961b4a3b8b6750c344ffd4660f1fb176aa424680", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_angles": "11ef218080920301ada1ef9cea558599ca110ba49e3dc4e9dc547c013b87fcc7", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_angles_non_square_axes": "19b6034dc4f1e350efe12eb2e5a6add1bf47ffe37d79bd05f1d15cff684af365", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_angles_non_square_axes": "8717c72274f25d42846ea8296a5b346ce6c467fc7ea194a9d1f5e208b0ea97f2", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_set_coord_type": "c66c6870631760871da3d722cf4739f2e542f6d10937f5c1e87054ea67121ae4", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_ticks_regression": "e1cbb0d489ecc3ca72258e87bbf0006bbc1bd2979df464cb61c68f5f38615987", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_axislabels_regression": "72598fe74fb8fc1d0ab83df6ef4886accd08c8b8f47938408fa9327c8a37e2eb", @@ -35,9 +35,9 @@ "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_beam_shape_from_args": "01dffa3f79ddbfa5c119a49fc1282fee63f2792ccebc189ad44ab644b59f1559", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_beam_shape_from_header": "c4b34fab02c5be0d9d69b2615a1b05c3b73ddf9e40031c6873abe26ab3090472", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_scalebar": "4b5c3fc70a8bbd7aaeed0d15d78bc491c8253ca2cc7d239c343306e0a71f44c5", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_elliptical_frame": "3cf0c18fa653fafaaba2287b4a8c3e125eb29c2ba6a120918d0e088cbc8467d2", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_hms_labels": "507fb9485ce61050323d021347ce43acd1929f44249cfc98bd684ce685b4315b", - "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_latex_labels": "66a902d8e9ed0f6e95a5433d4842ede2163ff11387804bf93ed810632e1cd373", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_elliptical_frame": "1524cbcdd819e04638a891660069467f9cc31e6f1430b525f3fee93992a636d0", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_hms_labels": "97e91caed53309d31e24cffad39c6a256739fd35123436f142092c37a99cfd26", + "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_latex_labels": "b08ef30472956763adafaf4d50a250d04f0cd2a47f96fcbb55528bfd1e2c80ef", "astropy.visualization.wcsaxes.tests.test_images.TestBasic.test_tick_params": "b197947dedc38821e02f31edb4e259a2d87439470947aa5c53bdfc89faa9d9b3", "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_wcs": "bf85a4cc85935869d4c26a39d155d90f68a9d266adcf2bac0d10b5da384d7045", "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_wcs_format_unit": "f632e2eddb9d683a085558339c23995049a7463644d6d6f997d923f5c6aaaa42", @@ -47,16 +47,16 @@ "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_1d_sliced_low_level_wcs[slices1-custom:pos.helioprojective.lat]": "03708033709fe9b63375f96fbf5ea44b4e17cae968d385f5899d4b2e42be1f6c", "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_put_varying_axis_on_bottom_lon[slices0-hpln]": "24af7845ef230f2be4949377f8d2a8e7b3bccd2622d8f488442ed09fc07b2b5d", "astropy.visualization.wcsaxes.tests.test_images.test_1d_plot_put_varying_axis_on_bottom_lon[slices1-hplt]": "03708033709fe9b63375f96fbf5ea44b4e17cae968d385f5899d4b2e42be1f6c", - "astropy.visualization.wcsaxes.tests.test_images.test_allsky_labels_wrap": "4697fefe84a8b0e5b58cc35bc250921ec3fffc7cbdee85836427af90fe4ac475", - "astropy.visualization.wcsaxes.tests.test_images.test_tickable_gridlines": "5ce7b377d7cf15d3be97a922929536413a36fc2c1a34423ecee8f04d361d35fd", - "astropy.visualization.wcsaxes.tests.test_images.test_overlay_nondegree_unit": "4f5819ad0dc6cc6c5a4776265f114f81b192bfd11ce1ddbb3d8b43da6b56e642", - "astropy.visualization.wcsaxes.tests.test_images.test_nosimplify": "336423447a5066d39a60ac4b0839dbe8eb2fab6a742830a71464f941c6307c66", - "astropy.visualization.wcsaxes.tests.test_images.test_custom_formatter": "1142e72415e9affaa97c7e763d1274632fe1d9cf8a9eae2a579a179fd14d6b9d", + "astropy.visualization.wcsaxes.tests.test_images.test_allsky_labels_wrap": "cc83d998e367896cd7f9c112ad0bfc61dbd83297df19b76d5822f70deaeffe52", + "astropy.visualization.wcsaxes.tests.test_images.test_tickable_gridlines": "317db2a0b2c5b69abbd015b23a929a3cfc954481167dc2ad95a59ccd7daa656b", + "astropy.visualization.wcsaxes.tests.test_images.test_overlay_nondegree_unit": "f4908df4bea70a091d8145296d4cd06bbdd45726b010315bdd8780cac9d07ad6", + "astropy.visualization.wcsaxes.tests.test_images.test_nosimplify": "3b7ef11648af0e54623ec891a498f03908031119aa4b0061d6d3f342bb8d117a", + "astropy.visualization.wcsaxes.tests.test_images.test_custom_formatter": "01b81670f49bec5d721a4d35ad1705ff1b08397ce7c04f4c840cbdd200615529", "astropy.visualization.wcsaxes.tests.test_images.test_equatorial_arcsec": "f048285e01f99235e711068e06ea5729b1ff5258604a944a5fa26042e613dae6", "astropy.visualization.wcsaxes.tests.test_images.test_wcs_preserve_units": "4111f2731c268bbe8ff57551b011afd6dd918efe07236507625f6e99e69b7263", - "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_coords_overlay": "f7635158075969632bc3cabd6cfa9798a3d4227f173aed32a546c540c65d9b4d", - "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_coords_overlay_auto_coord_meta": "9a3cc14c87a8349487888c69bab345748894be4295598eaed2475804f97b5f8a", - "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_direct_init": "021364576662b07f1eedf58251d657244fd1b67bb16ec4c99a0506b1dde0d66e", + "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_coords_overlay": "fc11a76eb5f492e78908056cfc952b230866d5eb926ef7c51caea3fd873a77c2", + "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_coords_overlay_auto_coord_meta": "34946e9b2a0e99223ec06f20c6df26db1716ea01c0495cf7ba519db78f5d0d41", + "astropy.visualization.wcsaxes.tests.test_transform_coord_meta.TestTransformCoordMeta.test_direct_init": "bd7af8c8ba136338e52619e594e7a926ed215b0d192429b2aa1a5654aef1449a", "astropy.visualization.wcsaxes.tests.test_wcsapi.test_wcsapi_5d_with_names": "b243aec60020235d04c816743c0fcc643495f58d4d4d5086588c59628d9cea76", "astropy.visualization.wcsaxes.tests.test_wcsapi.test_wcsapi_2d_celestial_arcsec": "ecb11a595e1352b3b8e6967494fa5eac63992fc878bf15a934329bb28aeb61d6" } diff --git a/astropy/tests/runner.py b/astropy/tests/runner.py index 0e046d7f65f4..fd10d10e2f57 100644 --- a/astropy/tests/runner.py +++ b/astropy/tests/runner.py @@ -179,12 +179,7 @@ def _generate_args(self, **kwargs): "pytest_doctestplus", "pytest_astropy_header", ] - _missing_dependancy_error = ( - "Test dependencies are missing: {}. You should install the " - "'pytest-astropy' package (you may need to update the package if you " - "have a previous version installed, e.g., " - "'pip install pytest-astropy --upgrade' or the equivalent with conda)." - ) + _missing_dependency_error = "Test dependencies are missing: {}. " @classmethod def _has_test_dependencies(cls): # pragma: no cover @@ -201,8 +196,10 @@ def _has_test_dependencies(cls): # pragma: no cover pluginmanager = pytest.PytestPluginManager() try: pluginmanager.import_plugin(module) - except ImportError: - raise RuntimeError(cls._missing_dependancy_error.format(module)) + except ImportError as exc: + raise RuntimeError( + cls._missing_dependency_error.format(module) + ) from exc def run_tests(self, **kwargs): # This method is weirdly hooked into various things with docstring diff --git a/astropy/time/core.py b/astropy/time/core.py index 1e0b13c42a1f..f1c95da42f7c 100644 --- a/astropy/time/core.py +++ b/astropy/time/core.py @@ -1163,7 +1163,8 @@ def insert(self, obj, values, axis=0): # For non-Time object, use numpy to help figure out the length. (Note annoying # case of a string input that has a length which is not the length we want). if not isinstance(values, self.__class__): - values = np.asarray(values) + # asanyarray, not asarray, so that a Masked input keeps its mask. + values = np.asanyarray(values) n_values = len(values) if values.shape else 1 # Finally make the new object with the correct length and set values for the @@ -1172,12 +1173,17 @@ def insert(self, obj, values, axis=0): [self], len(self) + n_values, name=self.info.name ) - out._time.jd1[:idx0] = self._time.jd1[:idx0] - out._time.jd2[:idx0] = self._time.jd2[:idx0] + # Copy the initial elements of self to out. ``new_like`` always makes an + # unmasked object, but copying `self` here using the full Time slice will + # upgrade ``out`` as needed. + out[:idx0] = self[:idx0] # This uses the Time setting machinery to coerce and validate as necessary. out[idx0 : idx0 + n_values] = values + # Finally we can just do a direct copy of the jd1/2 values since we know the + # masking is already handled. This is more efficient than using the Time + # setting machinery. out._time.jd1[idx0 + n_values :] = self._time.jd1[idx0:] out._time.jd2[idx0 + n_values :] = self._time.jd2[idx0:] @@ -1204,11 +1210,7 @@ def __setitem__(self, item, value): delattr(self, attr) if value is np.ma.masked or value is np.nan: # noqa: PLW0177, RUF100 - if not isinstance(self._time.jd2, Masked): - self._time.jd1 = Masked(self._time.jd1, copy=False) - self._time.jd2 = Masked( - self._time.jd2, mask=self._time.jd1.mask, copy=False - ) + self._time._ensure_masked() self._time.jd2.mask[item] = True return @@ -1222,6 +1224,13 @@ def __setitem__(self, item, value): # Finally directly set the jd1/2 values. Locations are known to match. if self.scale is not None: value = getattr(value, self.scale) + + # If the value carries a mask but we do not, we have to upgrade our + # internal jd1/jd2 to Masked first, otherwise the mask of the value + # would be silently dropped (gh-20173). + if value.masked: + self._time._ensure_masked() + self._time.jd1[item] = value._time.jd1 self._time.jd2[item] = value._time.jd2 diff --git a/astropy/time/formats.py b/astropy/time/formats.py index c105ad46e510..ca7c194c70d4 100644 --- a/astropy/time/formats.py +++ b/astropy/time/formats.py @@ -246,6 +246,16 @@ def jd2(self, jd2): if self._jd1 is not None: self._jd1, self._jd2 = _broadcast_writeable(self._jd1, self._jd2) + def _ensure_masked(self): + """Ensure that jd1 and jd2 are `~astropy.utils.masked.Masked` arrays. + + The two share the same (initially all-`False`) mask. This is a no-op if + the values are masked already. + """ + if not isinstance(self.jd2, Masked): + self.jd1 = Masked(self.jd1, copy=False) + self.jd2 = Masked(self.jd2, mask=self.jd1.mask, copy=False) + @classmethod @functools.cache def fill_value(cls, subfmt): diff --git a/astropy/time/tests/test_mask.py b/astropy/time/tests/test_mask.py index ba60e521ee53..364f4d5d9556 100644 --- a/astropy/time/tests/test_mask.py +++ b/astropy/time/tests/test_mask.py @@ -3,12 +3,13 @@ import functools import numpy as np +import numpy.testing as npt import pytest from astropy import units as u from astropy.coordinates import EarthLocation -from astropy.table import Table -from astropy.time import Time, conf +from astropy.table import QTable, Table, vstack +from astropy.time import Time, TimeDelta, conf from astropy.utils import iers from astropy.utils.compat.optional_deps import HAS_H5PY from astropy.utils.masked import Masked @@ -119,6 +120,52 @@ def test_mask_not_writeable(): assert np.may_share_memory(t._time.jd1.mask, t._time.jd2.mask) +def test_setitem_masked_value(): + # Regression test for gh-20173: assigning a masked Time into an unmasked + # one silently dropped the mask, revealing the underlying value again. + t = Time(["2000:001", "2000:002", "2000:003"]) + assert not t.masked + + value = Time(["2001:001", "2001:002"]) + value[1] = np.ma.masked + + t[:2] = value + assert t.masked + assert np.all(t.mask == [False, True, False]) + assert t.unmasked[0] == Time("2001:001") + assert t.unmasked[2] == Time("2000:003") + # jd1 and jd2 should share the mask, like they do elsewhere. + assert np.may_share_memory(t._time.jd1.mask, t._time.jd2.mask) + + # Scalar assignment of a masked element should mask the target too. + t2 = Time(["2000:001", "2000:002"]) + t2[0] = value[1] + assert np.all(t2.mask == [True, False]) + + # An unmasked value must not clear an existing mask elsewhere, and + # assigning over a masked element unmasks it again. + t[0] = np.ma.masked + t[1:] = Time(["2002:001", "2002:002"]) + assert np.all(t.mask == [True, False, False]) + + +def test_vstack_masked(): + # Regression test for gh-20173: vstack dropped the mask of Time columns. + t1 = QTable() + t1["time"] = Time(["2024-01-01T01:00:00", "2024-01-01T02:00:00"]) + t1["time"][1] = np.ma.masked + + t2 = QTable() + t2["time"] = Time(["2024-01-02T03:00:00", "2024-01-02T04:00:00"]) + t2["time"][0] = np.ma.masked + + combined = vstack([t1, t2]) + assert combined["time"].masked + assert np.all(combined["time"].mask == [False, True, True, False]) + assert np.all(combined["time"].unmasked[:2] == t1["time"].unmasked) + assert np.all(combined["time"].unmasked[2:] == t2["time"].unmasked) + + def test_str(): t = Time(["2000:001", "2000:002"]) t[1] = np.ma.masked @@ -364,3 +411,88 @@ def test_datetime64_with_nat(): t = Time(mdt64) assert t.masked assert np.all(t.mask == [True, True, False]) + + +def test_insert_masked(): + """Time.insert must preserve the mask of the original object (gh-20230).""" + t = Time(["2001:001", "2001:002", "2001:003"], out_subfmt="date") + t[0] = np.ma.masked + t[2] = np.ma.masked + + out = t.insert(1, "1999:001") + assert out.masked + assert np.all(out.mask == [True, False, False, True]) + assert out.value[1] == "1999:001" + assert out.value[2] == "2001:002" + npt.assert_array_equal( + out.unmasked.value, ["2001:001", "1999:001", "2001:002", "2001:003"] + ) + # jd1 and jd2 must share the mask, as they do everywhere else. + assert np.may_share_memory(out._time.jd1.mask, out._time.jd2.mask) + + # Insert more than one value. + out = t.insert(1, ["1999:001", "1999:002"]) + npt.assert_array_equal(out.mask, [True, False, False, False, True]) + + # An unmasked Time stays unmasked. + t_unmasked = Time(["2001:001", "2001:002"]) + assert not t_unmasked.insert(1, "1999:001").masked + + +def test_insert_masked_values(): + """Inserting masked values into an unmasked Time keeps their mask (gh-20230).""" + t = Time([1.0, 2.0, 3.0], format="cxcsec") + values = Time([10.0, 20.0], format="cxcsec") + values[1] = np.ma.masked + + out = t.insert(1, values) + assert out.masked + npt.assert_array_equal(out.mask, [False, False, True, False, False]) + npt.assert_array_equal(out.unmasked.value, [1.0, 10.0, 20.0, 2.0, 3.0]) + + # A bare Masked array as the inserted values works too. + out = t.insert(1, Masked(np.array([10.0, 20.0]), mask=[False, True])) + npt.assert_array_equal(out.mask, [False, False, True, False, False]) + + # A bare numpy masked array scalar as the inserted value works too. + out = t.insert(1, np.ma.array(10.0, mask=True)) + npt.assert_array_equal(out.mask, [False, True, False, False]) + + # A masked scalar Time. + value = Time(10.0, format="cxcsec") + value[()] = np.ma.masked + npt.assert_array_equal(t.insert(1, value).mask, [False, True, False, False]) + + # Both sides masked. + t = t.copy() + t[2] = np.ma.masked + out = t.insert(1, values) + npt.assert_array_equal(out.mask, [False, False, True, False, True]) + + +def test_insert_masked_2d(): + t = Time(np.array(["2001:001", "2001:002", "2001:003", "2001:004"]).reshape(2, 2)) + t[0, 1] = np.ma.masked + out = t.insert(1, "2010:001") + assert out.shape == (3, 2) + npt.assert_array_equal(out.mask, [[False, True], [False, False], [False, False]]) + + +def test_insert_masked_timedelta(): + dt = TimeDelta([1.0, 2.0, 3.0], format="jd") + dt[1] = np.ma.masked + out = dt.insert(0, TimeDelta(9.0, format="jd")) + assert out.masked + npt.assert_array_equal(out.mask, [False, False, True, False]) + npt.assert_array_equal(out.value[[0, 1, 3]], [9.0, 1.0, 3.0]) + + +def test_insert_masked_table_add_row(): + """Table.add_row goes through Time.insert (gh-20230).""" + t = Time(["2001:001", "2001:002", "2001:003"]) + t[0] = np.ma.masked + t[2] = np.ma.masked + tbl = Table([t], names=["time"]) + tbl.add_row([Time("2010:001")]) + assert tbl["time"].masked + npt.assert_array_equal(tbl["time"].mask, [True, False, True, False]) diff --git a/astropy/timeseries/periodograms/lombscargle/implementations/utils.py b/astropy/timeseries/periodograms/lombscargle/implementations/utils.py index ccf868caa773..1af15cd8731f 100644 --- a/astropy/timeseries/periodograms/lombscargle/implementations/utils.py +++ b/astropy/timeseries/periodograms/lombscargle/implementations/utils.py @@ -5,7 +5,7 @@ def bitceil(N): """ - Find the bit (i.e. power of 2) immediately greater than or equal to N + Find the bit (i.e., power of 2) immediately greater than or equal to N. Note: this works for numbers up to 2 ** 64. Roughly equivalent to int(2 ** np.ceil(np.log2(N))). """ @@ -16,6 +16,24 @@ def next_fast_len(N): """ Find the next number, greater than N, which can be expressed as 2^n * 3^m for nonnegative integer values of n and m, with m <= 4. + + Parameters + ---------- + N : int + Number of integer bins to use. + + Returns + ------- + Nfft : int + The next number found, as described above. + + Examples + -------- + >>> from astropy.timeseries.periodograms.lombscargle.implementations.utils import next_fast_len + >>> next_fast_len(15) + 16 + >>> next_fast_len(1000) + 1024 """ Nfft = bitceil(N) @@ -43,14 +61,14 @@ def extirpolate(x, y, N=None, M=4): Parameters ---------- x : array-like - array of abscissas + Array of abscissas y : array-like - array of ordinates + Array of ordinates N : int - number of integer bins to use. For best performance, N should be larger + Number of integer bins to use. For best performance, N should be larger than the maximum of x M : int - number of adjoining points on which to extirpolate. + Number of adjoining points on which to extirpolate. Returns ------- diff --git a/astropy/timeseries/tests/test_sampled.py b/astropy/timeseries/tests/test_sampled.py index 274255a6ab91..848e32a97f46 100644 --- a/astropy/timeseries/tests/test_sampled.py +++ b/astropy/timeseries/tests/test_sampled.py @@ -323,7 +323,7 @@ def test_pandas(): df1 = pandas.DataFrame() df1["a"] = [1, 2, 3] - df1.set_index(pandas.DatetimeIndex(INPUT_TIME.datetime64), inplace=True) + df1 = df1.set_index(pandas.DatetimeIndex(INPUT_TIME.datetime64)) ts = TimeSeries.from_pandas(df1) assert_equal(ts.time.isot, INPUT_TIME.isot) diff --git a/astropy/uncertainty/distributions.py b/astropy/uncertainty/distributions.py index d4687b95bf91..b629f0cc8fdb 100644 --- a/astropy/uncertainty/distributions.py +++ b/astropy/uncertainty/distributions.py @@ -61,7 +61,7 @@ def normal( if std is None: std = np.asanyarray(ivar) ** -0.5 else: - raise ValueError("normal cannot take both ivar and and std or var") + raise ValueError("normal cannot take both ivar and std or var") if std is None: raise ValueError("normal requires one of std, var, or ivar") else: diff --git a/astropy/units/quantity_helper/function_helpers.py b/astropy/units/quantity_helper/function_helpers.py index 34958309ac1c..29e4a91d17ef 100644 --- a/astropy/units/quantity_helper/function_helpers.py +++ b/astropy/units/quantity_helper/function_helpers.py @@ -48,6 +48,7 @@ NUMPY_LT_2_2, NUMPY_LT_2_4, NUMPY_LT_2_4_1, + NUMPY_LT_2_6, ) SUBCLASS_SAFE_FUNCTIONS = set() @@ -1519,7 +1520,11 @@ def structured_to_unstructured(arr, *args, **kwargs): """ from astropy.units import StructuredUnit - target_unit = arr.unit.values()[0] + if isinstance(arr.unit, StructuredUnit) and len(arr.unit) > 0: + target_unit = next(iter(arr.unit.values())) + else: + # Fallback if the unit isn't structured or is empty + target_unit = arr.unit def replace_unit(x): if isinstance(x, StructuredUnit): @@ -1527,7 +1532,8 @@ def replace_unit(x): else: return target_unit - to_unit = arr.unit._recursively_apply(replace_unit) + to_unit = replace_unit(arr.unit) + return (arr.to_value(to_unit),) + args, kwargs, target_unit, None @@ -1633,3 +1639,15 @@ def merge_arrays( unit, None, ) + + +if not NUMPY_LT_2_6: + + @function_helper + def top_k(a, k, /, *, axis=-1, mode="largest", sorted=True): + return ( + (a.view(np.ndarray), k), + {"axis": axis, "mode": mode, "sorted": sorted}, + (a.unit, None), + None, + ) diff --git a/astropy/units/quantity_helper/helpers.py b/astropy/units/quantity_helper/helpers.py index 0575cc3cca3e..02e7138f3467 100644 --- a/astropy/units/quantity_helper/helpers.py +++ b/astropy/units/quantity_helper/helpers.py @@ -19,6 +19,7 @@ NUMPY_LT_2_2, NUMPY_LT_2_3, NUMPY_LT_2_5, + NUMPY_LT_2_6, ) from . import UFUNC_HELPERS, UNSUPPORTED_UFUNCS @@ -368,6 +369,22 @@ def helper_clip(f, unit1, unit2, unit3): return converters, result_unit +def helper_unwrap(f, unit1, unit2, unit3): + """Support the private numpy ufunc np._core.umath._unwrap. + + This ufunc is used internally in `~numpy.unwrap`, and like for clip, the + first array is the primary one, and discont and period should simply be + converted to its unit. + + A tricky part is that `~numpy.unwrap` has dimensionless defaults for + discont and period, which are simply passed on. Rather than deal with + these here, we just continue to override `~numpy.unwrap` in + ``astropy.units.quantity_helpers.function_helpers``. + + """ + return helper_clip(f, unit1, unit2, unit3) + + # list of ufuncs: # https://numpy.org/doc/stable/reference/ufuncs.html#available-ufuncs @@ -582,4 +599,8 @@ def helper_clip(f, unit1, unit2, unit3): if isinstance(getattr(np_umath, "clip", None), np.ufunc): UFUNC_HELPERS[np_umath.clip] = helper_clip +if not NUMPY_LT_2_6: + # See docstring of helper_unwrap about this private numpy ufunc. + UFUNC_HELPERS[np_umath._unwrap] = helper_unwrap + del ufunc diff --git a/astropy/units/tests/test_quantity_non_ufuncs.py b/astropy/units/tests/test_quantity_non_ufuncs.py index e9402af91f15..baebc99056a2 100644 --- a/astropy/units/tests/test_quantity_non_ufuncs.py +++ b/astropy/units/tests/test_quantity_non_ufuncs.py @@ -21,7 +21,7 @@ TBD_FUNCTIONS, UNSUPPORTED_FUNCTIONS, ) -from astropy.utils.compat import NUMPY_LT_2_1, NUMPY_LT_2_2, NUMPY_LT_2_4 +from astropy.utils.compat import NUMPY_LT_2_1, NUMPY_LT_2_2, NUMPY_LT_2_4, NUMPY_LT_2_6 VAR_POSITIONAL = inspect.Parameter.VAR_POSITIONAL VAR_KEYWORD = inspect.Parameter.VAR_KEYWORD @@ -80,10 +80,18 @@ def setup_method(self): class InvariantUnitTestSetup(BasicTestSetup): def check(self, func, *args, **kwargs): - o = func(self.q, *args, **kwargs) - expected = func(self.q.value, *args, **kwargs) * self.q.unit - assert o.shape == expected.shape - assert np.all(o == expected) + out = func(self.q, *args, **kwargs) + expected = func(self.q.value, *args, **kwargs) + + if isinstance(expected, tuple): + assert isinstance(out, tuple) + else: + out = (out,) + expected = (expected,) + + for a, e in zip(out, expected, strict=True): + assert a.shape == e.shape + assert_array_equal(a.view(np.ndarray), e) class NoUnitTestSetup(BasicTestSetup): @@ -847,6 +855,11 @@ def test_cumulative_prod(self): with pytest.raises(u.UnitsError): np.cumulative_prod(self.q, axis=1) + @pytest.mark.skipif(NUMPY_LT_2_6, reason="np.top_k is new in Numpy 2.6") + def test_top_k(self): + self.check(np.top_k, 1) + self.check(np.top_k, 2) + class TestUfuncLike(InvariantUnitTestSetup): def test_ptp(self): @@ -2656,6 +2669,25 @@ def test_merge_arrays_str(self, flatten): ): rfn.merge_arrays((self.q_pv, np.array(["a", "b", "c"])), flatten=flatten) + def test_structured_to_unstructured_success(self): + """ + Test that structured_to_unstructured correctly extracts + the target unit for structured quantities where all fields + share the same compatible unit initialized as a standard unit. + """ + # Using a standard unit (not StructuredUnit) triggers the bug + # where `unit` (a standard unit) has no `values()` method. + dtype = np.dtype([("a", "f8"), ("b", "f8")]) + data = np.array([(100.0, 2.0), (300.0, 4.0)], dtype=dtype) + unit = u.m + q = u.Quantity(data, unit=unit) + + result = rfn.structured_to_unstructured(q) + + assert isinstance(result, u.Quantity) + assert result.unit == u.m + assert_array_equal(result.value, np.array([[100.0, 2.0], [300.0, 4.0]])) + all_wrapped_functions = get_wrapped_functions( np, np.fft, np.linalg, np.lib.recfunctions diff --git a/astropy/units/tests/test_quantity_ufuncs.py b/astropy/units/tests/test_quantity_ufuncs.py index cbc40a9312d2..6dd3c5018434 100644 --- a/astropy/units/tests/test_quantity_ufuncs.py +++ b/astropy/units/tests/test_quantity_ufuncs.py @@ -17,7 +17,7 @@ from astropy.units import quantity_helper as qh from astropy.units.quantity_helper.converters import UfuncHelpers from astropy.units.quantity_helper.helpers import helper_sqrt -from astropy.utils.compat.numpycompat import NUMPY_LT_2_3 +from astropy.utils.compat.numpycompat import NUMPY_LT_2_3, NUMPY_LT_2_6 from astropy.utils.compat.optional_deps import HAS_SCIPY @@ -1222,6 +1222,35 @@ def test_clip_errors(self): self.clip(q, 0.0, 1.0) +@pytest.mark.skipif(NUMPY_LT_2_6, reason="no _unwrap ufunc available") +class TestUnwrap: + """Test the np._core.umath._unwrap ufunc. + + In numpy, this is hidden behind `~numpy.unwrap`, a function that inserts + dimensionless defaults for discont and period that are a bit strange to + deal with (unless one wanted an implicitly ``u.dimensionless_angles()``. + So, we continue to override the function, which means that in practice + this ufunc is never called with Quantity. But we support it for + completeness. + + """ + + @classmethod + def setup_class(cls): + cls.unwrap = np_umath._unwrap + + def test_unwrap_simple(self): + a = [10.0, 90.0, 440.0] * u.deg + discont = np.pi * u.rad + period = 1 * u.cycle + got = self.unwrap(a, discont, period) + assert got.unit == a.unit + exp_value = self.unwrap( + a.value, discont.to_value(a.unit), period.to_value(a.unit) + ) + assert_array_equal(got.value, exp_value) + + class TestUfuncAt: """Test that 'at' method for ufuncs (calculates in-place at given indices) diff --git a/astropy/units/tests/test_structured.py b/astropy/units/tests/test_structured.py index 3967e39cd796..8ed24cbf2c21 100644 --- a/astropy/units/tests/test_structured.py +++ b/astropy/units/tests/test_structured.py @@ -659,7 +659,7 @@ def test_structured_to_unstructured(self): # ``test_quantity_non_ufuncs.TestRecFunctions.test_structured_to_unstructured`` def test_unstructured_to_structured(self): - # can't structure something that's already structured + # can't structure something with incompatible shape dtype = np.dtype([("f1", float), ("f2", float)]) with pytest.raises(ValueError, match="The length of the last dimension"): rfn.unstructured_to_structured(self.q_pv, dtype=self.q_pv.dtype) diff --git a/astropy/utils/compat/numpycompat.py b/astropy/utils/compat/numpycompat.py index b4f40fb1caa9..f051861543d4 100644 --- a/astropy/utils/compat/numpycompat.py +++ b/astropy/utils/compat/numpycompat.py @@ -21,6 +21,7 @@ "NUMPY_LT_2_4", "NUMPY_LT_2_4_1", "NUMPY_LT_2_5", + "NUMPY_LT_2_6", "chararray", "get_chararray", ] @@ -34,6 +35,7 @@ NUMPY_LT_2_4 = not minversion(np, "2.4.0.dev0") NUMPY_LT_2_4_1 = not minversion(np, "2.4.1.dev0") NUMPY_LT_2_5 = not minversion(np, "2.5.0.dev0") +NUMPY_LT_2_6 = not minversion(np, "2.6.0.dev0") def __getattr__(attr): diff --git a/astropy/utils/data.py b/astropy/utils/data.py index 21a6e19e8b68..32f471da7f93 100644 --- a/astropy/utils/data.py +++ b/astropy/utils/data.py @@ -17,16 +17,22 @@ import urllib.error import urllib.parse import urllib.request +import warnings import zipfile +from collections.abc import Container, Generator, Iterable +from contextlib import suppress from importlib import import_module +from pathlib import Path from tempfile import NamedTemporaryFile, TemporaryDirectory, gettempdir from types import MappingProxyType +from typing import Literal, assert_never, overload from warnings import warn import astropy_iers_data import astropy.config.paths from astropy import config as _config +from astropy.units import Quantity, UnitBase from astropy.utils.compat.optional_deps import ( HAS_BZ2, HAS_CERTIFI, @@ -108,10 +114,11 @@ class Conf(_config.ConfigNamespace): """ dataurl = _config.ConfigItem( - "http://data.astropy.org/", "Primary URL for astropy remote data site." + "http://www.astropy.org/astropy-data/", + "Primary URL for astropy remote data site.", ) dataurl_mirror = _config.ConfigItem( - "http://www.astropy.org/astropy-data/", + "http://data.astropy.org/", "Mirror URL for astropy remote data site.", ) default_http_user_agent = _config.ConfigItem( @@ -676,8 +683,11 @@ def get_pkg_data_fileobj(data_name, package=None, encoding=None, cache=True): def get_pkg_data_filename( - data_name, package=None, show_progress=True, remote_timeout=None -): + data_name: str, + package: str | None = None, + show_progress: bool = True, + remote_timeout: float | None = None, +) -> str: """ Retrieves a data file from the standard locations for the package and provides a local filename for the data. @@ -795,7 +805,12 @@ def get_pkg_data_filename( ) -def get_pkg_data_contents(data_name, package=None, encoding=None, cache=True): +def get_pkg_data_contents( + data_name: str, + package: str | None = None, + encoding: str | None = None, + cache: bool = True, +) -> bytes: """ Retrieves a data file from the standard locations and returns its contents as a bytes object. @@ -871,7 +886,11 @@ def get_pkg_data_contents(data_name, package=None, encoding=None, cache=True): return contents -def get_pkg_data_filenames(datadir, package=None, pattern="*"): +def get_pkg_data_filenames( + datadir: str, + package: str | None = None, + pattern: str = "*", +) -> Generator[str, None, None]: """ Returns the path of all of the data files in a given directory that match a given glob pattern. @@ -989,7 +1008,7 @@ def get_pkg_data_fileobjs(datadir, package=None, pattern="*", encoding=None): yield fd -def compute_hash(localfn): +def compute_hash(localfn: str) -> str: """Computes the MD5 hash for a file. The hash for a data file is used for looking up data files in a unique @@ -1024,7 +1043,10 @@ def compute_hash(localfn): return h.hexdigest() -def get_pkg_data_path(*path, package=None): +def get_pkg_data_path( + *path: str, + package: str | None = None, +) -> str: """Get path from source-included data directories. Parameters @@ -1076,6 +1098,7 @@ def get_pkg_data_path(*path, package=None): f"use astropy.utils.iers.{_IERS_DATA_REDIRECTS[filename][0]} " "instead.", AstropyDeprecationWarning, + stacklevel=2, ) return _IERS_DATA_REDIRECTS[filename][1] @@ -1098,7 +1121,10 @@ def get_pkg_data_path(*path, package=None): return full_path -def _find_hash_fn(hexdigest, pkgname="astropy"): +def _find_hash_fn( + hexdigest: str, + pkgname: str = "astropy", +) -> str | None: """ Looks for a local file by hash - returns file name if found and a valid file, otherwise returns None. @@ -1109,14 +1135,24 @@ def _find_hash_fn(hexdigest, pkgname="astropy"): return None -def get_free_space_in_dir(path, unit=False): +@overload +def get_free_space_in_dir( + path: str | os.PathLike[str], unit: Literal[False] +) -> int: ... +@overload +def get_free_space_in_dir( + path: str | os.PathLike[str], unit: UnitBase | Literal[True] +) -> Quantity: ... + + +def get_free_space_in_dir(path: str | os.PathLike[str], unit: UnitBase | bool = False): """ Given a path to a directory, returns the amount of free space on that filesystem. Parameters ---------- - path : str + path : str, os.PathLike The path to a directory. unit : bool or `~astropy.units.Unit` @@ -1130,10 +1166,17 @@ def get_free_space_in_dir(path, unit=False): If ``unit=False``, it is returned as plain integer (in bytes). """ - if not os.path.isdir(path): - raise OSError( - "Can only determine free space associated with directories, not files." - ) + if not (path := Path(path)).is_dir(): + exc_type: type[Exception] + note: str + if path.is_file(): + exc_type = FileExistsError + note = "found a file, expected a directory" + else: + exc_type = FileNotFoundError + note = "no such file or directory" + msg = f"Cannot determine free space from {path} ({note})" + raise exc_type(msg) # Actually you can on Linux but I want to avoid code that fails # on Windows only. free_space = shutil.disk_usage(path).free @@ -1147,14 +1190,17 @@ def get_free_space_in_dir(path, unit=False): return free_space -def check_free_space_in_dir(path, size): +def check_free_space_in_dir( + path: str | os.PathLike[str], + size: int | Quantity, +) -> None: """ Determines if a given directory has enough space to hold a file of a given size. Parameters ---------- - path : str + path : str, os.PathLike[str] The path to a directory. size : int or `~astropy.units.Quantity` @@ -1193,7 +1239,9 @@ def connect_ftp(self, user, passwd, host, port, dirs, timeout): @functools.lru_cache -def _build_urlopener(ftp_tls=False, ssl_context=None, allow_insecure=False): +def _build_urlopener( + ftp_tls: bool = False, ssl_context=None, allow_insecure: bool = False +) -> urllib.request.OpenerDirector: """ Helper for building a `urllib.request.build_opener` which handles TLS/SSL. """ @@ -1241,12 +1289,12 @@ def _build_urlopener(ftp_tls=False, ssl_context=None, allow_insecure=False): def _try_url_open( - source_url, - timeout=None, + source_url: str, + timeout: float | None = None, http_headers=None, ftp_tls=False, ssl_context=None, - allow_insecure=False, + allow_insecure: bool = False, ): """Helper for opening a URL while handling TLS/SSL verification issues.""" # Import ssl here to avoid import failure when running in pyodide/Emscripten @@ -1287,7 +1335,7 @@ def _try_url_open( raise urllib.error.URLError(msg) else: msg += ". Re-trying with allow_insecure=True." - warn(msg, AstropyWarning) + warn(msg, AstropyWarning, stacklevel=2) # Try again with a new urlopener allowing insecure connections urlopener = _build_urlopener( ftp_tls=ftp_tls, ssl_context=ssl_context, allow_insecure=True @@ -1298,17 +1346,17 @@ def _try_url_open( def _download_file_from_source( - source_url, - show_progress=True, - timeout=None, - remote_url=None, - cache=False, - pkgname="astropy", + source_url: str, + show_progress: bool = True, + timeout: float | None = None, + remote_url: str | None = None, + cache: bool = False, + pkgname: str = "astropy", http_headers=None, ftp_tls=None, ssl_context=None, - allow_insecure=False, -): + allow_insecure: bool = False, +) -> Path: from astropy.utils.console import ProgressBarOrSpinner if not conf.allow_internet: @@ -1364,8 +1412,7 @@ def _download_file_from_source( if size is not None: check_free_space_in_dir(gettempdir(), size) - if cache: - dldir = _get_download_cache_loc(pkgname) + if cache and (dldir := _get_download_cache_loc(pkgname)).exists(): check_free_space_in_dir(dldir, size) # If a user has overridden sys.stdout it might not have the @@ -1384,6 +1431,7 @@ def _download_file_from_source( with NamedTemporaryFile( prefix=f"astropy-download-{os.getpid()}-", delete=False ) as f: + fp = Path(f.name) try: bytes_read = 0 block = remote.read(conf.download_block_size) @@ -1405,26 +1453,23 @@ def _download_file_from_source( content=None, ) except BaseException: - if os.path.exists(f.name): - try: - os.remove(f.name) - except OSError: - pass + with suppress(PermissionError): + fp.unlink() raise - return f.name + return fp def download_file( - remote_url, - cache=False, - show_progress=True, - timeout=None, - sources=None, - pkgname="astropy", + remote_url: str, + cache: bool | Literal["update"] = False, + show_progress: bool = True, + timeout: float | None = None, + sources: list[str] | None = None, + pkgname: str = "astropy", http_headers=None, ssl_context=None, - allow_insecure=False, -): + allow_insecure: bool = False, +) -> str: """Downloads a URL and optionally caches the result. It returns the filename of a file containing the URL's contents. @@ -1540,7 +1585,7 @@ def download_file( if cache: try: - dldir = _get_download_cache_loc(pkgname) + dldir = _get_download_cache_loc(pkgname, ensure_exists=True) except OSError as e: cache = False missing_cache = ( @@ -1557,9 +1602,9 @@ def download_file( "otherwise use a boolean" ) else: - filename = os.path.join(dldir, _url_to_dirname(url_key), "contents") - if os.path.exists(filename): - return os.path.abspath(filename) + filename = dldir / _url_to_dirname(url_key) / "contents" + if filename.exists(): + return str(filename.absolute()) errors = {} for source_url in sources: @@ -1610,7 +1655,7 @@ def download_file( try: return import_file_to_cache( url_key, - f_name, + str(f_name), remove_original=True, replace=(cache == "update"), pkgname=pkgname, @@ -1625,13 +1670,18 @@ def download_file( # FIXME: other kinds of cache problem can occur? if missing_cache: - warn(CacheMissingWarning(missing_cache, f_name)) + warn(CacheMissingWarning(missing_cache, f_name), stacklevel=2) if conf.delete_temporary_downloads_at_exit: _tempfilestodel.append(f_name) return os.path.abspath(f_name) -def is_url_in_cache(url_key, pkgname="astropy"): +def is_url_in_cache( + url_key: str, + pkgname: str = "astropy", + *, + on_missing: Literal["ignore", "warn", "error"] = "warn", +) -> bool: """Check if a download for ``url_key`` is in the cache. The provided ``url_key`` will be the name used in the cache. The contents @@ -1646,7 +1696,11 @@ def is_url_in_cache(url_key, pkgname="astropy"): The package name to use to locate the download cache. i.e. for ``pkgname='astropy'`` the default cache location is ``~/.cache/astropy``. + on_missing : 'ignore', 'warn', or 'error' + What to do if the cache directory doesn't exist. + Default: 'warn' + .. versionadded:: 8.0 Returns ------- @@ -1658,24 +1712,25 @@ def is_url_in_cache(url_key, pkgname="astropy"): -------- cache_contents : obtain a dictionary listing everything in the cache """ - try: - dldir = _get_download_cache_loc(pkgname) - except OSError: + dldir = _get_download_cache_loc(pkgname, on_missing=on_missing) + if not dldir.exists(): return False - filename = os.path.join(dldir, _url_to_dirname(url_key), "contents") - return os.path.exists(filename) + filename = dldir / _url_to_dirname(url_key) / "contents" + return filename.exists() -def cache_total_size(pkgname="astropy"): +def cache_total_size( + pkgname: str = "astropy", +) -> int: """Return the total size in bytes of all files in the cache.""" size = 0 - dldir = _get_download_cache_loc(pkgname=pkgname) + dldir = _get_download_cache_loc(pkgname=pkgname, on_missing="ignore") for root, _, files in os.walk(dldir): size += sum(os.path.getsize(os.path.join(root, name)) for name in files) return size -def _do_download_files_in_parallel(kwargs): +def _do_download_files_in_parallel(kwargs) -> str: if (temp_config_path := kwargs.pop("temp_config")) is not None: temp_config_args = (temp_config_path,) else: @@ -1690,14 +1745,14 @@ def _do_download_files_in_parallel(kwargs): def download_files_in_parallel( - urls, - cache="update", - show_progress=True, - timeout=None, + urls: list[str], + cache: bool | Literal["update"] = "update", + show_progress: bool = True, + timeout: float | None = None, sources=None, - multiprocessing_start_method=None, - pkgname="astropy", -): + multiprocessing_start_method: str | None = None, + pkgname: str = "astropy", +) -> list[str]: """Download multiple files in parallel from the given URLs. Blocks until all files have downloaded. The result is a list of @@ -1791,6 +1846,7 @@ def download_files_in_parallel( 'it will be set to ``"update"``. You may need to manually remove ' "the cached files with clear_download_cache() afterwards.", AstropyWarning, + stacklevel=2, ) cache = "update" @@ -1820,7 +1876,7 @@ def download_files_in_parallel( multiprocess=True, multiprocessing_start_method=multiprocessing_start_method, ) - paths = [] + paths: list[str] = [] for url in urls: paths.append(combined_paths[combined_urls.index(url)]) return paths @@ -1828,7 +1884,7 @@ def download_files_in_parallel( # This is used by download_file and _deltemps to determine the files to delete # when the interpreter exits -_tempfilestodel = [] +_tempfilestodel: list[Path] = [] @atexit.register @@ -1836,14 +1892,14 @@ def _deltemps(): if _tempfilestodel is not None: while len(_tempfilestodel) > 0: fn = _tempfilestodel.pop() - if os.path.isfile(fn): + if fn.is_file(): try: - os.remove(fn) + fn.unlink() except OSError: # oh well we tried # could be held open by some process, on Windows pass - elif os.path.isdir(fn): + elif fn.is_dir(): try: shutil.rmtree(fn) except OSError: @@ -1852,7 +1908,12 @@ def _deltemps(): pass -def clear_download_cache(hashorurl=None, pkgname="astropy"): +def clear_download_cache( + hashorurl: str | None = None, + pkgname: str = "astropy", + *, + on_missing: Literal["ignore", "warn", "error"] = "warn", +) -> None: """Clears the data file cache by deleting the local file(s). If a URL is provided, it will be the name used in the cache. The contents @@ -1875,26 +1936,39 @@ def clear_download_cache(hashorurl=None, pkgname="astropy"): The package name to use to locate the download cache. i.e. for ``pkgname='astropy'`` the default cache location is ``~/.cache/astropy``. + + on_missing : 'ignore', 'warn', or 'error' + What to do if the path requested for deletion doesn't exist. + Default: 'warn' + + .. versionadded:: 8.0 """ try: - dldir = _get_download_cache_loc(pkgname) - except OSError as e: - # Problem arose when trying to open the cache - # Just a warning, though - msg = "Not clearing data cache - cache inaccessible due to " - estr = "" if len(e.args) < 1 else (": " + str(e)) - warn(CacheMissingWarning(msg + e.__class__.__name__ + estr)) - return - try: + dldir = _get_download_cache_loc(pkgname, on_missing="ignore") + if not dldir.exists(): + match on_missing: + case "ignore": + pass + case "warn": + warn( + f"{dldir} does not exist", + CacheMissingWarning, + stacklevel=2, + ) + case "error": + raise FileNotFoundError(f"no such file or directory {dldir}") + case _ as unreachable: + assert_never(unreachable) + return if hashorurl is None: # Optional: delete old incompatible caches too _rmtree(dldir) elif _is_url(hashorurl): - filepath = os.path.join(dldir, _url_to_dirname(hashorurl)) + filepath = dldir / _url_to_dirname(hashorurl) _rmtree(filepath) else: # Not a URL, it should be either a filename or a hash - filepath = os.path.join(dldir, hashorurl) + filepath = dldir / hashorurl rp = os.path.relpath(filepath, dldir) if rp.startswith(".."): raise RuntimeError( @@ -1906,7 +1980,7 @@ def clear_download_cache(hashorurl=None, pkgname="astropy"): # It's a filename not the hash of a URL # so we want to zap the directory containing the # files "url" and "contents" - filepath = os.path.join(dldir, d) + filepath = dldir / d if os.path.exists(filepath): _rmtree(filepath) elif len(hashorurl) == 2 * hashlib.md5( @@ -1919,11 +1993,16 @@ def clear_download_cache(hashorurl=None, pkgname="astropy"): except OSError as e: msg = "Not clearing data from cache - problem arose " estr = "" if len(e.args) < 1 else (": " + str(e)) - warn(CacheMissingWarning(msg + e.__class__.__name__ + estr)) + warn(CacheMissingWarning(msg + e.__class__.__name__ + estr), stacklevel=2) -def _get_download_cache_loc(pkgname="astropy"): - """Finds the path to the cache directory and makes them if they don't exist. +def _get_download_cache_loc( + pkgname: str = "astropy", + *, + ensure_exists: bool = False, + on_missing: Literal["ignore", "warn", "error"] = "warn", +) -> Path: + """Finds the path to the cache directory. Parameters ---------- @@ -1932,35 +2011,52 @@ def _get_download_cache_loc(pkgname="astropy"): ``pkgname='astropy'`` the default cache location is ``~/.cache/astropy``. + ensure_exists : bool, optional, keyword-only + Default: False + + on_missing : 'ignore', 'warn', or 'error' + What to do if the download cache directory doesn't exist. + Default: 'warn' + + .. versionadded:: 8.0 + Returns ------- - datadir : str + datadir : pathlib.Path The path to the data cache directory. """ - try: - datadir = astropy.config.paths.get_cache_dir_path( - pkgname, - ensure_exists=False, - ).joinpath("download", "url") + dldir = astropy.config.paths.get_cache_dir_path( + pkgname, + ensure_exists=False, + ).joinpath("download", "url") - if not datadir.exists(): - try: - datadir.mkdir(parents=True) - except OSError: - if not datadir.exists(): - raise - elif not datadir.is_dir(): - raise OSError(f"Data cache directory {datadir} is not a directory") + if ensure_exists: + dldir.mkdir(parents=True, exist_ok=True) - return datadir - except OSError as e: - msg = "Remote data cache could not be accessed due to " - estr = "" if len(e.args) < 1 else (": " + str(e)) - warn(CacheMissingWarning(msg + e.__class__.__name__ + estr)) - raise + if dldir.is_dir(): + return dldir + + if dldir.exists(): + raise NotADirectoryError(f"Data cache directory {dldir} is not a directory") + + match on_missing: + case "ignore": + pass + case "warn": + warnings.warn( + f"{dldir} does not exist", + CacheMissingWarning, + stacklevel=2, + ) + case "error": + raise FileNotFoundError(f"No such file or directory {dldir}") + case _ as unreachable: + assert_never(unreachable) + + return dldir -def _url_to_dirname(url): +def _url_to_dirname(url: str) -> str: if not _is_url(url): raise ValueError(f"Malformed URL: '{url}'") # Make domain names case-insensitive @@ -1973,22 +2069,27 @@ def _url_to_dirname(url): return hashlib.md5(url_c.encode("utf-8"), usedforsecurity=False).hexdigest() -_NOTHING = MappingProxyType({}) - - class CacheDamaged(ValueError): """Record the URL or file that was a problem. Using clear_download_cache on the .bad_file or .bad_url attribute, whichever is not None, should resolve this particular problem. """ - def __init__(self, *args, bad_urls=None, bad_files=None, **kwargs): + def __init__( + self, + *args: object, + bad_urls: list[str] | None = None, + bad_files: list[Path] | None = None, + **kwargs, + ): super().__init__(*args, **kwargs) self.bad_urls = bad_urls if bad_urls is not None else [] self.bad_files = bad_files if bad_files is not None else [] -def check_download_cache(pkgname="astropy"): +def check_download_cache( + pkgname: str = "astropy", +) -> None: """Do a consistency check on the cache. .. note:: @@ -2029,12 +2130,14 @@ def check_download_cache(pkgname="astropy"): :func:`clear_download_cache` to resolve, or may indicate some kind of misconfiguration. """ - bad_files = set() - messages = set() - dldir = _get_download_cache_loc(pkgname=pkgname) + bad_files: str[Path] = set() + messages: set[str] = set() + dldir = _get_download_cache_loc(pkgname=pkgname, on_missing="ignore") + if not dldir.exists(): + return with os.scandir(dldir) as it: for entry in it: - f = os.path.abspath(os.path.join(dldir, entry.name)) + f = dldir.joinpath(entry.name).absolute() if entry.name.startswith("rmtree-"): if f not in _tempfilestodel: bad_files.add(f) @@ -2043,12 +2146,12 @@ def check_download_cache(pkgname="astropy"): for sf in os.listdir(f): if sf in ["url", "contents"]: continue - sf = os.path.join(f, sf) + sf = f / sf bad_files.add(sf) messages.add(f"Unexpected file f{sf}") - urlf = os.path.join(f, "url") + urlf = f / "url" url = None - if not os.path.isfile(urlf): + if not urlf.is_file(): bad_files.add(urlf) messages.add(f"Problem with URL file f{urlf}") else: @@ -2064,7 +2167,7 @@ def check_download_cache(pkgname="astropy"): f"URL hashes to {hashname} but is stored in" f" {entry.name}" ) - if not os.path.isfile(os.path.join(f, "contents")): + if not f.joinpath("contents").is_file(): bad_files.add(f) if url is None: messages.add(f"Hash {entry.name} is missing contents") @@ -2076,10 +2179,13 @@ def check_download_cache(pkgname="astropy"): bad_files.add(f) messages.add(f"Left-over non-directory {f} in cache") if bad_files: - raise CacheDamaged("\n".join(messages), bad_files=bad_files) + raise CacheDamaged("\n".join(messages), bad_files=sorted(bad_files)) -def _rmtree(path, replace=None): +def _rmtree( + path: str | os.PathLike[str], + replace: str | os.PathLike[str] | None = None, +) -> None: """More-atomic rmtree. Ignores missing directory.""" with TemporaryDirectory( prefix="rmtree-", dir=os.path.dirname(os.path.abspath(path)) @@ -2094,12 +2200,13 @@ def _rmtree(path, replace=None): f"Unable to remove directory {path} because a file in it " "is in use and you are on Windows", path, - ) + ), + stacklevel=2, ) raise except OSError as e: if e.errno == errno.EXDEV: - warn(e.strerror, AstropyWarning) + warn(e.strerror or "", AstropyWarning, stacklevel=2) shutil.move(path, os.path.join(d, "to-zap")) else: raise @@ -2115,15 +2222,20 @@ def _rmtree(path, replace=None): # already there, fine pass elif e.errno == errno.EXDEV: - warn(e.strerror, AstropyWarning) + warn(e.strerror or "", AstropyWarning, stacklevel=2) shutil.move(replace, path) else: raise def import_file_to_cache( - url_key, filename, remove_original=False, pkgname="astropy", *, replace=True -): + url_key: str, + filename: str, + remove_original: bool = False, + pkgname: str = "astropy", + *, + replace: bool = True, +) -> str: """Import the on-disk file specified by filename to the cache. The provided ``url_key`` will be the name used in the cache. The file @@ -2156,19 +2268,19 @@ def import_file_to_cache( Whether or not to replace an existing object in the cache, if one exists. If replacement is not requested but the object exists, silently pass. """ - cache_dir = _get_download_cache_loc(pkgname=pkgname) + cache_dir = _get_download_cache_loc(pkgname=pkgname, ensure_exists=True) cache_dirname = _url_to_dirname(url_key) - local_dirname = os.path.join(cache_dir, cache_dirname) - local_filename = os.path.join(local_dirname, "contents") + local_dirname = cache_dir / cache_dirname + local_filename = local_dirname / "contents" with TemporaryDirectory( prefix="temp_dir", dir=cache_dir, ignore_cleanup_errors=True ) as temp_dir: - temp_filename = os.path.join(temp_dir, "contents") + temp_path = Path(temp_dir) + temp_filename = temp_path / "contents" # Make sure we're on the same filesystem # This will raise an exception if the url_key doesn't turn into a valid filename shutil.copy(filename, temp_filename) - with open(os.path.join(temp_dir, "url"), "w", encoding="utf-8") as f: - f.write(url_key) + temp_path.joinpath("url").write_text(url_key, encoding="utf-8") if replace: _rmtree(local_dirname, replace=temp_dir) else: @@ -2188,7 +2300,11 @@ def import_file_to_cache( return os.path.abspath(local_filename) -def get_cached_urls(pkgname="astropy"): +def get_cached_urls( + pkgname: str = "astropy", + *, + on_missing: Literal["ignore", "warn", "error"] = "warn", +) -> list[str]: """ Get the list of URLs in the cache. Especially useful for looking up what files are stored in your cache when you don't have internet access. @@ -2204,6 +2320,12 @@ def get_cached_urls(pkgname="astropy"): ``pkgname='astropy'`` the default cache location is ``~/.cache/astropy``. + on_missing : 'ignore', 'warn', or 'error' + What to do if the cache directory doesn't exist. + Default: 'warn' + + .. versionadded:: 8.0 + Returns ------- cached_urls : list @@ -2213,10 +2335,14 @@ def get_cached_urls(pkgname="astropy"): -------- cache_contents : obtain a dictionary listing everything in the cache """ - return sorted(cache_contents(pkgname=pkgname).keys()) + return sorted(cache_contents(pkgname=pkgname, on_missing=on_missing).keys()) -def cache_contents(pkgname="astropy"): +def cache_contents( + pkgname: str = "astropy", + *, + on_missing: Literal["ignore", "warn", "error"] = "warn", +) -> MappingProxyType[str, str]: """Obtain a dict mapping cached URLs to filenames. This dictionary is a read-only snapshot of the state of the cache when this @@ -2226,24 +2352,25 @@ def cache_contents(pkgname="astropy"): busy with many running astropy processes, although the same issues apply to most functions in this module. """ - r = {} - try: - dldir = _get_download_cache_loc(pkgname=pkgname) - except OSError: - return _NOTHING + dldir = _get_download_cache_loc(pkgname=pkgname, on_missing=on_missing) + if not dldir.exists(): + return MappingProxyType({}) + + r: dict[str, str] = {} with os.scandir(dldir) as it: for entry in it: - if entry.is_dir: - url = get_file_contents( - os.path.join(dldir, entry.name, "url"), encoding="utf-8" - ) - r[url] = os.path.abspath(os.path.join(dldir, entry.name, "contents")) + if entry.is_dir(): + url = get_file_contents(dldir / entry.name / "url", encoding="utf-8") + r[str(url)] = str(dldir.joinpath(entry.name, "contents").absolute()) return MappingProxyType(r) def export_download_cache( - filename_or_obj, urls=None, overwrite=False, pkgname="astropy" -): + filename_or_obj, + urls: Iterable[str] | None = None, + overwrite: bool = False, + pkgname: str = "astropy", +) -> None: """Exports the cache contents as a ZIP file. Parameters @@ -2282,8 +2409,11 @@ def export_download_cache( def import_download_cache( - filename_or_obj, urls=None, update_cache=False, pkgname="astropy" -): + filename_or_obj, + urls: Container[str] | None = None, + update_cache: bool = False, + pkgname: str = "astropy", +) -> None: """Imports the contents of a ZIP file into the cache. Each member of the ZIP file should be named by a quoted version of the @@ -2321,7 +2451,11 @@ def import_download_cache( # but throughout this file. if urls is not None and url not in urls: continue - if not update_cache and is_url_in_cache(url, pkgname=pkgname): + if ( + not update_cache + and _get_download_cache_loc(pkgname, on_missing="ignore").exists() + and is_url_in_cache(url, pkgname=pkgname) + ): continue f_temp_name = os.path.join(d, str(i)) with z.open(zf) as f_zip, open(f_temp_name, "wb") as f_temp: diff --git a/astropy/utils/data_info.py b/astropy/utils/data_info.py index 426fc28f56a6..9cc1f85a97c1 100644 --- a/astropy/utils/data_info.py +++ b/astropy/utils/data_info.py @@ -376,10 +376,16 @@ def __set__(self, instance, value): raise TypeError("info must be set with a DataInfo instance") def __getstate__(self): - return self._attrs + # If this is an unbound descriptor, _attrs is uninitialized, so + # return None to indicate no state for it. + return getattr(self, "_attrs", None) def __setstate__(self, state): - self._attrs = state + # Only assign _attrs if state is not None. This prevents _attrs + # from being set to None when unpickling an object that did not + # originally have _attrs initialized. + if state is not None: + self._attrs = state def _represent_as_dict(self, attrs=None): """Get the values for the parent ``attrs`` and return as a dict. This diff --git a/astropy/utils/iers/iers.py b/astropy/utils/iers/iers.py index efb59b589076..0541831366a0 100644 --- a/astropy/utils/iers/iers.py +++ b/astropy/utils/iers/iers.py @@ -41,6 +41,7 @@ get_readable_fileobj, is_url_in_cache, ) +from astropy.utils.decorators import deprecated from astropy.utils.exceptions import AstropyDeprecationWarning, AstropyWarning from astropy.utils.state import ScienceState @@ -515,6 +516,7 @@ def pm_source(self, i): return np.zeros_like(i) @property + @deprecated(since="8.0", alternative="Time.now()", obj_type="property") def time_now(self): """ Property to provide the current time, but also allow for explicitly setting @@ -783,20 +785,19 @@ class IERS_Auto(IERS_A): @classmethod def open(cls): - """If the configuration setting ``astropy.utils.iers.conf.auto_download`` - is set to True (default), then open a recent version of the IERS-A - table with predictions for UT1-UTC and polar motion out to - approximately one year from now. If the available version of this file - is older than ``astropy.utils.iers.conf.auto_max_age`` days old - (or non-existent) then it will be downloaded over the network and cached. + """ + This reads in the bundled IERS_A table (or the version of the file in + the current working directory, if present - see `IERS_A.read`). - If the configuration setting ``astropy.utils.iers.conf.auto_download`` - is set to False then the bundled IERS-A table will be used rather than - any downloaded version of the IERS-A table. + Subsequently, if values are requested which are more recent than the + range of validity of the bundled table, an updated version of the table + will be downloaded if the configuration setting + ``astropy.utils.iers.conf.auto_download`` is set to True (default) and + if the available version of this file is older than + ``astropy.utils.iers.conf.auto_max_age`` days old. - On the first call in a session, the table will be memoized (in the - ``iers_table`` class attribute), and further calls to ``open`` will - return this stored table. + If the configuration setting ``astropy.utils.iers.conf.auto_download`` + is set to False then the bundled IERS-A table will always be used. Returns ------- @@ -804,47 +805,8 @@ def open(cls): With IERS (Earth rotation) data columns """ - if not conf.auto_download: - # If auto_download is changed to False mid-session, iers_table may have already been - # made from non-bundled files, so it should be remade from bundled files - if not hasattr(cls, "_iers_table_bundled"): - cls._iers_table_bundled = cls.read() - cls.iers_table = cls._iers_table_bundled - return cls.iers_table - - all_urls = (conf.iers_auto_url, conf.iers_auto_url_mirror) - - if cls.iers_table is not None: - # If the URL has changed, we need to redownload the file, so we - # should ignore the internally cached version. - - if cls.iers_table.meta.get("data_url") in all_urls: - return cls.iers_table - - for url in all_urls: - try: - filename = download_file(url, cache=True) - except Exception as err: - warn(f"failed to download {url}: {err}", IERSWarning) - continue - - try: - cls.iers_table = cls.read(file=filename) - except Exception as err: - warn(f"malformed IERS table from {url}: {err}", IERSWarning) - continue - cls.iers_table.meta["data_url"] = url - break - - else: - # Issue a warning here, perhaps user is offline. An exception - # will be raised downstream if actually trying to interpolate - # predictive values. - warn( - "unable to download valid IERS file, using bundled IERS-A", IERSWarning - ) + if cls.iers_table is None: cls.iers_table = cls.read() - return cls.iers_table def _check_interpolate_indices(self, indices_orig, indices_clipped, max_input_mjd): @@ -860,7 +822,7 @@ def _check_interpolate_indices(self, indices_orig, indices_clipped, max_input_mj auto_max_age = _none_to_float(conf.auto_max_age) if ( max_input_mjd > predictive_mjd - and self.time_now.mjd - predictive_mjd > auto_max_age + and Time.now().mjd - predictive_mjd > auto_max_age ): raise ValueError(INTERPOLATE_ERROR.format(auto_max_age)) @@ -885,7 +847,7 @@ def _refresh_table_as_needed(self, mjd): # Pass in initial to np.max to allow things to work for empty mjd. max_input_mjd = np.max(mjd, initial=50000) - now_mjd = self.time_now.mjd + now_mjd = Time.now().mjd # IERS-A table contains predictive data out for a year after # the available definitive values. @@ -924,7 +886,26 @@ def _refresh_table_as_needed(self, mjd): ) return - new_table = self.__class__.read(file=filename) + try: + new_table = self.__class__.read(file=filename) + except Exception as err: + # The download succeeded but the content could not be parsed as + # an IERS table (e.g. the server returned an error page). Keep + # using the existing table; an exception will be raised + # downstream when actually trying to interpolate predictive + # values. + warn( + AstropyWarning( + "malformed IERS table downloaded from" + f" {' and '.join(all_urls)}: {err}.\nA coordinate or" + " time-related calculation might be compromised or fail" + " because the dates are not covered by the available IERS" + ' file. See the "IERS data access" section of the astropy' + " documentation for additional information on working" + " offline." + ) + ) + return new_table.meta["data_url"] = str(all_urls[0]) # New table has new values? @@ -1170,7 +1151,9 @@ def auto_open(cls, files=None): # already in cache. The bools here indicate that the cache # should be used. trials = [ - (f, True) for f in files if not urlparse(f).netloc or is_url_in_cache(f) + (f, True) + for f in files + if not urlparse(f).netloc or is_url_in_cache(f, on_missing="ignore") ] # If we are allowed to download, we try downloading new versions # if none of the above worked. diff --git a/astropy/utils/iers/tests/test_iers.py b/astropy/utils/iers/tests/test_iers.py index 29f0e802e401..847241d69249 100644 --- a/astropy/utils/iers/tests/test_iers.py +++ b/astropy/utils/iers/tests/test_iers.py @@ -20,7 +20,7 @@ from astropy.tests.helper import CI, assert_quantity_allclose from astropy.time import Time, TimeDelta from astropy.utils.data import get_pkg_data_filename -from astropy.utils.exceptions import AstropyDeprecationWarning +from astropy.utils.exceptions import AstropyDeprecationWarning, AstropyWarning from astropy.utils.iers import iers FILE_NOT_FOUND_ERROR = getattr(__builtins__, "FileNotFoundError", OSError) @@ -247,6 +247,12 @@ def setup_class(self): self._auto_download = iers.conf.auto_download iers.conf.auto_download = True + # Ensure no IERS_Auto table is cached from an earlier test, so that the + # first test's open() re-reads the (possibly monkeypatched) bundled file + # rather than returning a stale memoized table; teardown_method keeps it + # clean for the remaining tests. + iers.IERS_Auto.close() + # auto_download = False is tested in test_IERS_B_parameters_loading_into_IERS_Auto() def teardown_class(self): @@ -257,10 +263,11 @@ def teardown_method(self, method): """Run this after every test.""" iers.IERS_Auto.close() - def test_interpolate_error_formatting(self): + def test_interpolate_error_formatting(self, monkeypatch): """Regression test: make sure the error message in IERS_Auto._check_interpolate_indices() is formatted correctly. """ + monkeypatch.setattr(iers, "IERS_A_FILE", self.iers_a_file_1) with iers.conf.set_temp("iers_auto_url", self.iers_a_url_1): with iers.conf.set_temp("iers_auto_url_mirror", self.iers_a_url_1): with iers.conf.set_temp("auto_max_age", self.ame): @@ -276,10 +283,11 @@ def test_interpolate_error_formatting(self): warnings.simplefilter("ignore", iers.IERSStaleWarning) iers_table.ut1_utc(self.t.jd1, self.t.jd2) - def test_auto_max_age_none(self): + def test_auto_max_age_none(self, monkeypatch): """Make sure that iers.INTERPOLATE_ERROR's advice about setting auto_max_age = None actually works. """ + monkeypatch.setattr(iers, "IERS_A_FILE", self.iers_a_file_1) with iers.conf.set_temp("iers_auto_url", self.iers_a_url_1): with iers.conf.set_temp("auto_max_age", None): iers_table = iers.IERS_Auto.open() @@ -302,7 +310,8 @@ def test_auto_max_age_minimum(self): iers_table = iers.IERS_Auto.open() _ = iers_table.ut1_utc(self.t.jd1, self.t.jd2) - def test_simple(self): + def test_simple(self, monkeypatch): + monkeypatch.setattr(iers, "IERS_A_FILE", self.iers_a_file_1) with iers.conf.set_temp("iers_auto_url", self.iers_a_url_1): dat = iers.IERS_Auto.open() assert dat["MJD"][0] == 57359.0 * u.d @@ -310,7 +319,9 @@ def test_simple(self): # Pretend we are accessing at a time 7 days after start of predictive data predictive_mjd = dat.meta["predictive_mjd"] - dat._time_now = Time(predictive_mjd, format="mjd") + 7 * u.d + monkeypatch.setattr( + Time, "now", lambda: Time(predictive_mjd, format="mjd") + 7 * u.d + ) # Look at times before and after the test file begins. 0.1292934 is # the IERS-B value from MJD=57359. The value in @@ -325,7 +336,9 @@ def test_simple(self): # Now pretend we are accessing at time 60 days after start of predictive data. # There will be a warning when downloading the file doesn't give new data # and an exception when extrapolating into the future with insufficient data. - dat._time_now = Time(predictive_mjd, format="mjd") + 60 * u.d + monkeypatch.setattr( + Time, "now", lambda: Time(predictive_mjd, format="mjd") + 60 * u.d + ) assert np.allclose( dat.ut1_utc(Time(50000, format="mjd").jd).value, 0.1292934 ) @@ -364,7 +377,7 @@ def test_simple(self): dat.ut1_utc(Time(60000, format="mjd").jd) # Now point to a later file with same values but MJD increased by - # 60 days and see that things work. dat._time_now is still the same value + # 60 days and see that things work. Time.now() is still monkeypatched # as before, i.e. right around the start of predictive values for the new file. # (In other words this is like downloading the latest file online right now). with iers.conf.set_temp("iers_auto_url", self.iers_a_url_2): @@ -439,6 +452,7 @@ def test_iers_a_dl(): iers.IERS_A.close() +@pytest.mark.skipif(CI, reason="Flaky on CI") @pytest.mark.remote_data def test_iers_a_dl_mirror(): iersa_tab = iers.IERS_A.open(iers.IERS_A_URL_MIRROR, cache=False) @@ -450,6 +464,7 @@ def test_iers_a_dl_mirror(): iers.IERS_A.close() +@pytest.mark.skipif(CI, reason="Flaky on CI") @pytest.mark.remote_data def test_iers_b_dl(): iersb_tab = iers.IERS_B.open(iers.IERS_B_URL, cache=False) @@ -482,33 +497,58 @@ def test_iers_b_out_of_range_handling(): (now + 100 * u.day).ut1 +@pytest.fixture +def reset_iers_auto_cache(): + """Clear the IERS_A/IERS_Auto/IERS caches around a test. + + IERS_Auto.open() memoizes its table in a class attribute, so a test that + loads a non-default bundled table (e.g. a truncated fixture) would otherwise + leak that table into later tests through earth_orientation_table. close() + only nulls the cached table, so this is safe regardless of the current + IERS_A_FILE value. + """ + for cls in (iers.IERS_A, iers.IERS_Auto, iers.IERS): + cls.close() + yield + for cls in (iers.IERS_A, iers.IERS_Auto, iers.IERS): + cls.close() + + @pytest.mark.remote_data -def test_iers_download_error_handling(tmp_path): - # Make sure an IERS-A table isn't already loaded +def test_iers_download_error_handling(tmp_path, monkeypatch, reset_iers_auto_cache): + # IERS_Auto.open() now reads the bundled table and only attempts a download + # later, when predictive values beyond the table range are requested while + # the table is older than auto_max_age. Point at an old bundled table so + # that requesting a recent date triggers a download attempt. The truncated + # fixture it loads is cleared afterwards by reset_iers_auto_cache. + monkeypatch.setattr( + iers, + "IERS_A_FILE", + get_pkg_data_filename(os.path.join("data", "finals2000A-2016-02-30-test")), + ) with set_temp_cache(tmp_path), iers.conf.set_temp("auto_download", True): - iers.IERS_A.close() - iers.IERS_Auto.close() - iers.IERS.close() now = Time.now() - # bad site name - with iers.conf.set_temp("iers_auto_url", "FAIL FAIL"): - # site that exists but doesn't have IERS data - with iers.conf.set_temp("iers_auto_url_mirror", "https://google.com"): - with pytest.warns(iers.IERSWarning) as record: - with iers.conf.set_temp("iers_degraded_accuracy", "ignore"): - (now + 400 * u.day).ut1 - - assert len(record) == 3 - assert str(record[0].message).startswith( - "failed to download FAIL FAIL: Malformed URL" - ) - assert str(record[1].message).startswith( - "malformed IERS table from https://google.com" - ) - assert str(record[2].message).startswith( - "unable to download valid IERS file, using bundled IERS-A" - ) + # Primary URL is a bad site name and the mirror is a site that exists + # but does not provide a valid IERS table, so the refresh cannot find + # usable data. + with ( + iers.conf.set_temp("iers_auto_url", "FAIL FAIL"), + iers.conf.set_temp("iers_auto_url_mirror", "https://google.com"), + iers.conf.set_temp("iers_degraded_accuracy", "ignore"), + ): + # The failed refresh is reported with a warning (the download fails + # or the downloaded content cannot be parsed), and since the bundled + # table is too old to cover the requested predictive date the + # interpolation then raises. + with pytest.warns( + AstropyWarning, match="failed to download|malformed IERS table" + ): + with pytest.raises( + ValueError, + match="interpolating from IERS_Auto using predictive values", + ): + (now + 400 * u.day).ut1 OLD_DATA_FILES = { @@ -534,3 +574,9 @@ def test_get_pkg_data_filename_backcompat(data_file): ) assert filename == OLD_DATA_FILES[data_file] + + +def test_time_now_deprecation(): + x = iers.IERS.open() + with pytest.warns(AstropyDeprecationWarning, match=r"Use Time\.now\(\) instead\.$"): + x.time_now diff --git a/astropy/utils/iers/tests/test_leap_second.py b/astropy/utils/iers/tests/test_leap_second.py index 26565881dd28..fbbf7e82f59e 100644 --- a/astropy/utils/iers/tests/test_leap_second.py +++ b/astropy/utils/iers/tests/test_leap_second.py @@ -11,6 +11,7 @@ from numpy.testing import assert_array_equal import astropy +from astropy.tests.helper import CI from astropy.time import Time, TimeDelta from astropy.utils.data import get_pkg_data_filename from astropy.utils.iers import iers @@ -215,6 +216,7 @@ def teardown_class(cls): # In these tests, the results may be cached. # This is fine - no need to download again. + @pytest.mark.skipif(CI, reason="Flaky on CI") def test_iers_url(self): ls = iers.LeapSeconds.auto_open([iers.IERS_LEAP_SECOND_URL]) assert ls.expires > Time.now() diff --git a/astropy/utils/masked/tests/test_function_helpers.py b/astropy/utils/masked/tests/test_function_helpers.py index 84fbe40d61af..043639b59b21 100644 --- a/astropy/utils/masked/tests/test_function_helpers.py +++ b/astropy/utils/masked/tests/test_function_helpers.py @@ -23,7 +23,7 @@ get_covered_functions, get_wrapped_functions, ) -from astropy.utils.compat import NUMPY_LT_2_1, NUMPY_LT_2_2, NUMPY_LT_2_4 +from astropy.utils.compat import NUMPY_LT_2_1, NUMPY_LT_2_2, NUMPY_LT_2_4, NUMPY_LT_2_6 from astropy.utils.masked import Masked, MaskedNDArray from astropy.utils.masked.function_helpers import ( APPLY_TO_BOTH_FUNCTIONS, @@ -900,11 +900,26 @@ def setup_class(cls): def check(self, func, *args, **kwargs): out = func(self.ma, *args, **kwargs) expected = func(self.a, *args, **kwargs) - assert type(out) is MaskedNDArray - assert out.dtype.kind == "f" - assert_array_equal(out.unmasked, expected) - assert_array_equal(out.mask, self.mask_a) - assert not np.may_share_memory(out.mask, self.mask_a) + + if isinstance(expected, tuple): + assert isinstance(out, tuple) + else: + out = (out,) + expected = (expected,) + + MASK_FROM_INDICES_FUNCTIONS = {"top_k"} + for ma, e in zip(out, expected, strict=True): + assert type(ma) is MaskedNDArray + assert ma.dtype == e.dtype + assert_array_equal(ma.unmasked, e) + assert not np.may_share_memory(ma.mask, self.mask_a) + + if func.__name__ in MASK_FROM_INDICES_FUNCTIONS: + continue + + assert_array_equal(ma.mask, self.mask_a) + + return out, expected @pytest.mark.skipif(NUMPY_LT_2_1, reason="np.cumulative_prod is new in NumPy 2.1") def test_cumulative_prod(self): @@ -914,6 +929,19 @@ def test_cumulative_prod(self): def test_cumulative_sum(self): self.check(np.cumulative_sum, axis=0) + @pytest.mark.skipif(NUMPY_LT_2_6, reason="np.top_k is new in Numpy 2.6") + def test_top_k(self): + out, expected = self.check(np.top_k, self.mask_a.shape[-1], axis=-1) + + assert len(out) == len(expected) + assert len(out) == 2 + arr, indices = out + + assert_array_equal( + arr.mask, + np.take_along_axis(self.mask_a, indices, axis=-1), + ) + class TestOuterLikeFunctions(MaskedArraySetup): def test_outer(self): diff --git a/astropy/utils/misc.py b/astropy/utils/misc.py index 52b473b78e86..9a029f58fb3e 100644 --- a/astropy/utils/misc.py +++ b/astropy/utils/misc.py @@ -9,7 +9,6 @@ import inspect import json import locale -import os import re import sys import threading @@ -35,11 +34,9 @@ "find_api_page", "format_exception", "indent", - "is_path_hidden", "isiterable", "online_help", "silence", - "walk_skip_hidden", ] NOT_OVERWRITING_MSG: Final = ( @@ -337,78 +334,6 @@ def online_help(query: str) -> None: webbrowser.open(online_docs_root + f"search.html?{urlencode({'q': query})}") -# _has_hidden_attribute() can be deleted together with deprecated is_path_hidden() and -# walk_skip_hidden(). -if sys.platform == "win32": - import ctypes - - def _has_hidden_attribute(filepath): - """ - Returns True if the given filepath has the hidden attribute on - MS-Windows. Based on a post here: - https://stackoverflow.com/questions/284115/cross-platform-hidden-file-detection. - """ - if isinstance(filepath, bytes): - filepath = filepath.decode(sys.getfilesystemencoding()) - try: - attrs = ctypes.windll.kernel32.GetFileAttributesW(filepath) - result = bool(attrs & 2) and attrs != -1 - except AttributeError: - result = False - return result - -else: - - def _has_hidden_attribute(filepath): - return False - - -@deprecated(since="6.0") -def is_path_hidden(filepath): - """ - Determines if a given file or directory is hidden. - - Parameters - ---------- - filepath : str - The path to a file or directory - - Returns - ------- - hidden : bool - Returns `True` if the file is hidden - """ - name = os.path.basename(os.path.abspath(filepath)) - if isinstance(name, bytes): - is_dotted = name.startswith(b".") - else: - is_dotted = name.startswith(".") - return is_dotted or _has_hidden_attribute(filepath) - - -@deprecated(since="6.0") -def walk_skip_hidden(top, onerror=None, followlinks=False): - """ - A wrapper for `os.walk` that skips hidden files and directories. - - This function does not have the parameter ``topdown`` from - `os.walk`: the directories must always be recursed top-down when - using this function. - - See Also - -------- - os.walk : For a description of the parameters - """ - for root, dirs, files in os.walk( - top, topdown=True, onerror=onerror, followlinks=followlinks - ): - # These lists must be updated in-place so os.walk will skip - # hidden directories - dirs[:] = [d for d in dirs if not is_path_hidden(d)] - files[:] = [f for f in files if not is_path_hidden(f)] - yield root, dirs, files - - class JsonCustomEncoder(json.JSONEncoder): """Support for data types that JSON default encoder does not do. diff --git a/astropy/utils/tests/data/.hidden_file.txt b/astropy/utils/tests/data/.hidden_file.txt deleted file mode 100644 index 32330e65e467..000000000000 --- a/astropy/utils/tests/data/.hidden_file.txt +++ /dev/null @@ -1 +0,0 @@ -This is a deliberately hidden file. diff --git a/astropy/utils/tests/test_data.py b/astropy/utils/tests/test_data.py index 4e026e3e5264..130a6ed157e3 100644 --- a/astropy/utils/tests/test_data.py +++ b/astropy/utils/tests/test_data.py @@ -13,6 +13,7 @@ import pathlib import platform import random +import re import shutil import stat import sys @@ -33,7 +34,7 @@ from astropy import units as _u # u is taken from astropy.config import paths from astropy.io import fits -from astropy.tests.helper import CI, IS_CRON +from astropy.tests.helper import CI from astropy.utils.compat.optional_deps import HAS_BZ2, HAS_LZMA, HAS_UNCOMPRESSPY from astropy.utils.data import ( CacheDamaged, @@ -133,7 +134,8 @@ def _invalid_urls(tmp_path): @pytest.fixture def temp_cache(tmp_path): - with paths.set_temp_cache(tmp_path): + with paths.set_temp_cache(tmp_path) as tmpdir: + os.makedirs(os.path.join(tmpdir, "download", "url")) yield None check_download_cache() @@ -301,40 +303,6 @@ def a_file(tmp_path): return fn, contents -@pytest.mark.filterwarnings("ignore:unclosed:ResourceWarning") -def test_temp_cache(tmp_path): - dldir0 = _get_download_cache_loc() - check_download_cache() - - with paths.set_temp_cache(tmp_path): - dldir1 = _get_download_cache_loc() - check_download_cache() - assert dldir1 != dldir0 - - dldir2 = _get_download_cache_loc() - check_download_cache() - assert dldir2 != dldir1 - assert dldir2 == dldir0 - - # Check that things are okay even if we exit via an exception - class Special(Exception): - pass - - try: - with paths.set_temp_cache(tmp_path): - dldir3 = _get_download_cache_loc() - check_download_cache() - assert dldir3 == dldir1 - raise Special - except Special: - pass - - dldir4 = _get_download_cache_loc() - check_download_cache() - assert dldir4 != dldir3 - assert dldir4 == dldir0 - - @pytest.mark.filterwarnings("ignore:unclosed:ResourceWarning") @pytest.mark.parametrize("strategy", ["parallel", "sequential"]) def test_download_with_sources_and_bogus_original( @@ -499,7 +467,7 @@ def make_url(): with NamedTemporaryFile("w", dir=tmp_path, delete=False) as f: f.write(content) url = url_to(f.name) - clear_download_cache(url) + clear_download_cache(url, on_missing="ignore") filename = download_file(url, cache=True) return url, filename @@ -686,7 +654,7 @@ def test_download_cache_after_clear(tmp_path, temp_cache, valid_urls): testurl, contents = next(valid_urls) # Test issues raised in #4427 with clear_download_cache() without a URL, # followed by subsequent download. - download_dir = _get_download_cache_loc() + download_dir = _get_download_cache_loc(on_missing="ignore") fnout = download_file(testurl, cache=True) assert os.path.isfile(fnout) @@ -716,11 +684,10 @@ def test_download_parallel_from_internet_works(temp_cache): @pytest.mark.filterwarnings("ignore:unclosed:ResourceWarning") @pytest.mark.parametrize("method", [None, "spawn"]) -def test_download_parallel_fills_cache(tmp_path, valid_urls, method): +def test_download_parallel_fills_cache(valid_urls, method): urls = [] - # tmp_path is shared between many tests, and that can cause weird - # interactions if we set the temporary cache too directly - with paths.set_temp_cache(tmp_path): + with paths.temporary_cache_dir_path(namespace="astropy") as tmp_path: + tmp_path.joinpath("download", "url").mkdir(parents=True) for um, c in islice(valid_urls, FEW): assert not is_url_in_cache(um) urls.append((um, c)) @@ -733,7 +700,10 @@ def test_download_parallel_fills_cache(tmp_path, valid_urls, method): for r, (_, c) in zip(rs, urls): assert get_file_contents(r) == c check_download_cache() - assert not url_set.intersection(get_cached_urls()) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", CacheMissingWarning) + new_urls = get_cached_urls() + assert not url_set.intersection(new_urls) check_download_cache() @@ -841,7 +811,7 @@ def test_download_parallel_update(temp_cache, tmp_path): with open(fn, "w") as f: f.write(c) u = url_to(fn) - clear_download_cache(u) + clear_download_cache(u, on_missing="ignore") td.append((fn, u, c)) r1 = download_files_in_parallel([u for (fn, u, c) in td]) @@ -1124,48 +1094,34 @@ def test_data_noastropy_fallback(monkeypatch): # astropy dir could not be accessed @classmethod def osraiser(cls, linkto, pkgname=None): - raise OSError() + raise OSError("mock os error") monkeypatch.setattr(paths._DirectoryFinder, "find_namespaced_node", osraiser) # make sure the config dir search fails - with pytest.raises(OSError): + with pytest.raises(OSError, match="^mock os error$"): paths.get_cache_dir(rootname="astropy") - with pytest.raises(OSError): + with pytest.raises(OSError, match="^mock os error$"): paths.get_cache_dir_path(rootname="astropy") - with pytest.warns(CacheMissingWarning) as warning_lines: + with pytest.warns( + CacheMissingWarning, + match=( + r"Cache directory cannot be read or created \(mock os error\), " + r"providing data in temporary file instead\." + ), + ): fnout = download_file(TESTURL, cache=True) - n_warns = len(warning_lines) - - partial_warn_msgs = ["remote data cache could not be accessed", "temporary file"] - if n_warns == 4: - partial_warn_msgs.extend(["socket", "socket"]) - - for wl in warning_lines: - cur_w = str(wl).lower() - for i, partial_msg in enumerate(partial_warn_msgs): - if partial_msg in cur_w: - del partial_warn_msgs[i] - break - assert len(partial_warn_msgs) == 0, ( - f"Got some unexpected warnings: {partial_warn_msgs}" - ) - - assert n_warns in (2, 4), f"Expected 2 or 4 warnings, got {n_warns}" assert os.path.isfile(fnout) # clearing the cache should be a no-up that doesn't affect fnout - with pytest.warns(CacheMissingWarning) as record: + with pytest.warns( + CacheMissingWarning, + match="^Not clearing data from cache - problem arose OSError: mock os error$", + ): clear_download_cache(TESTURL) - assert len(record) == 2 - assert ( - record[0].message.args[0] - == "Remote data cache could not be accessed due to OSError" - ) - assert "Not clearing data cache - cache inaccessible" in record[1].message.args[0] assert os.path.isfile(fnout) # now remove it so tests don't clutter up the temp dir this should get @@ -1310,7 +1266,7 @@ def test_check_download_cache(tmp_path, temp_cache, valid_urls, invalid_urls): testurl2, testurl2_contents = next(valid_urls) zip_file_name = tmp_path / "the.zip" - clear_download_cache() + clear_download_cache(on_missing="ignore") assert not check_download_cache() download_file(testurl, cache=True) @@ -1457,7 +1413,7 @@ def test_cache_size_changes_correctly_when_files_are_added_and_removed( temp_cache, valid_urls ): u, c = next(valid_urls) - clear_download_cache(u) + clear_download_cache(u, on_missing="ignore") s_i = cache_total_size() download_file(u, cache=True) assert cache_total_size() == s_i + len(c) + len(u.encode("utf-8")) @@ -1483,14 +1439,27 @@ def test_free_space_checker_huge(tmp_path, desired_size): check_free_space_in_dir(tmp_path, desired_size) +@pytest.mark.parametrize( + "setup, note", + [ + pytest.param(lambda _: None, "no such file or directory", id="filenotfound"), + pytest.param( + lambda p: p.touch(), "found a file, expected a directory", id="fileexists" + ), + ], +) +def test_get_free_space_in_dir_oserror(setup, note, tmp_path): + d = tmp_path / str(uuid4()) + setup(d) + with pytest.raises( + OSError, + match=(rf"^Cannot determine free space from {re.escape(str(d))} \({note}\)$"), + ): + get_free_space_in_dir(d) + + @pytest.mark.filterwarnings("ignore:unclosed:ResourceWarning") def test_get_free_space_file_directory(tmp_path): - fn = tmp_path / "file" - with open(fn, "w"): - pass - with pytest.raises(OSError): - get_free_space_in_dir(fn) - free_space = get_free_space_in_dir(tmp_path) assert free_space > 0 and not hasattr(free_space, "unit") @@ -1521,17 +1490,21 @@ def test_download_file_local_directory(tmp_path): def test_download_file_schedules_deletion(valid_urls): u, c = next(valid_urls) f = download_file(u) - assert f in _tempfilestodel + assert Path(f) in _tempfilestodel # how to test deletion actually occurs? @pytest.mark.filterwarnings("ignore:unclosed:ResourceWarning") -def test_clear_download_cache_refuses_to_delete_outside_the_cache(tmp_path): +def test_clear_download_cache_refuses_to_delete_outside_the_cache(tmp_path, temp_cache): fn = str(tmp_path / "file") with open(fn, "w") as f: f.write("content") assert os.path.exists(fn) - with pytest.raises(RuntimeError): + + with pytest.raises( + RuntimeError, + match="attempted to use clear_download_cache on the path", + ): clear_download_cache(fn) assert os.path.exists(fn) @@ -1541,7 +1514,7 @@ def test_check_download_cache_finds_bogus_entries(temp_cache, valid_urls): u, c = next(valid_urls) download_file(u, cache=True) dldir = _get_download_cache_loc() - bf = os.path.abspath(os.path.join(dldir, "bogus")) + bf = dldir.joinpath("bogus").absolute() with open(bf, "w") as f: f.write("bogus file that exists") with pytest.raises(CacheDamaged) as e: @@ -1553,10 +1526,9 @@ def test_check_download_cache_finds_bogus_entries(temp_cache, valid_urls): @pytest.mark.filterwarnings("ignore:unclosed:ResourceWarning") def test_check_download_cache_finds_bogus_subentries(temp_cache, valid_urls): u, c = next(valid_urls) - f = download_file(u, cache=True) - bf = os.path.abspath(os.path.join(os.path.dirname(f), "bogus")) - with open(bf, "w") as f: - f.write("bogus file that exists") + f = Path(download_file(u, cache=True)) + bf = f.parent.joinpath("bogus").absolute() + bf.write_text("bogus file that exists") with pytest.raises(CacheDamaged) as e: check_download_cache() assert bf in e.value.bad_files @@ -1569,28 +1541,24 @@ def test_check_download_cache_cleanup(temp_cache, valid_urls): fn = download_file(u, cache=True) dldir = _get_download_cache_loc() - bf1 = os.path.abspath(os.path.join(dldir, "bogus1")) - with open(bf1, "w") as f: - f.write("bogus file that exists") + bf1 = dldir.joinpath("bogus1").absolute() + bf1.write_text("bogus file that exists") - bf2 = os.path.abspath(os.path.join(os.path.dirname(fn), "bogus2")) - with open(bf2, "w") as f: - f.write("other bogus file that exists") + bf2 = Path(fn).parent.joinpath("bogus2").absolute() + bf2.write_text("other bogus file that exists") - bf3 = os.path.abspath(os.path.join(dldir, "contents")) - with open(bf3, "w") as f: - f.write("awkwardly-named bogus file that exists") + bf3 = dldir.joinpath("contents").absolute() + bf3.write_text("awkwardly-named bogus file that exists") - u2, c2 = next(valid_urls) - f2 = download_file(u, cache=True) - os.unlink(f2) - bf4 = os.path.dirname(f2) + f2 = Path(download_file(u, cache=True)) + f2.unlink() + bf4 = f2.parent with pytest.raises(CacheDamaged) as e: check_download_cache() assert set(e.value.bad_files) == {bf1, bf2, bf3, bf4} for bf in e.value.bad_files: - clear_download_cache(bf) + clear_download_cache(str(bf)) # download cache will be checked on exit @@ -1612,31 +1580,42 @@ def test_cache_dir_is_actually_a_file(tmp_path: Path, valid_urls): to check what happens if any of the steps in the path is wrong somehow. """ - def check_quietly_ignores_bogus_cache(): + def check_quietly_ignores_bogus_cache(expect_file: bool = False) -> None: """We want a broken cache to produce a warning but then astropy should act like there isn't a cache. """ - with pytest.warns(CacheMissingWarning): + if expect_file: + ctx = pytest.raises(OSError) + ctx2 = pytest.warns(CacheMissingWarning) + ctx3 = contextlib.nullcontext() + ctx4 = ctx + else: + ctx = pytest.warns(CacheMissingWarning) + ctx2 = contextlib.nullcontext() + ctx3 = ctx + ctx4 = contextlib.nullcontext() + + with ctx: assert not get_cached_urls() - with pytest.warns(CacheMissingWarning): + with ctx: assert not is_url_in_cache("http://www.example.com/") - with pytest.warns(CacheMissingWarning): + with ctx: assert not cache_contents() - with pytest.warns(CacheMissingWarning): + with ctx3: u, c = next(valid_urls) - r = download_file(u, cache=True) + with ctx2: + r = download_file(u, cache=True) assert get_file_contents(r) == c # check the filename r appears in a warning message? # check r is added to the delete_at_exit list? # in fact should there be testing of the delete_at_exit mechanism, # as far as that is possible? - with pytest.warns(CacheMissingWarning): + with ctx: assert not is_url_in_cache(u) - with pytest.warns(CacheMissingWarning): - with pytest.raises(OSError): - check_download_cache() + with ctx4: + check_download_cache() - dldir = _get_download_cache_loc() + dldir = _get_download_cache_loc(ensure_exists=True) # set_temp_cache acts weird if it is pointed at a file (see below) # but we want to see what happens when the cache is pointed # at a file instead of a directory, so make a directory we can @@ -1685,7 +1664,7 @@ def check_quietly_ignores_bogus_cache(): cd /= "url" cd.write_text(ct) with paths.set_temp_cache(tmp_path): - check_quietly_ignores_bogus_cache() + check_quietly_ignores_bogus_cache(expect_file=True) assert dldir == _get_download_cache_loc() assert cd.read_text() == ct cd.unlink() @@ -2043,11 +2022,14 @@ def test_check_download_cache_works_if_fake_readonly(fake_readonly_cache): @pytest.mark.filterwarnings("ignore:unclosed:ResourceWarning") +@pytest.mark.usefixtures("ignore_config_paths_global_state") def test_pkgname_isolation(temp_cache, valid_urls): a = str(uuid4()) assert not get_cached_urls() - assert not get_cached_urls(pkgname=a) + with pytest.warns(CacheMissingWarning): + a_urls = get_cached_urls(pkgname=a) + assert not a_urls for u, _ in islice(valid_urls, FEW): download_file(u, cache=True, pkgname=a) @@ -2105,19 +2087,28 @@ def test_pkgname_isolation(temp_cache, valid_urls): clear_download_cache(pkgname=a) assert len(get_cached_urls()) == FEW - assert not get_cached_urls(pkgname=a) + with pytest.warns(CacheMissingWarning): + a_urls = get_cached_urls(pkgname=a) + assert not a_urls clear_download_cache() - assert not get_cached_urls() - assert not get_cached_urls(pkgname=a) + with pytest.warns(CacheMissingWarning): + urls = get_cached_urls() + assert not urls + with pytest.warns(CacheMissingWarning): + a_urls = get_cached_urls(pkgname=a) + assert not a_urls @pytest.mark.filterwarnings("ignore:unclosed:ResourceWarning") +@pytest.mark.usefixtures("ignore_config_paths_global_state") def test_transport_cache_via_zip(temp_cache, valid_urls): a = str(uuid4()) assert not get_cached_urls() - assert not get_cached_urls(pkgname=a) + with pytest.warns(CacheMissingWarning): + a_urls = get_cached_urls(pkgname=a) + assert not a_urls for u, _ in islice(valid_urls, FEW): download_file(u, cache=True) @@ -2152,7 +2143,9 @@ def test_download_parallel_respects_pkgname(temp_cache, valid_urls): a = str(uuid4()) assert not get_cached_urls() - assert not get_cached_urls(pkgname=a) + with pytest.warns(CacheMissingWarning): + a_urls = get_cached_urls(pkgname=a) + assert not a_urls download_files_in_parallel([u for (u, c) in islice(valid_urls, FEW)], pkgname=a) assert not get_cached_urls() @@ -2324,8 +2317,8 @@ def no_rename(path, mode=None): @pytest.mark.filterwarnings("ignore:unclosed:ResourceWarning") @pytest.mark.skipif( - CI and not IS_CRON, - reason="Flaky/too much external traffic for regular CI", + CI, + reason="Flaky/too much external traffic for CI", ) @pytest.mark.remote_data def test_ftp_tls_auto(temp_cache): diff --git a/astropy/utils/tests/test_data_info.py b/astropy/utils/tests/test_data_info.py index 521133ed95d1..ea2c929af931 100644 --- a/astropy/utils/tests/test_data_info.py +++ b/astropy/utils/tests/test_data_info.py @@ -1,5 +1,7 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst +import pickle + import numpy as np import pytest @@ -8,7 +10,7 @@ from astropy.table import QTable from astropy.table.index import SlicedIndex from astropy.time import Time -from astropy.utils.data_info import dtype_info_name +from astropy.utils.data_info import DataInfo, dtype_info_name STRING_TYPE_NAMES = {(True, "S"): "bytes", (True, "U"): "str"} @@ -106,3 +108,10 @@ def test_setting_info_name_to_with_invalid_type(): qt["a"] = [1, 2] * u.m with pytest.raises(TypeError, match="Expected a str value, got 1 with type int"): qt["a"].info.name = 1 + + +def test_pickle_unbound_data_info(): + """Test that an unbound DataInfo object can be pickled.""" + di = DataInfo() + di_rt = pickle.loads(pickle.dumps(di)) + assert isinstance(di_rt, DataInfo) diff --git a/astropy/utils/tests/test_misc.py b/astropy/utils/tests/test_misc.py index 3ea33d2793de..ab067371e868 100644 --- a/astropy/utils/tests/test_misc.py +++ b/astropy/utils/tests/test_misc.py @@ -2,7 +2,6 @@ import json import locale -import os import urllib.error from datetime import datetime @@ -11,7 +10,7 @@ from astropy.io import fits from astropy.tests.helper import CI -from astropy.utils import data, misc +from astropy.utils import misc from astropy.utils.exceptions import AstropyDeprecationWarning @@ -57,31 +56,6 @@ def test_api_lookup(): ) -def test_is_path_hidden_deprecation(): - with pytest.warns( - AstropyDeprecationWarning, match="^The is_path_hidden function is deprecated" - ): - misc.is_path_hidden("data") - - -# This is the only test that uses astropy/utils/tests/data/.hidden_file.txt -def test_skip_hidden(): - path = data.get_pkg_data_path("data") - for _, _, files in os.walk(path): - assert ".hidden_file.txt" in files - assert "local.dat" in files - # break after the first level since the data dir contains some other - # subdirectories that don't have these files - break - with pytest.warns( - AstropyDeprecationWarning, match="^The .*_hidden function is deprecated" - ): - for _, _, files in misc.walk_skip_hidden(path): - assert ".hidden_file.txt" not in files - assert "local.dat" in files - break - - def test_JsonCustomEncoder(): from astropy import units as u diff --git a/astropy/visualization/mpl_normalize.py b/astropy/visualization/mpl_normalize.py index cc5911803f5d..2183efff90bf 100644 --- a/astropy/visualization/mpl_normalize.py +++ b/astropy/visualization/mpl_normalize.py @@ -237,7 +237,7 @@ class SimpleNorm: Parameters ---------- - stretch : {'linear', 'sqrt', 'power', log', 'asinh', 'sinh'}, optional + stretch : {'linear', 'sqrt', 'power', 'log', 'asinh', 'sinh'}, optional The stretch function to apply to the image. The default is 'linear'. @@ -494,7 +494,7 @@ def simple_norm( data : ndarray The image array. - stretch : {'linear', 'sqrt', 'power', log', 'asinh', 'sinh'}, optional + stretch : {'linear', 'sqrt', 'power', 'log', 'asinh', 'sinh'}, optional The stretch function to apply to the image. The default is 'linear'. diff --git a/astropy/visualization/scripts/fits2bitmap.py b/astropy/visualization/scripts/fits2bitmap.py index 6fc5c06be20a..14e5bf158b67 100644 --- a/astropy/visualization/scripts/fits2bitmap.py +++ b/astropy/visualization/scripts/fits2bitmap.py @@ -39,7 +39,7 @@ def fits2bitmap( The filename of the output bitmap image. The type of bitmap is determined by the filename extension (e.g. '.jpg', '.png'). The default is a PNG file with the same name as the FITS file. - stretch : {'linear', 'sqrt', 'power', log', 'asinh'} + stretch : {'linear', 'sqrt', 'power', 'log', 'asinh'} The stretching function to apply to the image. The default is 'linear'. power : float, optional diff --git a/astropy/visualization/wcsaxes/_auto.py b/astropy/visualization/wcsaxes/_auto.py index d746673f157b..dcec8a9dd045 100644 --- a/astropy/visualization/wcsaxes/_auto.py +++ b/astropy/visualization/wcsaxes/_auto.py @@ -61,7 +61,7 @@ def auto_assign_coord_positions(ax): return # Extract the spines for the frame - spines = coords.frame._spine_auto_position_order + spines = ax.coords.frame._spine_auto_position_order # Construct a new list of spines taking into account excluded ones spines = "".join(s for s in spines if s not in already_used) @@ -112,6 +112,12 @@ def auto_assign_coord_positions(ax): n_tick_max = n_tick best_option = option + # If no option was found to be consistent with fixed tick positions, + # fall back to not showing any of the automatically-placed coordinates + # rather than crashing. + if best_option is None: + best_option = (" ",) * len(auto_coords) + # Finalize assignments for coord, spine in zip(auto_coords, best_option): position = [spine, "#"] if spine != " " else "#" diff --git a/astropy/visualization/wcsaxes/coordinate_helpers.py b/astropy/visualization/wcsaxes/coordinate_helpers.py index 3f490a8fc835..d65ca8f0353d 100644 --- a/astropy/visualization/wcsaxes/coordinate_helpers.py +++ b/astropy/visualization/wcsaxes/coordinate_helpers.py @@ -11,7 +11,6 @@ from matplotlib import rcParams from matplotlib.patches import PathPatch from matplotlib.path import Path -from matplotlib.transforms import Affine2D, ScaledTranslation from astropy import units as u from astropy.utils.decorators import deprecated_renamed_argument @@ -115,11 +114,7 @@ def __init__( self.set_coord_type(coord_type, coord_wrap) # Initialize ticks - self.dpi_transform = Affine2D() - self.offset_transform = ScaledTranslation(0, 0, self.dpi_transform) - self._ticks = Ticks( - frame=self.frame, transform=parent_axes.transData + self.offset_transform - ) + self._ticks = Ticks(frame=self.frame, transform=parent_axes.transData) # Initialize tick labels self._ticklabels = TickLabels( @@ -1019,23 +1014,35 @@ def _update_ticks(self): continue axes0 = transData.transform(pixel0) - # Advance 2 pixels in figure coordinates - pixel1 = axes0.copy() - pixel1[:, 0] += 2.0 - pixel1 = invertedTransLimits.transform(pixel1) - with np.errstate(invalid="ignore"): - world1 = self.transform.transform(pixel1)[:, self.coord_index] - - # Advance 2 pixels in figure coordinates - pixel2 = axes0.copy() - pixel2[:, 1] += 2.0 if self.frame.origin == "lower" else -2.0 - pixel2 = invertedTransLimits.transform(pixel2) - with np.errstate(invalid="ignore"): - world2 = self.transform.transform(pixel2)[:, self.coord_index] + # Define a helper function to minimize code repetition + def shifted_pixel_to_world(index, shift): + pixel = axes0.copy() + pixel[:, index] += shift + pixel = invertedTransLimits.transform(pixel) + with np.errstate(invalid="ignore"): + return self.transform.transform(pixel)[:, self.coord_index] + # Advance 2 pixels to the right in figure coordinates + world1 = shifted_pixel_to_world(0, 2) dx = world1 - world0 + + # Where advancing to the right results in NaN, advance to the left instead + invalid1 = np.isnan(world1) + if invalid1.any(): + world1 = shifted_pixel_to_world(0, -2) + dx[invalid1] = (world0 - world1)[invalid1] + + # Advance 2 pixels up in figure coordinates + amount = 2.0 if self.frame.origin == "lower" else -2.0 + world2 = shifted_pixel_to_world(1, amount) dy = world2 - world0 + # Where advancing up results in NaN, advance down instead + invalid2 = np.isnan(world2) + if invalid2.any(): + world2 = shifted_pixel_to_world(1, -amount) + dy[invalid2] = (world0 - world2)[invalid2] + # Rotate by 90 degrees dx, dy = -dy, dx @@ -1163,9 +1170,11 @@ def _compute_ticks( if self.coord_type == "longitude": if self._coord_scale_to_deg is not None: - t *= self._coord_scale_to_deg + world = t * self._coord_scale_to_deg + else: + world = t - world = wrap_angle_at(t, self.coord_wrap.to_value(u.deg)) + world = wrap_angle_at(world, self.coord_wrap.to_value(u.deg)) if self._coord_scale_to_deg is not None: world /= self._coord_scale_to_deg @@ -1191,6 +1200,7 @@ def _compute_ticks( data=(x_data_i, y_data_i), world=world, angle=spine.normal_angle[imin], + tick_angle=angle_i, axis_displacement=imin + frac, ) ) @@ -1432,6 +1442,8 @@ def _clear_grid_contour(self): self._grid.remove() def _update_grid_contour(self): + self._grid = None + if self.coord_index is None: return @@ -1454,12 +1466,17 @@ def _update_grid_contour(self): # tick_world_coordinates is a Quantities array and we only needs its values tick_world_coordinates_values = tick_world_coordinates.value - if self.coord_type == "longitude": - # Find biggest gap in tick_world_coordinates and wrap in middle - # For now just assume spacing is equal, so any mid-point will do - mid = 0.5 * ( - tick_world_coordinates_values[0] + tick_world_coordinates_values[1] - ) + if self.coord_type == "longitude" and len(tick_world_coordinates_values) > 0: + if len(tick_world_coordinates_values) > 1: + # Find biggest gap in tick_world_coordinates and wrap in middle + # For now just assume spacing is equal, so any mid-point will do + mid = 0.5 * ( + tick_world_coordinates_values[0] + tick_world_coordinates_values[1] + ) + else: + # With a single tick, wrap on the opposite side of the sphere + # so the discontinuity cannot cross the contour level + mid = tick_world_coordinates_values[0] + 180.0 field = wrap_angle_at(field, mid) tick_world_coordinates_values = wrap_angle_at( tick_world_coordinates_values, mid @@ -1483,8 +1500,6 @@ def _update_grid_contour(self): field.transpose(), levels=np.sort(tick_world_coordinates_values), ) - else: - self._grid = None def tick_params(self, which="both", **kwargs): """ diff --git a/astropy/visualization/wcsaxes/core.py b/astropy/visualization/wcsaxes/core.py index e6cf7098e66a..dafa9bdbc2d4 100644 --- a/astropy/visualization/wcsaxes/core.py +++ b/astropy/visualization/wcsaxes/core.py @@ -141,12 +141,12 @@ def __init__( # data->pixel mapping self.transData = transData + self._display_coords_index = 0 self.reset_wcs( wcs=wcs, slices=slices, transform=transform, coord_meta=coord_meta ) self._hide_parent_artists() self.format_coord = self._display_world_coords - self._display_coords_index = 0 fig.canvas.mpl_connect("key_press_event", self._set_cursor_prefs) self.patch = self.coords.frame.patch self._wcsaxesartist = _WCSAxesArtist() @@ -468,6 +468,13 @@ def reset_wcs(self, wcs=None, slices=None, transform=None, coord_meta=None): self._all_coords = [self.coords] + # Any overlays added via get_coords_overlay have been dropped above, + # so make sure the cursor position display does not try to index + # into a now out-of-range overlay. The -1 (pixel display) state + # remains valid, so leave it alone. + if self._display_coords_index >= len(self._all_coords): + self._display_coords_index = 0 + # Common default settings for Rectangular Frame for ind, pos in enumerate( coord_meta.get("default_axislabel_position", ["b", "l"]) @@ -673,9 +680,11 @@ def get_coords_overlay(self, frame, coord_meta=None): # Here we can't use get_transform because that deals with # pixel-to-pixel transformations when passing a WCS object. if isinstance(frame, WCS): - transform, coord_meta = transform_coord_meta_from_wcs( + transform, wcs_coord_meta = transform_coord_meta_from_wcs( frame, self.frame_class ) + if coord_meta is None: + coord_meta = wcs_coord_meta else: transform = self._get_transform_no_transdata(frame) @@ -691,11 +700,22 @@ def get_coords_overlay(self, frame, coord_meta=None): self._all_coords.append(coords) - # Common settings for overlay - coords[0].set_axislabel_position("t") - coords[1].set_axislabel_position("r") - coords[0].set_ticklabel_position("t") - coords[1].set_ticklabel_position("r") + # Common settings for overlay: place overlay labels on the + # opposite side from the main axes labels. For rectangular frames + # this means 't' (top) and 'r' (right). For non-rectangular + # frames (e.g., EllipticalFrame), 't' and 'r' are not valid spine + # names so we leave the tick label positions empty (no tick labels + # shown by default for overlays on non-rectangular frames). + if issubclass(self.frame_class, RectangularFrame): + coords[0].set_axislabel_position("t") + coords[1].set_axislabel_position("r") + coords[0].set_ticklabel_position("t") + coords[1].set_ticklabel_position("r") + else: + coords[0].set_axislabel_position("") + coords[1].set_axislabel_position("") + coords[0].set_ticklabel_position("") + coords[1].set_ticklabel_position("") self.overlay_coords = coords diff --git a/astropy/visualization/wcsaxes/formatter_locator.py b/astropy/visualization/wcsaxes/formatter_locator.py index 53197ca4c8c2..e1970f7a519e 100644 --- a/astropy/visualization/wcsaxes/formatter_locator.py +++ b/astropy/visualization/wcsaxes/formatter_locator.py @@ -19,12 +19,12 @@ from astropy.coordinates import Angle from astropy.units import UnitsError -DMS_RE = re.compile("^dd(:mm(:ss(.(s)+)?)?)?$") -HMS_RE = re.compile("^hh(:mm(:ss(.(s)+)?)?)?$") -DDEC_RE = re.compile("^d(.(d)+)?$") -DMIN_RE = re.compile("^m(.(m)+)?$") -DSEC_RE = re.compile("^s(.(s)+)?$") -SCAL_RE = re.compile("^x(.(x)+)?$") +DMS_RE = re.compile(r"^dd(:mm(:ss(\.(s)+)?)?)?$") +HMS_RE = re.compile(r"^hh(:mm(:ss(\.(s)+)?)?)?$") +DDEC_RE = re.compile(r"^d(\.(d)+)?$") +DMIN_RE = re.compile(r"^m(\.(m)+)?$") +DSEC_RE = re.compile(r"^s(\.(s)+)?$") +SCAL_RE = re.compile(r"^x(\.(x)+)?$") # Units with custom representations - see the note where it is used inside @@ -134,7 +134,7 @@ def spacing(self, spacing): self._values = None def minor_locator(self, spacing, frequency, value_min, value_max): - if self.values is not None: + if self.values is not None or self.number == 0: return [] * self._unit minor_spacing = spacing.value / frequency @@ -366,7 +366,9 @@ def locator(self, value_min, value_max): spacing_value = self.spacing.to_value(self._unit) elif self.number == 0: - return [] * self._unit, np.nan * self._unit + # Return a finite spacing in case the caller needs to format + # a single coordinate, e.g. for the mouseover display. + return [] * self._unit, 1 * u.arcsec elif self.number is not None: # number of ticks was specified, work out optimal spacing @@ -600,14 +602,21 @@ def locator(self, value_min, value_max): else: # In the special case where value_min is the same as value_max, we # don't locate any ticks. This can occur for example when taking a - # slice for a cube (along the dimension sliced). + # slice for a cube (along the dimension sliced). We return a + # non-zero spacing in case the caller needs to format a single + # coordinate, e.g. for mouseover. if value_min == value_max: - return [] * self._unit, 0 * self._unit + return [] * self._unit, 1 * self._unit if self.spacing is not None: # spacing was manually specified spacing = self.spacing.to_value(self._unit) + elif self.number == 0: + # Return a finite spacing in case the caller needs to format + # a single coordinate, e.g. for the mouseover display. + return [] * self._unit, 1 * self._unit + elif self.number is not None: # number of ticks was specified, work out optimal spacing @@ -643,7 +652,7 @@ def formatter(self, values, spacing, format="auto"): else: precision = 0 elif self.format.startswith("%"): - return [(self.format % x.value) for x in values] + return [(self.format % x.to_value(self._format_unit)) for x in values] else: precision = self._precision diff --git a/astropy/visualization/wcsaxes/frame.py b/astropy/visualization/wcsaxes/frame.py index 3b875c960a07..9800602dc822 100644 --- a/astropy/visualization/wcsaxes/frame.py +++ b/astropy/visualization/wcsaxes/frame.py @@ -97,10 +97,8 @@ def world(self, value): self._world = value if value is None: self._data = None - self._pixel = None else: self._data = self.transform.transform(value) - self._pixel = self.parent_axes.transData.transform(self._data) self._update_normal() def _update_normal(self): diff --git a/astropy/visualization/wcsaxes/grid_paths.py b/astropy/visualization/wcsaxes/grid_paths.py index 0e79e54f9f5e..9eeba9a89adc 100644 --- a/astropy/visualization/wcsaxes/grid_paths.py +++ b/astropy/visualization/wcsaxes/grid_paths.py @@ -47,7 +47,7 @@ def get_lon_lat_path(lon_lat, pixel, lon_lat_check): with np.errstate(invalid="ignore"): sep[sep > np.pi] -= 2.0 * np.pi - mask = np.abs(sep > ROUND_TRIP_RTOL * scale_size) + mask = sep > ROUND_TRIP_RTOL * scale_size # Mask values with invalid pixel positions mask = mask | np.isnan(pixel[:, 0]) | np.isnan(pixel[:, 1]) @@ -81,7 +81,7 @@ def get_lon_lat_path(lon_lat, pixel, lon_lat_check): codes[2:][discontinuous] = Path.MOVETO # The above missed the first step, so check that too - if step[0] > DISCONT_FACTOR * step[1]: + if len(step) >= 2 and step[0] > DISCONT_FACTOR * step[1]: codes[1] = Path.MOVETO # Create the path diff --git a/astropy/visualization/wcsaxes/tests/test_coordinate_helpers.py b/astropy/visualization/wcsaxes/tests/test_coordinate_helpers.py index 29ca0656e64d..86dc03f87799 100644 --- a/astropy/visualization/wcsaxes/tests/test_coordinate_helpers.py +++ b/astropy/visualization/wcsaxes/tests/test_coordinate_helpers.py @@ -3,9 +3,11 @@ from unittest.mock import MagicMock, patch import matplotlib.transforms as transforms +import numpy as np import pytest from matplotlib.backends.backend_agg import FigureCanvasAgg from matplotlib.figure import Figure +from matplotlib.path import Path from astropy import units as u from astropy.io import fits @@ -396,3 +398,123 @@ def test_set_ticks_values(): lbl_locations = u.Quantity(lbl_world1, unit=u.deg) assert u.allclose(lbl_locations, ax.coords[0]._formatter_locator.values) assert u.Quantity(lbl_world).unit is xticks.unit + + +@pytest.mark.parametrize("n_ticks", [0, 1]) +def test_grid_contour_few_ticks(n_ticks): + # Regression test for an IndexError when drawing a contour-type grid + # for a longitude coordinate that has 0 or 1 major ticks. + fig = Figure() + canvas = FigureCanvasAgg(fig) + ax = WCSAxes(fig, [0.1, 0.1, 0.8, 0.8], wcs=WCS(MSX_HEADER)) + fig.add_axes(ax) + + if n_ticks == 0: + ax.coords[0].set_ticks(number=0) + else: + ax.coords[0].set_ticks(values=[320] * u.deg) + + ax.coords[0].grid(grid_type="contours") + + canvas.draw() + + +def test_grid_contour_sliced_coord(): + # Regression test for an AttributeError when drawing a contour-type grid + # for a coordinate that has been sliced out of the plot. + wcs = WCS(naxis=3) + wcs.wcs.ctype = ["RA---TAN", "DEC--TAN", "FREQ"] + wcs.wcs.crval = [10, 20, 1.42e9] + wcs.wcs.crpix = [30, 40, 5] + wcs.wcs.cdelt = [-0.1, 0.1, 1e7] + + fig = Figure() + canvas = FigureCanvasAgg(fig) + ax = WCSAxes(fig, [0.1, 0.1, 0.8, 0.8], wcs=wcs, slices=("x", "y", 5)) + fig.add_axes(ax) + + ax.coords[2].grid(grid_type="contours") + + canvas.draw() + + +def test_grid_contour_one_tick_crossing_wrap(): + # A single longitude tick on a field of view that crosses the longitude + # wrap should produce one gridline, not an additional spurious line + # along the wrap discontinuity. + wcs = WCS(naxis=2) + wcs.wcs.ctype = ["RA---TAN", "DEC--TAN"] + wcs.wcs.crval = [0, 0] + wcs.wcs.crpix = [50, 50] + wcs.wcs.cdelt = [-0.1, 0.1] + + fig = Figure() + canvas = FigureCanvasAgg(fig) + ax = WCSAxes(fig, [0.1, 0.1, 0.8, 0.8], wcs=wcs) + fig.add_axes(ax) + ax.set_xlim(0, 100) + ax.set_ylim(0, 100) + + ax.coords[0].set_ticks(values=[358] * u.deg) + ax.coords[0].grid(grid_type="contours") + + canvas.draw() + + n_lines = sum( + np.sum(path.codes == Path.MOVETO) for path in ax.coords[0]._grid.get_paths() + ) + assert n_lines == 1 + + +def test_ticks_multiple_intersections_non_degree_longitude(): + # Regression test for a bug where, for a longitude coordinate whose unit + # is not degrees, a tick with more than one intersection along a single + # spine would get corrupted world coordinates (and could raise) for all + # but the first intersection, because the loop variable was rescaled to + # degrees in place instead of using a separate variable. + class OscillatingTransform(transforms.Transform): + input_dims = 2 + output_dims = 2 + is_separable = False + has_inverse = False + + def transform(self, values): + x = values[:, 0] + y = values[:, 1] + lon = 0.05 + 0.02 * np.sin(x / 3.0) + lat = y * 0.001 + return np.column_stack([lon, lat]) + + def transform_path(self, path): + from matplotlib.path import Path + + return Path(self.transform(path.vertices), path.codes) + + transform_path_non_affine = transform_path + + coord_meta = { + "type": ("longitude", "latitude"), + "unit": (u.rad, u.rad), + "wrap": (360 * u.deg, None), + "name": ("lon", "lat"), + } + + fig = Figure() + canvas = FigureCanvasAgg(fig) + ax = WCSAxes( + fig, + [0.1, 0.1, 0.8, 0.8], + transform=OscillatingTransform(), + coord_meta=coord_meta, + ) + fig.add_axes(ax) + ax.set_xlim(0, 100) + ax.set_ylim(0, 100) + + # This tick value is crossed many times along the bottom spine because + # the custom transform oscillates the longitude as a function of x. + ax.coords[0].set_ticks(values=[0.05] * u.rad) + + # Should not raise (previously corrupted intermediate tick values could + # produce huge/garbage numbers that broke text rendering). + canvas.draw() diff --git a/astropy/visualization/wcsaxes/tests/test_formatter_locator.py b/astropy/visualization/wcsaxes/tests/test_formatter_locator.py index f74db6eff621..1948e935e6a4 100644 --- a/astropy/visualization/wcsaxes/tests/test_formatter_locator.py +++ b/astropy/visualization/wcsaxes/tests/test_formatter_locator.py @@ -3,15 +3,23 @@ import numpy as np import pytest from matplotlib import rc_context +from matplotlib.backends.backend_agg import FigureCanvasAgg +from matplotlib.figure import Figure from numpy.testing import assert_almost_equal from astropy import units as u +from astropy.io import fits from astropy.tests.helper import assert_quantity_allclose from astropy.units import UnitsError +from astropy.utils.data import get_pkg_data_filename +from astropy.visualization.wcsaxes.core import WCSAxes from astropy.visualization.wcsaxes.formatter_locator import ( AngleFormatterLocator, ScalarFormatterLocator, ) +from astropy.wcs import WCS + +MSX_HEADER = fits.Header.fromtextfile(get_pkg_data_filename("data/msx_header")) class TestAngleFormatterLocator: @@ -643,3 +651,59 @@ def test_values_unit(self): fl = ScalarFormatterLocator(unit=u.cm, format_unit=u.m) fl.format = "x.x" assert_quantity_allclose(fl.locator(1, 19)[0], [10] * u.cm) + + +def test_angle_number_zero_finite_spacing(): + # Regression test for a bug where the locator returned NaN spacing when + # the number of ticks was set to zero, which crashed the formatter, + # e.g. in the mouseover coordinate display. + fl = AngleFormatterLocator(number=0) + values, spacing = fl.locator(30, 40) + assert len(values) == 0 + assert fl.formatter([32.5] * u.deg, spacing) == ["32°30'00\""] + + +def test_set_ticks_number_zero_scalar(): + # Regression test for a bug that caused a ZeroDivisionError when + # set_ticks(number=0) was used on a scalar (non-angle) coordinate. + fig = Figure() + canvas = FigureCanvasAgg(fig) + ax = WCSAxes(fig, [0.1, 0.1, 0.8, 0.8], wcs=WCS(naxis=1)) + fig.add_axes(ax) + + ax.coords[0].set_ticks(number=0) + ax.coords[0].display_minor_ticks(True) + canvas.draw() + + +def test_scalar_degenerate_range_formatter(): + # Regression test for a bug where the locator returned zero spacing for + # a degenerate (zero-size) coordinate range, which crashed the formatter + # with an OverflowError, e.g. in the mouseover coordinate display. + fl = ScalarFormatterLocator(unit=u.m) + values, spacing = fl.locator(5, 5) + assert len(values) == 0 + assert fl.formatter([5] * u.m, spacing) == ["5"] + + +def test_scalar_percent_format_uses_format_unit(): + # Regression test for a bug where a '%'-style format ignored the format + # unit and instead formatted the coordinate's native unit. + fl = ScalarFormatterLocator(number=5, format="%.2f", unit=u.m) + fl.format_unit = u.km + assert fl.formatter([2000.0] * u.m, None)[0] == "2.00" + + +def test_set_major_formatter_rejects_malformed_separator(): + # Regression test for a bug where the format-string regexes used an + # unescaped '.', so a non-dot separator before the fractional field was + # silently accepted (and the fractional field silently dropped) instead + # of raising a ValueError. + fig = Figure() + canvas = FigureCanvasAgg(fig) + ax = WCSAxes(fig, [0.1, 0.1, 0.8, 0.8], wcs=WCS(MSX_HEADER)) + fig.add_axes(ax) + canvas.draw() + + with pytest.raises(ValueError, match="Invalid format"): + ax.coords[1].set_major_formatter("dd:mm:ssXs") diff --git a/astropy/visualization/wcsaxes/tests/test_grid_paths.py b/astropy/visualization/wcsaxes/tests/test_grid_paths.py index 8d7e8f961c4e..a491c0fc978b 100644 --- a/astropy/visualization/wcsaxes/tests/test_grid_paths.py +++ b/astropy/visualization/wcsaxes/tests/test_grid_paths.py @@ -1,8 +1,30 @@ import numpy as np import pytest +from matplotlib.backends.backend_agg import FigureCanvasAgg +from matplotlib.figure import Figure from matplotlib.lines import Path +from astropy.visualization.wcsaxes import conf from astropy.visualization.wcsaxes.grid_paths import get_lon_lat_path +from astropy.wcs import WCS + + +def test_gridline_two_samples_no_error(): + # Regression test for a bug where drawing a gridline sampled with only + # two points raised an IndexError while checking for discontinuities. + wcs = WCS(naxis=2) + wcs.wcs.ctype = ["RA---TAN", "DEC--TAN"] + wcs.wcs.crval = [0, 0] + wcs.wcs.crpix = [1, 1] + wcs.wcs.cdelt = [-1, 1] + + fig = Figure() + canvas = FigureCanvasAgg(fig) + ax = fig.add_subplot(1, 1, 1, projection=wcs) + + with conf.set_temp("grid_samples", 2): + ax.coords[0].grid() + canvas.draw() @pytest.mark.parametrize("step_in_degrees", [10, 1, 0.01]) diff --git a/astropy/visualization/wcsaxes/tests/test_misc.py b/astropy/visualization/wcsaxes/tests/test_misc.py index e2395e4fe876..49a797dec961 100644 --- a/astropy/visualization/wcsaxes/tests/test_misc.py +++ b/astropy/visualization/wcsaxes/tests/test_misc.py @@ -5,6 +5,7 @@ import matplotlib as mpl import numpy as np import pytest +from matplotlib.backend_bases import KeyEvent from matplotlib.backends.backend_agg import FigureCanvasAgg from matplotlib.contour import QuadContourSet from matplotlib.figure import Figure @@ -578,7 +579,7 @@ def test_set_labels_with_coords(ignore_matplotlibrc, frame_class): @pytest.mark.parametrize("atol", [0.2, 1.0e-8]) def test_bbox_size(atol): # Test for the size of a WCSAxes bbox (only have Matplotlib >= 3.0 now) - extents = [11.38888888888889, 3.5, 576.0, 432.0] + extents = [11.38888888888889, 3.388888888888889, 576.0, 432.0] fig = Figure() canvas = FigureCanvasAgg(fig) @@ -790,6 +791,19 @@ def test_simplify_cases(before, after): assert ticklabels.text["axis"] == after +def test_simplify_labels_prefix(ignore_matplotlibrc): + # Regression test for crashes in simplify_labels (IndexError or + # UnboundLocalError/NameError) when one tick label is a prefix of an + # adjacent one, which can happen with tick labels of varying length. + ticklabels = TickLabels(frame=MagicMock()) + for i, label in enumerate(["10d30m", "10d3"]): + ticklabels.add( + axis="axis", world=0, angle=0, text=label, axis_displacement=i, data=(i, i) + ) + ticklabels.simplify_labels() + assert ticklabels.text["axis"] == ["10d30m", "10d3"] + + def test_get_transform_unit_mismatch(): """ Regression test for a bug that caused get_transform to ignore differences @@ -824,3 +838,102 @@ def test_get_transform_unit_mismatch(): pixels = rng.uniform(0, 100, (2, 100)) assert_allclose(transform1.transform(pixels), transform2.transform(pixels)) + + +def test_get_coords_overlay_wcs_honors_coord_meta(ignore_matplotlibrc): + # Regression test for a bug where get_coords_overlay silently discarded + # a caller-supplied coord_meta when the frame passed in was a WCS, + # unlike every other frame type. + wcs = WCS(TARGET_HEADER) + + fig = Figure() + ax = fig.add_subplot(1, 1, 1, projection=wcs) + + custom_coord_meta = { + "type": ("scalar", "scalar"), + "wrap": (None, None), + "unit": (u.deg, u.deg), + "name": ("x", "y"), + } + + overlay = ax.get_coords_overlay(WCS(TARGET_HEADER), coord_meta=custom_coord_meta) + + assert overlay[0].coord_type == "scalar" + assert overlay[1].coord_type == "scalar" + + +def test_get_coords_overlay_elliptical_frame(ignore_matplotlibrc): + """ + Regression test for calling get_coords_overlay on axes with a + non-rectangular frame (e.g., EllipticalFrame). + + Previously this would emit an AstropyDeprecationWarning because 't' + and 'r' are not valid spine names for EllipticalFrame. + """ + wcs = WCS(naxis=2) + wcs.wcs.ctype = ["GLON-AIT", "GLAT-AIT"] + wcs.wcs.crpix = [180.5, 90.5] + wcs.wcs.cdelt = [-1, 1] + wcs.wcs.crval = [0, 0] + + fig = Figure() + ax = fig.add_subplot(projection=wcs, frame_class=EllipticalFrame) + + # This should not raise an AstropyDeprecationWarning + overlay = ax.get_coords_overlay("icrs") + assert overlay is not None + + +def test_reset_wcs_resets_display_coords_index(ignore_matplotlibrc): + # Regression test for a bug where reset_wcs dropped overlays from + # _all_coords without resetting _display_coords_index, so that if the + # cursor display had been advanced to an overlay (by pressing "w"), + # format_coord would later raise an IndexError. + wcs = WCS(TARGET_HEADER) + fig = Figure() + canvas = FigureCanvasAgg(fig) + ax = fig.add_subplot(1, 1, 1, projection=wcs) + canvas.draw() + + ax.get_coords_overlay("icrs") + + # Simulate the user pressing "w" to advance the cursor display to the + # overlay coordinates. + event = KeyEvent("key_press_event", canvas, "w") + canvas.callbacks.process("key_press_event", event) + + ax.reset_wcs(wcs=wcs) + + # This should not raise an IndexError. + assert ax.format_coord(10, 10) != "" + + # Pressing "w" once more cycles past the world coordinates to the pixel + # display mode, which is still valid after reset_wcs and should survive it. + canvas.callbacks.process("key_press_event", event) + assert "pixel" in ax.format_coord(10, 10) + + ax.reset_wcs(wcs=wcs) + assert "pixel" in ax.format_coord(10, 10) + + +def test_auto_assign_coord_positions_no_consistent_option( + ignore_matplotlibrc, tmp_path +): + # Regression test for a bug where auto_assign_coord_positions crashed + # with a TypeError if no assignment of spines to coordinates was + # consistent with fixed tick positions (best_option stayed None). + wcs = WCS(TARGET_HEADER) + fig = Figure() + ax = fig.add_subplot(1, 1, 1, projection=wcs) + + # Coordinate 0 has fixed tick label position on 'b', so 'b' is excluded + # from the spines available for automatic placement. + ax.coords[0].set_ticklabel_position("b") + ax.coords[0].set_ticks_position("b") + + # Coordinate 1 has fixed tick position on 'b' but automatic tick labels, + # so every candidate spine assignment is inconsistent. + ax.coords[1].set_ticks_position("b") + ax.coords[1].set_ticklabel_position("#") + + fig.savefig(tmp_path / "plot.png") diff --git a/astropy/visualization/wcsaxes/tests/test_wcsapi.py b/astropy/visualization/wcsaxes/tests/test_wcsapi.py index 7c2106e7ab81..57491374fd0f 100644 --- a/astropy/visualization/wcsaxes/tests/test_wcsapi.py +++ b/astropy/visualization/wcsaxes/tests/test_wcsapi.py @@ -168,6 +168,81 @@ def test_3d(): np.testing.assert_allclose(world[:, 1], world_2[:, 1]) +def test_world2pixel_transform_empty_input_1d_wcs(): + # Regression test: transforming an empty array of world coordinates + # should not crash for a 1-d WCS (e.g. RectangularFrame1D axes). + wcs = WCS(naxis=1) + wcs.wcs.ctype = ["WAVE"] + wcs.wcs.crpix = [256.0] + wcs.wcs.cdelt = [-0.05] + wcs.wcs.crval = [50.0] + wcs.wcs.set() + + fig = Figure() + ax = fig.add_subplot(111, projection=wcs) + transform = ax.get_transform("world") + + result = transform.transform_non_affine(np.zeros((0, 2))) + assert result.shape == (0, 2) + + +def test_pixel2world_transform_empty_input_shape(): + # Regression test: the empty-input branch of the pixel->world transform + # should return the same (N, dims) shape convention as the non-empty + # branch, instead of a transposed (dims, N) shape. + wcs = WCS(naxis=2) + wcs.wcs.ctype = ["RA---TAN", "DEC--TAN"] + wcs.wcs.crpix = [256.0, 256.0] + wcs.wcs.cdelt = [-0.05, 0.05] + wcs.wcs.crval = [120.0, -19.0] + + fig = Figure() + ax = fig.add_subplot(111, projection=wcs) + transform = ax.get_transform("world").inverted() + + result = transform.transform_non_affine(np.zeros((0, 2))) + assert result.shape == (0, 2) + + +def test_pixel2world_transform_empty_input_1d_wcs(): + # Regression test: the pixel->world transform for a 1-d WCS should + # return a 2-d (0, 1) shaped array for empty input, not a 3-d array. + wcs = WCS(naxis=1) + wcs.wcs.ctype = ["WAVE"] + wcs.wcs.crpix = [256.0] + wcs.wcs.cdelt = [-0.05] + wcs.wcs.crval = [50.0] + wcs.wcs.set() + + fig = Figure() + ax = fig.add_subplot(111, projection=wcs) + # ax.coords[i].transform is the documented public transform for a given + # coordinate, and for a 1-d WCS is the bare pixel-to-world transform. + transform = ax.coords[0].transform + + result = transform.transform_non_affine(np.zeros((0, 1))) + assert result.shape == (0, 1) + + +def test_pixel2world_transform_wrong_dimension_message(): + # Regression test: the pixel->world transform validates the number of + # *pixel* coordinates, so the error message should refer to pixel + # coordinates, not world coordinates. + wcs = WCS(naxis=1) + wcs.wcs.ctype = ["WAVE"] + wcs.wcs.crpix = [256.0] + wcs.wcs.cdelt = [-0.05] + wcs.wcs.crval = [50.0] + wcs.wcs.set() + + fig = Figure() + ax = fig.add_subplot(111, projection=wcs) + transform = ax.get_transform("world").inverted() + + with pytest.raises(ValueError, match=r"Expected 1 pixel coordinates, got 2"): + transform.transform_non_affine(np.ones((3, 2))) + + def test_coord_type_from_ctype(cube_wcs): _, coord_meta = transform_coord_meta_from_wcs( cube_wcs, RectangularFrame, slices=(50, "y", "x") @@ -437,6 +512,97 @@ def test_custom_coord_type_1d_2d_wcs_overwrite(): assert coord_meta["wrap"] == [None, None] +def test_custom_ucd_coord_meta_mapping_partial_conflict(): + # Regression test: if a later key in the mapping conflicts with an + # existing entry and overwrite=False, no keys from the mapping should + # be added, including ones that come before the conflicting key. + custom_meta = { + "pos.newkey": {"coord_type": "longitude"}, + "pos.heliographic.stonyhurst.lon": {"coord_type": "latitude"}, + } + + with pytest.raises( + ValueError, match="pos.heliographic.stonyhurst.lon already exists" + ): + with custom_ucd_coord_meta_mapping(custom_meta): + pass + + # If the failed call above had already registered "pos.newkey", then + # registering it again with overwrite=False would raise; it must not. + with custom_ucd_coord_meta_mapping({"pos.newkey": {"coord_type": "longitude"}}): + pass + + +def test_custom_ucd_coord_meta_mapping_duplicate_key(): + # A mapping containing both 'custom:X' and 'X' defines the same UCD + # twice and should raise instead of silently keeping the last value. + custom_meta = { + "custom:pos.eggs": {"coord_type": "longitude"}, + "pos.eggs": {"coord_type": "latitude"}, + } + + with pytest.raises(ValueError, match="specified more than once"): + with custom_ucd_coord_meta_mapping(custom_meta): + pass + + +class LowLevelWCSRANonInternedDeg(BaseLowLevelWCS): + # APE 14 WCS whose RA axis unit is equal to but not the interned u.deg + # singleton, to test that the hourangle format-unit override does not + # rely on object identity. + + @property + def pixel_n_dim(self): + return 2 + + @property + def world_n_dim(self): + return 2 + + @property + def world_axis_physical_types(self): + return ["pos.eq.ra", "pos.eq.dec"] + + @property + def world_axis_units(self): + return ["1 deg", "1 deg"] + + @property + def world_axis_names(self): + return ["RA", "DEC"] + + def pixel_to_world_values(self, *pixel_arrays): + return pixel_arrays + + def world_to_pixel_values(self, *world_arrays): + return world_arrays + + @property + def world_axis_object_components(self): + return [ + ("celestial", 0, "spherical.lon.degree"), + ("celestial", 1, "spherical.lat.degree"), + ] + + @property + def world_axis_object_classes(self): + return {"celestial": (SkyCoord, (), {"unit": "deg"})} + + +def test_coord_type_ra_non_interned_deg_unit(): + # Regression test: the RA -> hourangle format-unit override should not + # rely on axis_unit being the interned u.deg singleton. + assert u.Unit("1 deg") == u.deg + assert u.Unit("1 deg") is not u.deg + + _, coord_meta = transform_coord_meta_from_wcs( + LowLevelWCSRANonInternedDeg(), RectangularFrame + ) + + assert coord_meta["type"] == ["longitude", "latitude"] + assert coord_meta["format_unit"] == [u.hourangle, u.Unit("1 deg")] + + def test_coord_type_1d_1d_wcs(): wcs = WCS(naxis=1) wcs.wcs.ctype = ["WAVE"] diff --git a/astropy/visualization/wcsaxes/ticklabels.py b/astropy/visualization/wcsaxes/ticklabels.py index fdc55a47b63d..8177d5029128 100644 --- a/astropy/visualization/wcsaxes/ticklabels.py +++ b/astropy/visualization/wcsaxes/ticklabels.py @@ -10,8 +10,6 @@ from astropy.utils.decorators import deprecated_renamed_argument from astropy.utils.exceptions import AstropyDeprecationWarning -from .frame import RectangularFrame - def sort_using(X, Y): return [x for (y, x) in sorted(zip(Y, X))] @@ -66,6 +64,7 @@ def clear(self): self.world = defaultdict(list) self.data = defaultdict(list) self.angle = defaultdict(list) + self.tick_angle = defaultdict(list) self.text = defaultdict(list) self.disp = defaultdict(list) @@ -78,6 +77,7 @@ def add( text=None, axis_displacement=None, data=None, + tick_angle=None, ): """ Add a label. @@ -98,6 +98,8 @@ def add( Displacement from axis. data : [float, float] Data coordinates of the label. + tick_angle : float + Angle of the corresponding tick. Defaults to be equal to ``angle``. """ required_args = ["axis", "world", "angle", "text", "axis_displacement", "data"] if pixel is not None: @@ -123,6 +125,7 @@ def add( self.world[axis].append(world) self.data[axis].append(data) self.angle[axis].append(angle) + self.tick_angle[axis].append(tick_angle if tick_angle is not None else angle) self.text[axis].append(text) self.disp[axis].append(axis_displacement) @@ -137,6 +140,7 @@ def sort(self): self.world[axis] = sort_using(self.world[axis], self.disp[axis]) self.data[axis] = sort_using(self.data[axis], self.disp[axis]) self.angle[axis] = sort_using(self.angle[axis], self.disp[axis]) + self.tick_angle[axis] = sort_using(self.tick_angle[axis], self.disp[axis]) self.text[axis] = sort_using(self.text[axis], self.disp[axis]) self.disp[axis] = sort_using(self.disp[axis], self.disp[axis]) self._stale = True @@ -159,10 +163,19 @@ def simplify_labels(self): # non-number (and non-decimal place) character we can find. start = _find_start_of_last_number(t2) else: - for j in range(len(t1)): + start = 0 + for j in range(min(len(t1), len(t2))): if t1[j] != t2[j]: start = _find_start_of_last_number(t2[: j + 1]) break + else: + # One of the strings is a prefix of the other (up to + # the length of the shorter one) without any + # differing character, so the entire overlapping + # part can be considered shared and only the extra + # trailing part of t2 (if any) needs to be shown. + if len(t2) > len(t1): + start = _find_start_of_last_number(t2[: len(t1) + 1]) if start != 0: starts_dollar = t2.startswith("$") @@ -211,7 +224,6 @@ def _set_xy_alignments(self, renderer): if self._simplify: self.simplify_labels() - text_size = renderer.points_to_pixels(self.get_size()) visible_axes = self.get_visible_axes() self.xy = {axis: {} for axis in visible_axes} @@ -229,89 +241,60 @@ def _set_xy_alignments(self, renderer): x, y = self._frame.parent_axes.transData.transform(self.data[axis][i]) pad = renderer.points_to_pixels(self.get_pad() + self._tick_out_size) - if isinstance(self._frame, RectangularFrame): - # This is just to preserve the current results, but can be - # removed next time the reference images are re-generated. - if np.abs(self.angle[axis][i]) < 45.0: - ha = "right" - va = "bottom" - dx = -pad - dy = -text_size * 0.5 - elif np.abs(self.angle[axis][i] - 90.0) < 45: - ha = "center" - va = "bottom" - dx = 0 - dy = -text_size - pad - elif np.abs(self.angle[axis][i] - 180.0) < 45: - ha = "left" - va = "bottom" - dx = pad - dy = -text_size * 0.5 + # Set initial position and find bounding box + self.set_text(self.text[axis][i]) + self.set_position((x, y)) + bb = super().get_window_extent(renderer) + + width = bb.width + height = bb.height + + # The pad direction (typically perpendicular to the spine) + pad_angle = np.radians(self.angle[axis][i]) + px = np.cos(pad_angle) + py = np.sin(pad_angle) + + # If the tick angle is NaN, use the pad angle for the tick angle + tick_angle = ( + np.radians(self.tick_angle[axis][i]) + if not np.isnan(self.tick_angle[axis][i]) + else pad_angle + ) + tx = np.cos(tick_angle) + ty = np.sin(tick_angle) + + # Set anchor point for label where the pad direction intersects bounding box + with np.errstate(divide="ignore"): + if np.abs(py / px) < np.abs(height / width): + ax = width / 2 * np.sign(px) + ay = width / 2 * py / np.abs(px) else: - ha = "center" - va = "bottom" - dx = 0 - dy = pad - - x = x + dx - y = y + dy - - else: - # This is the more general code for arbitrarily oriented - # axes - - # Set initial position and find bounding box - self.set_text(self.text[axis][i]) - self.set_position((x, y)) - bb = super().get_window_extent(renderer) - - # Find width and height, as well as angle at which we - # transition which side of the label we use to anchor the - # label. - width = bb.width - height = bb.height - - # Project axis angle onto bounding box - ax = np.cos(np.radians(self.angle[axis][i])) - ay = np.sin(np.radians(self.angle[axis][i])) - - # Set anchor point for label - if np.abs(self.angle[axis][i]) < 45.0: - dx = width - dy = ay * height - elif np.abs(self.angle[axis][i] - 90.0) < 45: - dx = ax * width - dy = height - elif np.abs(self.angle[axis][i] - 180.0) < 45: - dx = -width - dy = ay * height - else: - dx = ax * width - dy = -height - - dx *= 0.5 - dy *= 0.5 - - # Find normalized vector along axis normal, so as to be - # able to nudge the label away by a constant padding factor - - dist = np.hypot(dx, dy) - - ddx = dx / dist - ddy = dy / dist - - dx += ddx * pad - dy += ddy * pad - - x = x - dx - y = y - dy - - ha = "center" - va = "center" - - self.xy[axis][i] = (x, y) - self.ha[axis][i] = ha - self.va[axis][i] = va + ax = height / 2 * px / np.abs(py) + ay = height / 2 * np.sign(py) + + # Extract the component of the tick direction perpendicular to the pad direction + scale = tx * px + ty * py + vx = tx - px * scale + vy = ty - py * scale + + # We scale the above vector for adding to the pad direction to get a combined + # displacement. When the tick direction is close to the pad direction, the scaling + # is such that the displacement direction is exactly the tick direction. When the + # tick direction is close to perpendicular to the pad direction, we cap the scaling, + # which means that the effective tick direction is never more than 60 degrees + # perpendicular to the pad direction. This prevents pushing the tick label too far + # away from the tick. + scale = np.max([scale, 0.5]) # 0.5 == cos(60 deg) + vx /= scale + vy /= scale + + # Pad the anchor point in the combined displacement direction + dx = ax + (vx + px) * pad + dy = ay + (vy + py) * pad + + self.xy[axis][i] = (x - dx, y - dy) + self.ha[axis][i] = "center" + self.va[axis][i] = "center" self._stale = False diff --git a/astropy/visualization/wcsaxes/wcsapi.py b/astropy/visualization/wcsaxes/wcsapi.py index c50f1a4bf05e..b9df27515a85 100644 --- a/astropy/visualization/wcsaxes/wcsapi.py +++ b/astropy/visualization/wcsaxes/wcsapi.py @@ -102,13 +102,23 @@ def custom_ucd_coord_meta_mapping(mapping, *, overwrite=False): > """ + normalized = {} + for k, v in mapping.items(): + k = k.removeprefix("custom:") + if k in normalized: + raise ValueError(f"UCD metadata mapping {k} specified more than once.") + normalized[k] = v + mapping = normalized + + if not overwrite: + for k in mapping: + if k in CUSTOM_UCD_COORD_META_MAPPING: + raise ValueError(f"UCD metadata mapping {k} already exists.") + added_keys = [] overwritten = {} for k, v in mapping.items(): - k = k.removeprefix("custom:") if k in CUSTOM_UCD_COORD_META_MAPPING: - if not overwrite: - raise ValueError(f"UCD metadata mapping {k} already exists.") overwritten[k] = CUSTOM_UCD_COORD_META_MAPPING[k] else: added_keys.append(k) @@ -165,9 +175,7 @@ def transform_coord_meta_from_wcs(wcs, frame_class, slices=None): if axis_type is not None: axis_type_split = axis_type.split(".") - - if len(axis_type_split): - axis_type_split[0] = axis_type_split[0].replace("custom:", "") + axis_type_split[0] = axis_type_split[0].replace("custom:", "") for ucd, meta in CUSTOM_UCD_COORD_META_MAPPING.items(): if ucd in axis_type: @@ -180,7 +188,7 @@ def transform_coord_meta_from_wcs(wcs, frame_class, slices=None): # We only do the following if the original unit was # degrees. If the unit was e.g. arcsec, it seems # reasonable to stick to the WCS unit. - if ucd == "ra" and axis_unit is u.deg: + if ucd == "ra" and axis_unit == u.deg: dim_meta["format_unit"] = u.hourangle break @@ -243,12 +251,6 @@ def transform_coord_meta_from_wcs(wcs, frame_class, slices=None): for i in range(len(coord_meta["type"])): coord_meta["visible"].append(i in world_map) - inv_all_corr = [False] * wcs.world_n_dim - m = transform_wcs.axis_correlation_matrix.copy() - if invert_xy: - inv_all_corr = np.all(m, axis=1) - m = m[:, ::-1] - if frame_class in (RectangularFrame, RectangularFrame1D): for index in world_map: coord_meta["default_axislabel_position"][index] = "#" @@ -374,7 +376,10 @@ def transform(self, world): world = world[0:1] if len(world[0]) == 0: - pixel = np.zeros((0, 2)) + if self.wcs.pixel_n_dim == 1: + pixel = np.array([]) + else: + pixel = [np.array([])] * self.wcs.pixel_n_dim else: pixel = self.wcs.world_to_pixel_values(*world) @@ -434,14 +439,17 @@ def transform(self, pixel): if len(pixel) != self.wcs.pixel_n_dim: raise ValueError( - f"Expected {self.wcs.pixel_n_dim} world coordinates, got {len(pixel)} " + f"Expected {self.wcs.pixel_n_dim} pixel coordinates, got {len(pixel)}" ) if self.invert_xy: pixel = pixel[::-1] if len(pixel[0]) == 0: - world = np.zeros((0, self.wcs.world_n_dim)) + if self.wcs.world_n_dim == 1: + world = np.array([]) + else: + world = [np.array([])] * self.wcs.world_n_dim else: world = self.wcs.pixel_to_world_values(*pixel) diff --git a/astropy/wcs/include/astropy_wcs/distortion_wrap.h b/astropy/wcs/include/astropy_wcs/distortion_wrap.h index f1779e185f7e..52270b497c24 100644 --- a/astropy/wcs/include/astropy_wcs/distortion_wrap.h +++ b/astropy/wcs/include/astropy_wcs/distortion_wrap.h @@ -9,13 +9,13 @@ #include "pyutil.h" #include "distortion.h" -extern PyObject* PyDistLookupType; +extern PyObject* DistLookupType; typedef struct { PyObject_HEAD distortion_lookup_t x; /*@null@*/ /*@shared@*/ PyArrayObject* py_data; -} PyDistLookup; +} DistLookup; int _setup_distortion_type( diff --git a/astropy/wcs/include/astropy_wcs/pyutil.h b/astropy/wcs/include/astropy_wcs/pyutil.h index 9122a3fbcfd5..7e48dbfa4eab 100644 --- a/astropy/wcs/include/astropy_wcs/pyutil.h +++ b/astropy/wcs/include/astropy_wcs/pyutil.h @@ -16,7 +16,7 @@ #include PyObject* -PyArrayProxy_New( +ArrayProxy_New( PyObject* self, int nd, const npy_intp* dims, @@ -24,7 +24,7 @@ PyArrayProxy_New( const void* data); PyObject* -PyArrayReadOnlyProxy_New( +ArrayReadOnlyProxy_New( PyObject* self, int nd, const npy_intp* dims, @@ -32,7 +32,7 @@ PyArrayReadOnlyProxy_New( const void* data); /*@null@*/ PyObject * -PyStrListProxy_New( +StrListProxy_New( PyObject* owner, Py_ssize_t size, Py_ssize_t maxsize, @@ -240,7 +240,7 @@ get_double_array( const npy_intp* dims, /*@shared@*/ PyObject* owner) { - return PyArrayProxy_New(owner, ndims, dims, NPY_DOUBLE, value); + return ArrayProxy_New(owner, ndims, dims, NPY_DOUBLE, value); } /*@null@*/ static INLINE PyObject* @@ -251,7 +251,7 @@ get_double_array_readonly( const npy_intp* dims, /*@shared@*/ PyObject* owner) { - return PyArrayReadOnlyProxy_New(owner, ndims, dims, NPY_DOUBLE, value); + return ArrayReadOnlyProxy_New(owner, ndims, dims, NPY_DOUBLE, value); } int @@ -270,7 +270,7 @@ get_int_array( const npy_intp* dims, /*@shared@*/ PyObject* owner) { - return PyArrayProxy_New(owner, ndims, dims, NPY_INT, value); + return ArrayProxy_New(owner, ndims, dims, NPY_INT, value); } int @@ -289,7 +289,7 @@ get_str_list( Py_ssize_t maxlen, PyObject* owner) { - return PyStrListProxy_New(owner, len, maxlen, array); + return StrListProxy_New(owner, len, maxlen, array); } int diff --git a/astropy/wcs/include/astropy_wcs/sip_wrap.h b/astropy/wcs/include/astropy_wcs/sip_wrap.h index 89211b869cf6..6ba1ee6c16db 100644 --- a/astropy/wcs/include/astropy_wcs/sip_wrap.h +++ b/astropy/wcs/include/astropy_wcs/sip_wrap.h @@ -9,12 +9,12 @@ #include "pyutil.h" #include "sip.h" -extern PyObject* PySipType; +extern PyObject* SipType; typedef struct { PyObject_HEAD sip_t x; -} PySip; +} Sip; int _setup_sip_type( diff --git a/astropy/wcs/include/astropy_wcs/str_list_proxy.h b/astropy/wcs/include/astropy_wcs/str_list_proxy.h index b6462656a764..36d9859f27bc 100644 --- a/astropy/wcs/include/astropy_wcs/str_list_proxy.h +++ b/astropy/wcs/include/astropy_wcs/str_list_proxy.h @@ -18,7 +18,7 @@ typedef int (*str_verify_fn)(const char *); /*@null@*/ PyObject * -PyStrListProxy_New( +StrListProxy_New( PyObject* owner, Py_ssize_t size, Py_ssize_t maxsize, diff --git a/astropy/wcs/include/astropy_wcs/unit_list_proxy.h b/astropy/wcs/include/astropy_wcs/unit_list_proxy.h index c3efd53073d1..917eacf56dd3 100644 --- a/astropy/wcs/include/astropy_wcs/unit_list_proxy.h +++ b/astropy/wcs/include/astropy_wcs/unit_list_proxy.h @@ -16,7 +16,7 @@ ***************************************************************************/ /*@null@*/ PyObject * -PyUnitListProxy_New( +UnitListProxy_New( PyObject* owner, Py_ssize_t size, char (*array)[72], @@ -35,7 +35,7 @@ get_unit_list( PyObject* owner, int readonly) { - return PyUnitListProxy_New(owner, len, array, readonly); + return UnitListProxy_New(owner, len, array, readonly); } int diff --git a/astropy/wcs/include/astropy_wcs/wcslib_auxprm_wrap.h b/astropy/wcs/include/astropy_wcs/wcslib_auxprm_wrap.h index ddff30452779..2a06916f058d 100644 --- a/astropy/wcs/include/astropy_wcs/wcslib_auxprm_wrap.h +++ b/astropy/wcs/include/astropy_wcs/wcslib_auxprm_wrap.h @@ -4,16 +4,16 @@ #include "pyutil.h" #include "wcs.h" -extern PyObject* PyAuxprmType; +extern PyObject* AuxprmType; typedef struct { PyObject_HEAD struct auxprm* x; PyObject* owner; -} PyAuxprm; +} Auxprm; -PyAuxprm* -PyAuxprm_cnew(PyObject* wcsprm, struct auxprm* x); +Auxprm* +Auxprm_cnew(PyObject* wcsprm, struct auxprm* x); int _setup_auxprm_type(PyObject* m); diff --git a/astropy/wcs/include/astropy_wcs/wcslib_celprm_wrap.h b/astropy/wcs/include/astropy_wcs/wcslib_celprm_wrap.h index 61a9777afc3c..f5462a6804c9 100644 --- a/astropy/wcs/include/astropy_wcs/wcslib_celprm_wrap.h +++ b/astropy/wcs/include/astropy_wcs/wcslib_celprm_wrap.h @@ -4,16 +4,16 @@ #include "pyutil.h" #include "wcs.h" -extern PyObject* PyCelprmType; +extern PyObject* CelprmType; typedef struct { PyObject_HEAD struct celprm* x; int* prefcount; PyObject* owner; -} PyCelprm; +} Celprm; -PyCelprm* PyCelprm_cnew(PyObject* wcsprm_obj, struct celprm* x, int* prefcount); +Celprm* Celprm_cnew(PyObject* wcsprm_obj, struct celprm* x, int* prefcount); int _setup_celprm_type(PyObject* m); diff --git a/astropy/wcs/include/astropy_wcs/wcslib_prjprm_wrap.h b/astropy/wcs/include/astropy_wcs/wcslib_prjprm_wrap.h index 4d74148c58c7..ee0035c0bd04 100644 --- a/astropy/wcs/include/astropy_wcs/wcslib_prjprm_wrap.h +++ b/astropy/wcs/include/astropy_wcs/wcslib_prjprm_wrap.h @@ -4,16 +4,16 @@ #include "pyutil.h" #include "wcs.h" -extern PyObject* PyPrjprmType; +extern PyObject* PrjprmType; typedef struct { PyObject_HEAD struct prjprm* x; int* prefcount; PyObject* owner; -} PyPrjprm; +} Prjprm; -PyPrjprm* PyPrjprm_cnew(PyObject* celprm, struct prjprm* x, int* prefcount); +Prjprm* Prjprm_cnew(PyObject* celprm, struct prjprm* x, int* prefcount); int _setup_prjprm_type(PyObject* m); diff --git a/astropy/wcs/include/astropy_wcs/wcslib_tabprm_wrap.h b/astropy/wcs/include/astropy_wcs/wcslib_tabprm_wrap.h index 37b0f9a500b2..eed3e894d362 100644 --- a/astropy/wcs/include/astropy_wcs/wcslib_tabprm_wrap.h +++ b/astropy/wcs/include/astropy_wcs/wcslib_tabprm_wrap.h @@ -9,16 +9,16 @@ #include "pyutil.h" #include "wcs.h" -extern PyObject* PyTabprmType; +extern PyObject* TabprmType; typedef struct { PyObject_HEAD struct tabprm* x; PyObject* owner; -} PyTabprm; +} Tabprm; -PyTabprm* -PyTabprm_cnew(PyObject* wcsprm, struct tabprm* x); +Tabprm* +Tabprm_cnew(PyObject* wcsprm, struct tabprm* x); int _setup_tabprm_type(PyObject* m); diff --git a/astropy/wcs/include/astropy_wcs/wcslib_wrap.h b/astropy/wcs/include/astropy_wcs/wcslib_wrap.h index b28be09680e4..284ab112e238 100644 --- a/astropy/wcs/include/astropy_wcs/wcslib_wrap.h +++ b/astropy/wcs/include/astropy_wcs/wcslib_wrap.h @@ -7,7 +7,7 @@ #include "pyutil.h" -extern PyObject* PyWcsprmType; +extern PyObject* WcsprmType; typedef struct { @@ -28,12 +28,12 @@ typedef struct { char (*original_cunit)[72]; double *unit_scaling; -} PyWcsprm; +} Wcsprm; int _setup_wcsprm_type(PyObject* m); PyObject* -PyWcsprm_find_all_wcs( +Wcsprm_find_all_wcs( PyObject* self, PyObject* args, PyObject* kwds); @@ -42,6 +42,6 @@ int _update_wtbarr_from_hdulist(PyObject *hdulist, struct wtbarr *wtb); void _set_wtbarr_callback(PyObject* callback); -int PyWcsprm_cset(PyWcsprm* self, const int convert); +int Wcsprm_cset(Wcsprm* self, const int convert); #endif diff --git a/astropy/wcs/include/astropy_wcs/wcslib_wtbarr_wrap.h b/astropy/wcs/include/astropy_wcs/wcslib_wtbarr_wrap.h index b3521c3a7ef3..bcffbc17586d 100644 --- a/astropy/wcs/include/astropy_wcs/wcslib_wtbarr_wrap.h +++ b/astropy/wcs/include/astropy_wcs/wcslib_wtbarr_wrap.h @@ -9,16 +9,16 @@ #include "pyutil.h" #include "wcs.h" -extern PyObject* PyWtbarrType; +extern PyObject* WtbarrType; typedef struct { PyObject_HEAD struct wtbarr* x; PyObject* owner; -} PyWtbarr; +} Wtbarr; -PyWtbarr* -PyWtbarr_cnew(PyObject* wcsprm, struct wtbarr* x); +Wtbarr* +Wtbarr_cnew(PyObject* wcsprm, struct wtbarr* x); int _setup_wtbarr_type(PyObject* m); diff --git a/astropy/wcs/src/astropy_wcs.c b/astropy/wcs/src/astropy_wcs.c index e98e9b7c4172..3b850359c002 100644 --- a/astropy/wcs/src/astropy_wcs.c +++ b/astropy/wcs/src/astropy_wcs.c @@ -23,6 +23,7 @@ #include #include +#include #include /*************************************************************************** @@ -32,7 +33,7 @@ static int _setup_wcs_type(PyObject* m); -PyObject* PyWcsprm_set_wtbarr_fitsio_callback(PyObject *dummy, PyObject *args) { +PyObject* Wcsprm_set_wtbarr_fitsio_callback(PyObject *dummy, PyObject *args) { PyObject *callback; if (PyArg_ParseTuple(args, "O:set_wtbarr_fitsio_callback", &callback)) { @@ -107,6 +108,13 @@ Wcs_new( self = (Wcs*)alloc_func(type, 0); if (self != NULL) { pipeline_clear(&self->x); + /* Wcs_init fills the pipeline without calling pipeline_init, so + * the eager wcserr allocation has to happen here. */ + self->x.err = calloc(1, sizeof(struct wcserr)); + if (self->x.err == NULL) { + Py_DECREF(self); + return PyErr_NoMemory(); + } self->py_det2im[0] = NULL; self->py_det2im[1] = NULL; self->py_sip = NULL; @@ -143,7 +151,7 @@ Wcs_init( /* Check and set Distortion lookup tables */ for (i = 0; i < 2; ++i) { if (py_det2im[i] != NULL && py_det2im[i] != Py_None) { - if (!PyObject_TypeCheck(py_det2im[i], (PyTypeObject*)PyDistLookupType)) { + if (!PyObject_TypeCheck(py_det2im[i], (PyTypeObject*)DistLookupType)) { PyErr_SetString(PyExc_TypeError, "Arg 4 must be a pair of DistortionLookupTable or None objects"); return -1; @@ -152,13 +160,13 @@ Wcs_init( Py_CLEAR(self->py_det2im[i]); self->py_det2im[i] = py_det2im[i]; Py_INCREF(py_det2im[i]); - self->x.det2im[i] = &(((PyDistLookup*)py_det2im[i])->x); + self->x.det2im[i] = &(((DistLookup*)py_det2im[i])->x); } } /* Check and set SIP */ if (py_sip != NULL && py_sip != Py_None) { - if (!PyObject_TypeCheck(py_sip, (PyTypeObject*)PySipType)) { + if (!PyObject_TypeCheck(py_sip, (PyTypeObject*)SipType)) { PyErr_SetString(PyExc_TypeError, "Arg 1 must be Sip object"); return -1; @@ -167,13 +175,13 @@ Wcs_init( Py_CLEAR(self->py_sip); self->py_sip = py_sip; Py_INCREF(py_sip); - self->x.sip = &(((PySip*)py_sip)->x); + self->x.sip = &(((Sip*)py_sip)->x); } /* Check and set Distortion lookup tables */ for (i = 0; i < 2; ++i) { if (py_distortion_lookup[i] != NULL && py_distortion_lookup[i] != Py_None) { - if (!PyObject_TypeCheck(py_distortion_lookup[i], (PyTypeObject*)PyDistLookupType)) { + if (!PyObject_TypeCheck(py_distortion_lookup[i], (PyTypeObject*)DistLookupType)) { PyErr_SetString(PyExc_TypeError, "Arg 2 must be a pair of DistortionLookupTable or None objects"); return -1; @@ -182,13 +190,13 @@ Wcs_init( Py_CLEAR(self->py_distortion_lookup[i]); self->py_distortion_lookup[i] = py_distortion_lookup[i]; Py_INCREF(py_distortion_lookup[i]); - self->x.cpdis[i] = &(((PyDistLookup*)py_distortion_lookup[i])->x); + self->x.cpdis[i] = &(((DistLookup*)py_distortion_lookup[i])->x); } } /* Set and lookup Wcsprm object */ if (py_wcsprm != NULL && py_wcsprm != Py_None) { - if (!PyObject_TypeCheck(py_wcsprm, (PyTypeObject*)PyWcsprmType)) { + if (!PyObject_TypeCheck(py_wcsprm, (PyTypeObject*)WcsprmType)) { PyErr_SetString(PyExc_TypeError, "Arg 3 must be Wcsprm object"); return -1; @@ -197,7 +205,7 @@ Wcs_init( Py_CLEAR(self->py_wcsprm); self->py_wcsprm = py_wcsprm; Py_INCREF(py_wcsprm); - self->x.wcs = &(((PyWcsprm*)py_wcsprm)->x); + self->x.wcs = &(((Wcsprm*)py_wcsprm)->x); } return 0; @@ -249,24 +257,38 @@ Wcs_all_pix2world( goto exit; } - // Here we force a call to wcsset. Normally, WCSLIB will call wcsset automatically when - // calling wcsp2s, but we need to call it ourselves using PyWcsprm_cset so that we can - // catch cases where the units might change if e.g. they are not in SI to start with. - /* Force a call to wcsset here*/ - if (((PyWcsprm*)(self->py_wcsprm))->preserve_units && PyWcsprm_cset(((PyWcsprm*)(self->py_wcsprm)), 1)) { + // Force a call to wcsset via Wcsprm_cset before entering the parallel + // region. This serves two purposes: (1) preserve_units unit-change + // detection (the original reason), and (2) ensuring wcs->flag == WCSSET + // so the wcsp2s call below will not invoke wcsset itself. wcsset is the + // only writer wcsp2s would otherwise trigger, and by running it eagerly + // under the GIL here (short-circuited by wcsenq on subsequent calls) we + // can safely drop the wcsprm_python2c / wcsprm_c2python round-trip from + // around pipeline_all_pixel2world. + if (Wcsprm_cset(((Wcsprm*)(self->py_wcsprm)), 1)) { return NULL; } - /* Make the call */ + /* Make the call. + * + * The pipeline (det2im / SIP / distortion / wcsp2s stages) is read-only + * on the wcsprm struct -- after wcsset has run (now hoisted into + * Wcsprm_cset above), the transform routines consume the precomputed + * sub-structs wcs->lin / wcs->cel / wcs->spc plus wcs->crval[i], and + * never re-read the raw arrays (cd, cdelt, crpix, crota, obsgeo, + * mjdobs, ...) that the wcsprm_python2c / wcsprm_c2python pair was + * rewriting NaN <-> UNDEFINED in place. The round-trip can therefore + * be dropped here without changing the transform's output, and + * concurrent threads no longer race on those raw arrays + *. + */ Py_BEGIN_ALLOW_THREADS preoffset_array(pixcrd, origin); - wcsprm_python2c(self->x.wcs); status = pipeline_all_pixel2world(&self->x, (unsigned int)ncoord, (unsigned int)nelem, (double*)PyArray_DATA(pixcrd), (double*)PyArray_DATA(world)); - wcsprm_c2python(self->x.wcs); unoffset_array(pixcrd, origin); Py_END_ALLOW_THREADS /* unoffset_array(world, origin); */ @@ -277,9 +299,9 @@ Wcs_all_pix2world( if (status == 0 || status == 8) { // Since the conversion succeeded, if user has requested to preserve units, // we convert the world coordinates to the original units - if (((PyWcsprm*)(self->py_wcsprm))->unit_scaling != NULL) { + if (((Wcsprm*)(self->py_wcsprm))->unit_scaling != NULL) { double *world_data = (double *)PyArray_DATA(world); - double *unit_scaling = ((PyWcsprm*)(self->py_wcsprm))->unit_scaling; + double *unit_scaling = ((Wcsprm*)(self->py_wcsprm))->unit_scaling; for (npy_intp i = 0; i < nelem; ++i) { for (npy_intp j = 0; j < ncoord; ++j) { world_data[j * nelem + i] /= unit_scaling[i]; @@ -529,7 +551,7 @@ Wcs_set_wcs( self->x.wcs = NULL; if (value != NULL && value != Py_None) { - if (!PyObject_TypeCheck(value, (PyTypeObject*)PyWcsprmType)) { + if (!PyObject_TypeCheck(value, (PyTypeObject*)WcsprmType)) { PyErr_SetString(PyExc_TypeError, "wcs must be Wcsprm object"); return -1; @@ -537,7 +559,7 @@ Wcs_set_wcs( Py_INCREF(value); self->py_wcsprm = value; - self->x.wcs = &(((PyWcsprm*)value)->x); + self->x.wcs = &(((Wcsprm*)value)->x); } return 0; @@ -567,7 +589,7 @@ Wcs_set_cpdis1( self->x.cpdis[0] = NULL; if (value != NULL && value != Py_None) { - if (!PyObject_TypeCheck(value, (PyTypeObject*)PyDistLookupType)) { + if (!PyObject_TypeCheck(value, (PyTypeObject*)DistLookupType)) { PyErr_SetString(PyExc_TypeError, "cpdis1 must be DistortionLookupTable object"); return -1; @@ -575,7 +597,7 @@ Wcs_set_cpdis1( Py_INCREF(value); self->py_distortion_lookup[0] = value; - self->x.cpdis[0] = &(((PyDistLookup*)value)->x); + self->x.cpdis[0] = &(((DistLookup*)value)->x); } return 0; @@ -605,7 +627,7 @@ Wcs_set_cpdis2( self->x.cpdis[1] = NULL; if (value != NULL && value != Py_None) { - if (!PyObject_TypeCheck(value, (PyTypeObject*)PyDistLookupType)) { + if (!PyObject_TypeCheck(value, (PyTypeObject*)DistLookupType)) { PyErr_SetString(PyExc_TypeError, "cpdis2 must be DistortionLookupTable object"); return -1; @@ -613,7 +635,7 @@ Wcs_set_cpdis2( Py_INCREF(value); self->py_distortion_lookup[1] = value; - self->x.cpdis[1] = &(((PyDistLookup*)value)->x); + self->x.cpdis[1] = &(((DistLookup*)value)->x); } return 0; @@ -643,7 +665,7 @@ Wcs_set_det2im1( self->x.det2im[0] = NULL; if (value != NULL && value != Py_None) { - if (!PyObject_TypeCheck(value, (PyTypeObject*)PyDistLookupType)) { + if (!PyObject_TypeCheck(value, (PyTypeObject*)DistLookupType)) { PyErr_SetString(PyExc_TypeError, "det2im1 must be DistortionLookupTable object"); return -1; @@ -651,7 +673,7 @@ Wcs_set_det2im1( Py_INCREF(value); self->py_det2im[0] = value; - self->x.det2im[0] = &(((PyDistLookup*)value)->x); + self->x.det2im[0] = &(((DistLookup*)value)->x); } return 0; @@ -681,7 +703,7 @@ Wcs_set_det2im2( self->x.det2im[1] = NULL; if (value != NULL && value != Py_None) { - if (!PyObject_TypeCheck(value, (PyTypeObject*)PyDistLookupType)) { + if (!PyObject_TypeCheck(value, (PyTypeObject*)DistLookupType)) { PyErr_SetString(PyExc_TypeError, "det2im2 must be DistortionLookupTable object"); return -1; @@ -689,7 +711,7 @@ Wcs_set_det2im2( Py_INCREF(value); self->py_det2im[1] = value; - self->x.det2im[1] = &(((PyDistLookup*)value)->x); + self->x.det2im[1] = &(((DistLookup*)value)->x); } return 0; @@ -719,7 +741,7 @@ Wcs_set_sip( self->x.sip = NULL; if (value != NULL && value != Py_None) { - if (!PyObject_TypeCheck(value, (PyTypeObject*)PySipType)) { + if (!PyObject_TypeCheck(value, (PyTypeObject*)SipType)) { PyErr_SetString(PyExc_TypeError, "sip must be Sip object"); return -1; @@ -727,7 +749,7 @@ Wcs_set_sip( Py_INCREF(value); self->py_sip = value; - self->x.sip = &(((PySip*)value)->x); + self->x.sip = &(((Sip*)value)->x); } return 0; @@ -772,8 +794,8 @@ static PyMethodDef Wcs_methods[] = { static PyMethodDef module_methods[] = { {"_sanity_check", (PyCFunction)_sanity_check, METH_NOARGS, ""}, - {"find_all_wcs", (PyCFunction)PyWcsprm_find_all_wcs, METH_VARARGS|METH_KEYWORDS, doc_find_all_wcs}, - {"set_wtbarr_fitsio_callback", (PyCFunction)PyWcsprm_set_wtbarr_fitsio_callback, METH_VARARGS, NULL}, + {"find_all_wcs", (PyCFunction)Wcsprm_find_all_wcs, METH_VARARGS|METH_KEYWORDS, doc_find_all_wcs}, + {"set_wtbarr_fitsio_callback", (PyCFunction)Wcsprm_set_wtbarr_fitsio_callback, METH_VARARGS, NULL}, {NULL} /* Sentinel */ }; diff --git a/astropy/wcs/src/distortion_wrap.c b/astropy/wcs/src/distortion_wrap.c index d242a5d5026b..5a7a9646ee34 100644 --- a/astropy/wcs/src/distortion_wrap.c +++ b/astropy/wcs/src/distortion_wrap.c @@ -11,8 +11,8 @@ #include /* From Python */ static int -PyDistLookup_traverse( - PyDistLookup* self, +DistLookup_traverse( + DistLookup* self, visitproc visit, void* arg) { @@ -23,8 +23,8 @@ PyDistLookup_traverse( } static int -PyDistLookup_clear( - PyDistLookup* self) { +DistLookup_clear( + DistLookup* self) { Py_CLEAR(self->py_data); @@ -32,8 +32,8 @@ PyDistLookup_clear( } static void -PyDistLookup_dealloc( - PyDistLookup* self) { +DistLookup_dealloc( + DistLookup* self) { PyObject_GC_UnTrack(self); distortion_lookup_t_free(&self->x); @@ -45,15 +45,15 @@ PyDistLookup_dealloc( } /*@null@*/ static PyObject * -PyDistLookup_new( +DistLookup_new( PyTypeObject* type, /*@unused@*/ PyObject* args, /*@unused@*/ PyObject* kwds) { - PyDistLookup* self; + DistLookup* self; allocfunc alloc_func = PyType_GetSlot(type, Py_tp_alloc); - self = (PyDistLookup*)alloc_func(type, 0); + self = (DistLookup*)alloc_func(type, 0); if (self != NULL) { if (distortion_lookup_t_init(&self->x)) { return NULL; @@ -64,8 +64,8 @@ PyDistLookup_new( } static int -PyDistLookup_init( - PyDistLookup* self, +DistLookup_init( + DistLookup* self, PyObject* args, /*@unused@*/ PyObject* kwds) { @@ -94,8 +94,8 @@ PyDistLookup_init( } static PyObject* -PyDistLookup_get_cdelt( - PyDistLookup* self, +DistLookup_get_cdelt( + DistLookup* self, /*@unused@*/ void* closure) { Py_ssize_t naxis = 2; @@ -104,8 +104,8 @@ PyDistLookup_get_cdelt( } static int -PyDistLookup_set_cdelt( - PyDistLookup* self, +DistLookup_set_cdelt( + DistLookup* self, PyObject* value, /*@unused@*/ void* closure) { @@ -115,8 +115,8 @@ PyDistLookup_set_cdelt( } static PyObject* -PyDistLookup_get_crpix( - PyDistLookup* self, +DistLookup_get_crpix( + DistLookup* self, /*@unused@*/ void* closure) { Py_ssize_t naxis = 2; @@ -125,8 +125,8 @@ PyDistLookup_get_crpix( } static int -PyDistLookup_set_crpix( - PyDistLookup* self, +DistLookup_set_crpix( + DistLookup* self, PyObject* value, /*@unused@*/ void* closure) { @@ -136,8 +136,8 @@ PyDistLookup_set_crpix( } static PyObject* -PyDistLookup_get_crval( - PyDistLookup* self, +DistLookup_get_crval( + DistLookup* self, /*@unused@*/ void* closure) { Py_ssize_t naxis = 2; @@ -146,8 +146,8 @@ PyDistLookup_get_crval( } static int -PyDistLookup_set_crval( - PyDistLookup* self, +DistLookup_set_crval( + DistLookup* self, PyObject* value, /*@unused@*/ void* closure) { @@ -157,8 +157,8 @@ PyDistLookup_set_crval( } /*@shared@*/ static PyObject* -PyDistLookup_get_data( - PyDistLookup* self, +DistLookup_get_data( + DistLookup* self, /*@unused@*/ void* closure) { if (self->py_data == NULL) { @@ -171,8 +171,8 @@ PyDistLookup_get_data( } static int -PyDistLookup_set_data( - PyDistLookup* self, +DistLookup_set_data( + DistLookup* self, PyObject* value, /*@unused@*/ void* closure) { @@ -201,8 +201,8 @@ PyDistLookup_set_data( } /*@null@*/ static PyObject* -PyDistLookup_get_offset( - PyDistLookup* self, +DistLookup_get_offset( + DistLookup* self, PyObject* args, /*@unused@*/ PyObject* kwds) { @@ -224,15 +224,15 @@ PyDistLookup_get_offset( } static PyObject* -PyDistLookup___copy__( - PyDistLookup* self, +DistLookup___copy__( + DistLookup* self, /*@unused@*/ PyObject* args, /*@unused@*/ PyObject* kwds) { - PyDistLookup* copy = NULL; + DistLookup* copy = NULL; int i = 0; - copy = (PyDistLookup*)PyDistLookup_new((PyTypeObject*)PyDistLookupType, NULL, NULL); + copy = (DistLookup*)DistLookup_new((PyTypeObject*)DistLookupType, NULL, NULL); if (copy == NULL) { return NULL; } @@ -245,23 +245,23 @@ PyDistLookup___copy__( } if (self->py_data) { - PyDistLookup_set_data(copy, (PyObject*)self->py_data, NULL); + DistLookup_set_data(copy, (PyObject*)self->py_data, NULL); } return (PyObject*)copy; } static PyObject* -PyDistLookup___deepcopy__( - PyDistLookup* self, +DistLookup___deepcopy__( + DistLookup* self, PyObject* memo, /*@unused@*/ PyObject* kwds) { - PyDistLookup* copy; + DistLookup* copy; PyObject* obj_copy; int i = 0; - copy = (PyDistLookup*)PyDistLookup_new((PyTypeObject*)PyDistLookupType, NULL, NULL); + copy = (DistLookup*)DistLookup_new((PyTypeObject*)DistLookupType, NULL, NULL); if (copy == NULL) { return NULL; } @@ -279,7 +279,7 @@ PyDistLookup___deepcopy__( Py_DECREF(copy); return NULL; } - PyDistLookup_set_data(copy, (PyObject*)obj_copy, NULL); + DistLookup_set_data(copy, (PyObject*)obj_copy, NULL); Py_DECREF(obj_copy); } @@ -287,48 +287,48 @@ PyDistLookup___deepcopy__( } -static PyGetSetDef PyDistLookup_getset[] = { - {"cdelt", (getter)PyDistLookup_get_cdelt, (setter)PyDistLookup_set_cdelt, (char *)doc_cdelt}, - {"crpix", (getter)PyDistLookup_get_crpix, (setter)PyDistLookup_set_crpix, (char *)doc_crpix}, - {"crval", (getter)PyDistLookup_get_crval, (setter)PyDistLookup_set_crval, (char *)doc_crval}, - {"data", (getter)PyDistLookup_get_data, (setter)PyDistLookup_set_data, (char *)doc_data}, +static PyGetSetDef DistLookup_getset[] = { + {"cdelt", (getter)DistLookup_get_cdelt, (setter)DistLookup_set_cdelt, (char *)doc_cdelt}, + {"crpix", (getter)DistLookup_get_crpix, (setter)DistLookup_set_crpix, (char *)doc_crpix}, + {"crval", (getter)DistLookup_get_crval, (setter)DistLookup_set_crval, (char *)doc_crval}, + {"data", (getter)DistLookup_get_data, (setter)DistLookup_set_data, (char *)doc_data}, {NULL} }; -static PyMethodDef PyDistLookup_methods[] = { - {"__copy__", (PyCFunction)PyDistLookup___copy__, METH_NOARGS, NULL}, - {"__deepcopy__", (PyCFunction)PyDistLookup___deepcopy__, METH_O, NULL}, - {"get_offset", (PyCFunction)PyDistLookup_get_offset, METH_VARARGS, doc_get_offset}, +static PyMethodDef DistLookup_methods[] = { + {"__copy__", (PyCFunction)DistLookup___copy__, METH_NOARGS, NULL}, + {"__deepcopy__", (PyCFunction)DistLookup___deepcopy__, METH_O, NULL}, + {"get_offset", (PyCFunction)DistLookup_get_offset, METH_VARARGS, doc_get_offset}, {NULL} }; -static PyType_Spec PyDistLookupType_spec = { +static PyType_Spec DistLookupType_spec = { .name = "astropy.wcs.DistortionLookupTable", - .basicsize = sizeof(PyDistLookup), + .basicsize = sizeof(DistLookup), .itemsize = 0, .flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC | Py_TPFLAGS_IMMUTABLETYPE, .slots = (PyType_Slot[]){ - {Py_tp_dealloc, (destructor)PyDistLookup_dealloc}, + {Py_tp_dealloc, (destructor)DistLookup_dealloc}, {Py_tp_doc, doc_DistortionLookupTable}, - {Py_tp_traverse, (traverseproc)PyDistLookup_traverse}, - {Py_tp_clear, (inquiry)PyDistLookup_clear}, - {Py_tp_methods, PyDistLookup_methods}, - {Py_tp_getset, PyDistLookup_getset}, - {Py_tp_init, (initproc)PyDistLookup_init}, - {Py_tp_new, PyDistLookup_new}, + {Py_tp_traverse, (traverseproc)DistLookup_traverse}, + {Py_tp_clear, (inquiry)DistLookup_clear}, + {Py_tp_methods, DistLookup_methods}, + {Py_tp_getset, DistLookup_getset}, + {Py_tp_init, (initproc)DistLookup_init}, + {Py_tp_new, DistLookup_new}, {0, NULL}, }, }; -PyObject* PyDistLookupType = NULL; +PyObject* DistLookupType = NULL; int _setup_distortion_type( PyObject* m) { - PyDistLookupType = PyType_FromSpec(&PyDistLookupType_spec); - if (PyDistLookupType == NULL) { + DistLookupType = PyType_FromSpec(&DistLookupType_spec); + if (DistLookupType == NULL) { return -1; } - return PyModule_AddObject(m, "DistortionLookupTable", PyDistLookupType); + return PyModule_AddObject(m, "DistortionLookupTable", DistLookupType); } diff --git a/astropy/wcs/src/pipeline.c b/astropy/wcs/src/pipeline.c index fd6501c457c2..11e0f7a8d3ef 100644 --- a/astropy/wcs/src/pipeline.c +++ b/astropy/wcs/src/pipeline.c @@ -39,7 +39,10 @@ pipeline_init( pipeline->cpdis[0] = cpdis[0]; pipeline->cpdis[1] = cpdis[1]; pipeline->wcs = wcs; - pipeline->err = NULL; + /* Allocated eagerly: a lazy calloc in the error paths would race + * between threads. Wcs_new does the same for pipelines set up + * without pipeline_init. Freed by pipeline_free. */ + pipeline->err = calloc(1, sizeof(struct wcserr)); } void @@ -150,9 +153,8 @@ pipeline_all_pixel2world( if ((status = wcsp2s(pipeline->wcs, (int)ncoord, (int)nelem, wcs_input, imgcrd, phi, theta, wcs_output, stat))) { - if (pipeline->err == NULL) { - pipeline->err = calloc(1, sizeof(struct wcserr)); - } + /* err is allocated eagerly in Wcs_new / pipeline_init; a lazy + * calloc here would race. */ wcserr_copy(pipeline->wcs->err, pipeline->err); } @@ -246,9 +248,8 @@ int pipeline_pix2foc( if (has_sip) { status = sip_pix2deltas(pipeline->sip, 2, ncoord, input, foc); if (status) { - if (pipeline->err == NULL) { - pipeline->err = calloc(1, sizeof(struct wcserr)); - } + /* err is allocated eagerly in Wcs_new / pipeline_init; a lazy + * calloc here would race. */ wcserr_copy(pipeline->sip->err, pipeline->err); goto exit; } diff --git a/astropy/wcs/src/pyutil.c b/astropy/wcs/src/pyutil.c index 5418de2da75d..a93500b77f52 100644 --- a/astropy/wcs/src/pyutil.c +++ b/astropy/wcs/src/pyutil.c @@ -18,7 +18,7 @@ #include "wcsunits.h" /*@null@*/ static INLINE PyObject* -_PyArrayProxy_New( +_ArrayProxy_New( /*@shared@*/ PyObject* self, int nd, const npy_intp* dims, @@ -52,25 +52,25 @@ _PyArrayProxy_New( } /*@null@*/ PyObject* -PyArrayProxy_New( +ArrayProxy_New( /*@shared@*/ PyObject* self, int nd, const npy_intp* dims, int typenum, const void* data) { - return _PyArrayProxy_New(self, nd, dims, typenum, data, NPY_ARRAY_WRITEABLE); + return _ArrayProxy_New(self, nd, dims, typenum, data, NPY_ARRAY_WRITEABLE); } /*@null@*/ PyObject* -PyArrayReadOnlyProxy_New( +ArrayReadOnlyProxy_New( /*@shared@*/ PyObject* self, int nd, const npy_intp* dims, int typenum, const void* data) { - return _PyArrayProxy_New(self, nd, dims, typenum, data, 0); + return _ArrayProxy_New(self, nd, dims, typenum, data, 0); } void @@ -735,7 +735,6 @@ get_pscards( } if (PyList_SetItem(result, i, subresult)) { - Py_DECREF(subresult); Py_DECREF(result); return NULL; } @@ -846,7 +845,6 @@ get_pvcards( } if (PyList_SetItem(result, i, subresult)) { - Py_DECREF(subresult); Py_DECREF(result); return NULL; } diff --git a/astropy/wcs/src/sip_wrap.c b/astropy/wcs/src/sip_wrap.c index cff85f780bec..e1f1bc240c14 100644 --- a/astropy/wcs/src/sip_wrap.c +++ b/astropy/wcs/src/sip_wrap.c @@ -10,8 +10,8 @@ #include "wcs.h" static void -PySip_dealloc( - PySip* self) { +Sip_dealloc( + Sip* self) { sip_free(&self->x); PyTypeObject *tp = Py_TYPE((PyObject*)self); @@ -21,14 +21,14 @@ PySip_dealloc( } /*@null@*/ static PyObject * -PySip_new( +Sip_new( PyTypeObject* type, /*@unused@*/ PyObject* args, /*@unused@*/ PyObject* kwds) { - PySip* self; + Sip* self; allocfunc alloc_func = PyType_GetSlot(type, Py_tp_alloc); - self = (PySip*)alloc_func(type, 0); + self = (Sip*)alloc_func(type, 0); if (self != NULL) { sip_clear(&self->x); } @@ -68,8 +68,8 @@ convert_matrix( } static int -PySip_init( - PySip* self, +Sip_init( + Sip* self, PyObject* args, /*@unused@*/ PyObject* kwds) { @@ -142,8 +142,8 @@ PySip_init( } /*@null@*/ static PyObject* -PySip_pix2foc( - PySip* self, +Sip_pix2foc( + Sip* self, PyObject* args, PyObject* kwds) { @@ -225,8 +225,8 @@ PySip_pix2foc( } /*@null@*/ static PyObject* -PySip_foc2pix( - PySip* self, +Sip_foc2pix( + Sip* self, PyObject* args, PyObject* kwds) { @@ -315,8 +315,8 @@ PySip_foc2pix( } /*@null@*/ static PyObject* -PySip_get_a( - PySip* self, +Sip_get_a( + Sip* self, /*@unused@*/ void* closure) { npy_intp dims[2]; @@ -333,8 +333,8 @@ PySip_get_a( } /*@null@*/ static PyObject* -PySip_get_b( - PySip* self, +Sip_get_b( + Sip* self, /*@unused@*/ void* closure) { npy_intp dims[2]; @@ -351,8 +351,8 @@ PySip_get_b( } /*@null@*/ static PyObject* -PySip_get_ap( - PySip* self, +Sip_get_ap( + Sip* self, /*@unused@*/ void* closure) { npy_intp dims[2]; @@ -369,8 +369,8 @@ PySip_get_ap( } /*@null@*/ static PyObject* -PySip_get_bp( - PySip* self, +Sip_get_bp( + Sip* self, /*@unused@*/ void* closure) { npy_intp dims[2]; @@ -387,40 +387,40 @@ PySip_get_bp( } static PyObject* -PySip_get_a_order( - PySip* self, +Sip_get_a_order( + Sip* self, /*@unused@*/ void* closure) { return get_int("a_order", (long int)self->x.a_order); } static PyObject* -PySip_get_b_order( - PySip* self, +Sip_get_b_order( + Sip* self, /*@unused@*/ void* closure) { return get_int("b_order", (long int)self->x.b_order); } static PyObject* -PySip_get_ap_order( - PySip* self, +Sip_get_ap_order( + Sip* self, /*@unused@*/ void* closure) { return get_int("ap_order", (long int)self->x.ap_order); } static PyObject* -PySip_get_bp_order( - PySip* self, +Sip_get_bp_order( + Sip* self, /*@unused@*/ void* closure) { return get_int("bp_order", (long int)self->x.bp_order); } static PyObject* -PySip_get_crpix( - PySip* self, +Sip_get_crpix( + Sip* self, /*@unused@*/ void* closure) { Py_ssize_t naxis = 2; @@ -429,14 +429,14 @@ PySip_get_crpix( } static PyObject* -PySip___copy__( - PySip* self, +Sip___copy__( + Sip* self, /*@unused@*/ PyObject* args, /*@unused@*/ PyObject* kwds) { - PySip* copy = NULL; + Sip* copy = NULL; - copy = (PySip*)PySip_new((PyTypeObject*)PySipType, NULL, NULL); + copy = (Sip*)Sip_new((PyTypeObject*)SipType, NULL, NULL); if (copy == NULL) { return NULL; } @@ -455,53 +455,53 @@ PySip___copy__( } -static PyGetSetDef PySip_getset[] = { - {"a", (getter)PySip_get_a, NULL, (char *)doc_a}, - {"a_order", (getter)PySip_get_a_order, NULL, (char *)doc_a_order}, - {"b", (getter)PySip_get_b, NULL, (char *)doc_b}, - {"b_order", (getter)PySip_get_b_order, NULL, (char *)doc_b_order}, - {"ap", (getter)PySip_get_ap, NULL, (char *)doc_ap}, - {"ap_order", (getter)PySip_get_ap_order, NULL, (char *)doc_ap_order}, - {"bp", (getter)PySip_get_bp, NULL, (char *)doc_bp}, - {"bp_order", (getter)PySip_get_bp_order, NULL, (char *)doc_bp_order}, - {"crpix", (getter)PySip_get_crpix, NULL, (char *)doc_crpix}, +static PyGetSetDef Sip_getset[] = { + {"a", (getter)Sip_get_a, NULL, (char *)doc_a}, + {"a_order", (getter)Sip_get_a_order, NULL, (char *)doc_a_order}, + {"b", (getter)Sip_get_b, NULL, (char *)doc_b}, + {"b_order", (getter)Sip_get_b_order, NULL, (char *)doc_b_order}, + {"ap", (getter)Sip_get_ap, NULL, (char *)doc_ap}, + {"ap_order", (getter)Sip_get_ap_order, NULL, (char *)doc_ap_order}, + {"bp", (getter)Sip_get_bp, NULL, (char *)doc_bp}, + {"bp_order", (getter)Sip_get_bp_order, NULL, (char *)doc_bp_order}, + {"crpix", (getter)Sip_get_crpix, NULL, (char *)doc_crpix}, {NULL} }; -static PyMethodDef PySip_methods[] = { - {"__copy__", (PyCFunction)PySip___copy__, METH_NOARGS, NULL}, - {"__deepcopy__", (PyCFunction)PySip___copy__, METH_O, NULL}, - {"pix2foc", (PyCFunction)PySip_pix2foc, METH_VARARGS|METH_KEYWORDS, doc_sip_pix2foc}, - {"foc2pix", (PyCFunction)PySip_foc2pix, METH_VARARGS|METH_KEYWORDS, doc_sip_foc2pix}, +static PyMethodDef Sip_methods[] = { + {"__copy__", (PyCFunction)Sip___copy__, METH_NOARGS, NULL}, + {"__deepcopy__", (PyCFunction)Sip___copy__, METH_O, NULL}, + {"pix2foc", (PyCFunction)Sip_pix2foc, METH_VARARGS|METH_KEYWORDS, doc_sip_pix2foc}, + {"foc2pix", (PyCFunction)Sip_foc2pix, METH_VARARGS|METH_KEYWORDS, doc_sip_foc2pix}, {NULL} }; -static PyType_Spec PySipType_spec = { +static PyType_Spec SipType_spec = { .name = "astropy.wcs.Sip", - .basicsize = sizeof(PySip), + .basicsize = sizeof(Sip), .itemsize = 0, .flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_IMMUTABLETYPE, .slots = (PyType_Slot[]){ - {Py_tp_dealloc, (destructor)PySip_dealloc}, + {Py_tp_dealloc, (destructor)Sip_dealloc}, {Py_tp_doc, doc_Sip}, - {Py_tp_methods, PySip_methods}, - {Py_tp_getset, PySip_getset}, - {Py_tp_init, (initproc)PySip_init}, - {Py_tp_new, PySip_new}, + {Py_tp_methods, Sip_methods}, + {Py_tp_getset, Sip_getset}, + {Py_tp_init, (initproc)Sip_init}, + {Py_tp_new, Sip_new}, {0, NULL}, }, }; -PyObject* PySipType = NULL; +PyObject* SipType = NULL; int _setup_sip_type( PyObject* m) { - PySipType = PyType_FromSpec(&PySipType_spec); + SipType = PyType_FromSpec(&SipType_spec); - if (PySipType == NULL) + if (SipType == NULL) return -1; - return PyModule_AddObject(m, "Sip", PySipType); + return PyModule_AddObject(m, "Sip", SipType); } diff --git a/astropy/wcs/src/str_list_proxy.c b/astropy/wcs/src/str_list_proxy.c index c0d283fa1c76..a8c52dc84fc0 100644 --- a/astropy/wcs/src/str_list_proxy.c +++ b/astropy/wcs/src/str_list_proxy.c @@ -12,7 +12,7 @@ * List-of-strings proxy object ***************************************************************************/ -static PyObject* PyStrListProxyType; +static PyObject* StrListProxyType; typedef struct { PyObject_HEAD @@ -20,11 +20,11 @@ typedef struct { Py_ssize_t size; Py_ssize_t maxsize; char (*array)[72]; -} PyStrListProxy; +} StrListProxy; static void -PyStrListProxy_dealloc( - PyStrListProxy* self) { +StrListProxy_dealloc( + StrListProxy* self) { PyObject_GC_UnTrack(self); Py_XDECREF(self->pyobject); @@ -35,15 +35,15 @@ PyStrListProxy_dealloc( } /*@null@*/ static PyObject * -PyStrListProxy_new( +StrListProxy_new( PyTypeObject* type, /*@unused@*/ PyObject* args, /*@unused@*/ PyObject* kwds) { - PyStrListProxy* self = NULL; + StrListProxy* self = NULL; allocfunc alloc_func = PyType_GetSlot(type, Py_tp_alloc); - self = (PyStrListProxy*)alloc_func(type, 0); + self = (StrListProxy*)alloc_func(type, 0); if (self != NULL) { self->pyobject = NULL; } @@ -51,8 +51,8 @@ PyStrListProxy_new( } static int -PyStrListProxy_traverse( - PyStrListProxy* self, +StrListProxy_traverse( + StrListProxy* self, visitproc visit, void *arg) { @@ -62,8 +62,8 @@ PyStrListProxy_traverse( } static int -PyStrListProxy_clear( - PyStrListProxy *self) { +StrListProxy_clear( + StrListProxy *self) { Py_CLEAR(self->pyobject); @@ -71,21 +71,21 @@ PyStrListProxy_clear( } /*@null@*/ PyObject * -PyStrListProxy_New( +StrListProxy_New( /*@shared@*/ PyObject* owner, Py_ssize_t size, Py_ssize_t maxsize, char (*array)[72]) { - PyStrListProxy* self = NULL; + StrListProxy* self = NULL; if (maxsize == 0) { maxsize = 68; } - PyTypeObject* tp = (PyTypeObject*)PyStrListProxyType; + PyTypeObject* tp = (PyTypeObject*)StrListProxyType; allocfunc alloc_func = PyType_GetSlot(tp, Py_tp_alloc); - self = (PyStrListProxy*)alloc_func(tp, 0); + self = (StrListProxy*)alloc_func(tp, 0); if (self == NULL) { return NULL; } @@ -99,15 +99,15 @@ PyStrListProxy_New( } static Py_ssize_t -PyStrListProxy_len( - PyStrListProxy* self) { +StrListProxy_len( + StrListProxy* self) { return self->size; } /*@null@*/ static PyObject* -PyStrListProxy_getitem( - PyStrListProxy* self, +StrListProxy_getitem( + StrListProxy* self, Py_ssize_t index) { if (index >= self->size || index < 0) { @@ -119,8 +119,8 @@ PyStrListProxy_getitem( } static int -PyStrListProxy_setitem( - PyStrListProxy* self, +StrListProxy_setitem( + StrListProxy* self, Py_ssize_t index, PyObject* arg) { @@ -199,39 +199,39 @@ str_list_proxy_repr( } /*@null@*/ static PyObject* -PyStrListProxy_repr( - PyStrListProxy* self) { +StrListProxy_repr( + StrListProxy* self) { return str_list_proxy_repr(self->array, self->size, self->maxsize); } -static PyType_Spec PyStrListProxyType_spec = { +static PyType_Spec StrListProxyType_spec = { .name = "astropy.wcs.StrListProxy", - .basicsize = sizeof(PyStrListProxy), + .basicsize = sizeof(StrListProxy), .itemsize = 0, .flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC, .slots = (PyType_Slot[]){ - {Py_tp_dealloc, (destructor)PyStrListProxy_dealloc}, - {Py_tp_repr, (reprfunc)PyStrListProxy_repr}, - {Py_sq_length, (lenfunc)PyStrListProxy_len}, - {Py_sq_item, (ssizeargfunc)PyStrListProxy_getitem}, - {Py_sq_ass_item, (ssizeobjargproc)PyStrListProxy_setitem}, - {Py_tp_str, (reprfunc)PyStrListProxy_repr}, - {Py_tp_traverse, (traverseproc)PyStrListProxy_traverse}, - {Py_tp_clear, (inquiry)PyStrListProxy_clear}, - {Py_tp_new, (newfunc)PyStrListProxy_new}, + {Py_tp_dealloc, (destructor)StrListProxy_dealloc}, + {Py_tp_repr, (reprfunc)StrListProxy_repr}, + {Py_sq_length, (lenfunc)StrListProxy_len}, + {Py_sq_item, (ssizeargfunc)StrListProxy_getitem}, + {Py_sq_ass_item, (ssizeobjargproc)StrListProxy_setitem}, + {Py_tp_str, (reprfunc)StrListProxy_repr}, + {Py_tp_traverse, (traverseproc)StrListProxy_traverse}, + {Py_tp_clear, (inquiry)StrListProxy_clear}, + {Py_tp_new, (newfunc)StrListProxy_new}, {0, NULL}, }, }; -static PyObject* PyStrListProxyType = NULL; +static PyObject* StrListProxyType = NULL; int _setup_str_list_proxy_type( /*@unused@*/ PyObject* m) { - PyStrListProxyType = PyType_FromSpec(&PyStrListProxyType_spec); - if (PyStrListProxyType == NULL) { + StrListProxyType = PyType_FromSpec(&StrListProxyType_spec); + if (StrListProxyType == NULL) { return 1; } diff --git a/astropy/wcs/src/unit_list_proxy.c b/astropy/wcs/src/unit_list_proxy.c index 169815ed6398..514c8aa77e7e 100644 --- a/astropy/wcs/src/unit_list_proxy.c +++ b/astropy/wcs/src/unit_list_proxy.c @@ -16,7 +16,7 @@ #define MAXSIZE 68 #define ARRAYSIZE 72 -static PyObject* PyUnitListProxyType; +static PyObject* UnitListProxyType; typedef struct { PyObject_HEAD @@ -25,11 +25,11 @@ typedef struct { char (*array)[ARRAYSIZE]; PyObject* unit_class; int readonly; -} PyUnitListProxy; +} UnitListProxy; static void -PyUnitListProxy_dealloc( - PyUnitListProxy* self) { +UnitListProxy_dealloc( + UnitListProxy* self) { PyObject_GC_UnTrack(self); Py_XDECREF(self->pyobject); @@ -40,15 +40,15 @@ PyUnitListProxy_dealloc( } /*@null@*/ static PyObject * -PyUnitListProxy_new( +UnitListProxy_new( PyTypeObject* type, /*@unused@*/ PyObject* args, /*@unused@*/ PyObject* kwds) { - PyUnitListProxy* self = NULL; + UnitListProxy* self = NULL; allocfunc alloc_func = PyType_GetSlot(type, Py_tp_alloc); - self = (PyUnitListProxy*)alloc_func(type, 0); + self = (UnitListProxy*)alloc_func(type, 0); if (self != NULL) { self->pyobject = NULL; self->unit_class = NULL; @@ -57,8 +57,8 @@ PyUnitListProxy_new( } static int -PyUnitListProxy_traverse( - PyUnitListProxy* self, +UnitListProxy_traverse( + UnitListProxy* self, visitproc visit, void *arg) { @@ -69,8 +69,8 @@ PyUnitListProxy_traverse( } static int -PyUnitListProxy_clear( - PyUnitListProxy *self) { +UnitListProxy_clear( + UnitListProxy *self) { Py_CLEAR(self->pyobject); Py_CLEAR(self->unit_class); @@ -79,13 +79,13 @@ PyUnitListProxy_clear( } /*@null@*/ PyObject * -PyUnitListProxy_New( +UnitListProxy_New( /*@shared@*/ PyObject* owner, Py_ssize_t size, char (*array)[ARRAYSIZE], int readonly) { - PyUnitListProxy* self = NULL; + UnitListProxy* self = NULL; PyObject *units_module; PyObject *units_dict; PyObject *unit_class; @@ -108,9 +108,9 @@ PyUnitListProxy_New( Py_INCREF(unit_class); - PyTypeObject* type = (PyTypeObject*)PyUnitListProxyType; + PyTypeObject* type = (PyTypeObject*)UnitListProxyType; allocfunc alloc_func = PyType_GetSlot(type, Py_tp_alloc); - self = (PyUnitListProxy*)alloc_func(type, 0); + self = (UnitListProxy*)alloc_func(type, 0); if (self == NULL) { return NULL; } @@ -125,8 +125,8 @@ PyUnitListProxy_New( } static Py_ssize_t -PyUnitListProxy_len( - PyUnitListProxy* self) { +UnitListProxy_len( + UnitListProxy* self) { return self->size; } @@ -161,8 +161,8 @@ _get_unit( } /*@null@*/ static PyObject* -PyUnitListProxy_getitem( - PyUnitListProxy* self, +UnitListProxy_getitem( + UnitListProxy* self, Py_ssize_t index) { PyObject *value; @@ -182,16 +182,16 @@ PyUnitListProxy_getitem( } static PyObject* -PyUnitListProxy_richcmp( +UnitListProxy_richcmp( PyObject *a, PyObject *b, int op){ - PyUnitListProxy *lhs, *rhs; + UnitListProxy *lhs, *rhs; Py_ssize_t idx; int equal = 1; assert(a != NULL && b != NULL); - if (!PyObject_TypeCheck(a, (PyTypeObject*)PyUnitListProxyType) || - !PyObject_TypeCheck(b, (PyTypeObject*)PyUnitListProxyType)) { + if (!PyObject_TypeCheck(a, (PyTypeObject*)UnitListProxyType) || + !PyObject_TypeCheck(b, (PyTypeObject*)UnitListProxyType)) { Py_RETURN_NOTIMPLEMENTED; } if (op != Py_EQ && op != Py_NE) { @@ -201,8 +201,8 @@ PyUnitListProxy_richcmp( /* The actual comparison of the two objects. unit_class is ignored because * it's not an essential property of the instances. */ - lhs = (PyUnitListProxy *)a; - rhs = (PyUnitListProxy *)b; + lhs = (UnitListProxy *)a; + rhs = (UnitListProxy *)b; if (lhs->size != rhs->size) { equal = 0; } @@ -220,8 +220,8 @@ PyUnitListProxy_richcmp( } static int -PyUnitListProxy_setitem( - PyUnitListProxy* self, +UnitListProxy_setitem( + UnitListProxy* self, Py_ssize_t index, PyObject* arg) { @@ -269,33 +269,33 @@ PyUnitListProxy_setitem( } /*@null@*/ static PyObject* -PyUnitListProxy_repr( - PyUnitListProxy* self) { +UnitListProxy_repr( + UnitListProxy* self) { return str_list_proxy_repr(self->array, self->size, MAXSIZE); } -static PyType_Spec PyUnitListProxyType_spec = { +static PyType_Spec UnitListProxyType_spec = { .name = "astropy.wcs.UnitListProxy", - .basicsize = sizeof(PyUnitListProxy), + .basicsize = sizeof(UnitListProxy), .itemsize = 0, .flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC | Py_TPFLAGS_IMMUTABLETYPE, .slots = (PyType_Slot[]){ - {Py_tp_dealloc, (destructor)PyUnitListProxy_dealloc}, - {Py_tp_repr, (reprfunc)PyUnitListProxy_repr}, - {Py_tp_str, (reprfunc)PyUnitListProxy_repr}, - {Py_tp_traverse, (traverseproc)PyUnitListProxy_traverse}, - {Py_tp_clear, (inquiry)PyUnitListProxy_clear}, - {Py_tp_richcompare, (richcmpfunc)PyUnitListProxy_richcmp}, - {Py_tp_new, (newfunc)PyUnitListProxy_new}, - {Py_sq_length, (lenfunc)PyUnitListProxy_len}, - {Py_sq_item, (ssizeargfunc)PyUnitListProxy_getitem}, - {Py_sq_ass_item, (ssizeobjargproc)PyUnitListProxy_setitem}, + {Py_tp_dealloc, (destructor)UnitListProxy_dealloc}, + {Py_tp_repr, (reprfunc)UnitListProxy_repr}, + {Py_tp_str, (reprfunc)UnitListProxy_repr}, + {Py_tp_traverse, (traverseproc)UnitListProxy_traverse}, + {Py_tp_clear, (inquiry)UnitListProxy_clear}, + {Py_tp_richcompare, (richcmpfunc)UnitListProxy_richcmp}, + {Py_tp_new, (newfunc)UnitListProxy_new}, + {Py_sq_length, (lenfunc)UnitListProxy_len}, + {Py_sq_item, (ssizeargfunc)UnitListProxy_getitem}, + {Py_sq_ass_item, (ssizeobjargproc)UnitListProxy_setitem}, {0, NULL}, }, }; -static PyObject* PyUnitListProxyType = NULL; +static PyObject* UnitListProxyType = NULL; int set_unit_list( @@ -330,7 +330,7 @@ set_unit_list( return -1; } - proxy = PyUnitListProxy_New(owner, len, dest, 0); + proxy = UnitListProxy_New(owner, len, dest, 0); if (proxy == NULL) { return -1; } @@ -361,8 +361,8 @@ int _setup_unit_list_proxy_type( /*@unused@*/ PyObject* m) { - PyUnitListProxyType = PyType_FromSpec(&PyUnitListProxyType_spec); - if (PyUnitListProxyType == NULL) { + UnitListProxyType = PyType_FromSpec(&UnitListProxyType_spec); + if (UnitListProxyType == NULL) { return 1; } diff --git a/astropy/wcs/src/wcslib_auxprm_wrap.c b/astropy/wcs/src/wcslib_auxprm_wrap.c index 16d249a2de3f..edef4dc78fe1 100644 --- a/astropy/wcs/src/wcslib_auxprm_wrap.c +++ b/astropy/wcs/src/wcslib_auxprm_wrap.c @@ -16,21 +16,21 @@ /*************************************************************************** - * PyAuxprm methods * + * Auxprm methods * ***************************************************************************/ static PyObject* -PyAuxprm_new(PyTypeObject* type, PyObject* args, PyObject* kwds) { - PyAuxprm* self; +Auxprm_new(PyTypeObject* type, PyObject* args, PyObject* kwds) { + Auxprm* self; allocfunc alloc_func = PyType_GetSlot(type, Py_tp_alloc); - self = (PyAuxprm*)alloc_func(type, 0); + self = (Auxprm*)alloc_func(type, 0); return (PyObject*)self; } static int -PyAuxprm_traverse(PyAuxprm* self, visitproc visit, void *arg) { +Auxprm_traverse(Auxprm* self, visitproc visit, void *arg) { Py_VISIT(self->owner); Py_VISIT((PyObject*)Py_TYPE((PyObject*)self)); return 0; @@ -38,14 +38,14 @@ PyAuxprm_traverse(PyAuxprm* self, visitproc visit, void *arg) { static int -PyAuxprm_clear(PyAuxprm* self) { +Auxprm_clear(Auxprm* self) { Py_CLEAR(self->owner); return 0; } -static void PyAuxprm_dealloc(PyAuxprm* self) { - PyAuxprm_clear(self); +static void Auxprm_dealloc(Auxprm* self) { + Auxprm_clear(self); PyTypeObject *tp = Py_TYPE((PyObject*)self); freefunc free_func = PyType_GetSlot(tp, Py_tp_free); free_func((PyObject*)self); @@ -53,11 +53,11 @@ static void PyAuxprm_dealloc(PyAuxprm* self) { } -PyAuxprm* PyAuxprm_cnew(PyObject* wcsprm, struct auxprm* x) { - PyAuxprm* self; - PyTypeObject* type = (PyTypeObject*)PyAuxprmType; +Auxprm* Auxprm_cnew(PyObject* wcsprm, struct auxprm* x) { + Auxprm* self; + PyTypeObject* type = (PyTypeObject*)AuxprmType; allocfunc alloc_func = PyType_GetSlot(type, Py_tp_alloc); - self = (PyAuxprm*)alloc_func(type, 0); + self = (Auxprm*)alloc_func(type, 0); if (self == NULL) return NULL; self->x = x; Py_INCREF(wcsprm); @@ -96,7 +96,7 @@ static void auxprmprt(const struct auxprm *aux) { } -static PyObject* PyAuxprm___str__(PyAuxprm* self) { +static PyObject* Auxprm___str__(Auxprm* self) { /* This is not thread-safe, but since we're holding onto the GIL, we can assume we won't have thread conflicts */ wcsprintf_set(NULL); @@ -109,7 +109,7 @@ static PyObject* PyAuxprm___str__(PyAuxprm* self) { * Member getters/setters (properties) */ -static PyObject* PyAuxprm_get_rsun_ref(PyAuxprm* self, void* closure) { +static PyObject* Auxprm_get_rsun_ref(Auxprm* self, void* closure) { if(self->x == NULL || self->x->rsun_ref == UNDEFINED) { Py_RETURN_NONE; } else { @@ -117,7 +117,7 @@ static PyObject* PyAuxprm_get_rsun_ref(PyAuxprm* self, void* closure) { } } -static int PyAuxprm_set_rsun_ref(PyAuxprm* self, PyObject* value, void* closure) { +static int Auxprm_set_rsun_ref(Auxprm* self, PyObject* value, void* closure) { if(self->x == NULL) { return -1; } else if (value == Py_None) { @@ -128,7 +128,7 @@ static int PyAuxprm_set_rsun_ref(PyAuxprm* self, PyObject* value, void* closure) } } -static PyObject* PyAuxprm_get_dsun_obs(PyAuxprm* self, void* closure) { +static PyObject* Auxprm_get_dsun_obs(Auxprm* self, void* closure) { if(self->x == NULL || self->x->dsun_obs == UNDEFINED) { Py_RETURN_NONE; } else { @@ -136,7 +136,7 @@ static PyObject* PyAuxprm_get_dsun_obs(PyAuxprm* self, void* closure) { } } -static int PyAuxprm_set_dsun_obs(PyAuxprm* self, PyObject* value, void* closure) { +static int Auxprm_set_dsun_obs(Auxprm* self, PyObject* value, void* closure) { if(self->x == NULL) { return -1; } else if (value == Py_None) { @@ -147,7 +147,7 @@ static int PyAuxprm_set_dsun_obs(PyAuxprm* self, PyObject* value, void* closure) } } -static PyObject* PyAuxprm_get_crln_obs(PyAuxprm* self, void* closure) { +static PyObject* Auxprm_get_crln_obs(Auxprm* self, void* closure) { if(self->x == NULL || self->x->crln_obs == UNDEFINED) { Py_RETURN_NONE; } else { @@ -155,7 +155,7 @@ static PyObject* PyAuxprm_get_crln_obs(PyAuxprm* self, void* closure) { } } -static int PyAuxprm_set_crln_obs(PyAuxprm* self, PyObject* value, void* closure) { +static int Auxprm_set_crln_obs(Auxprm* self, PyObject* value, void* closure) { if(self->x == NULL) { return -1; } else if (value == Py_None) { @@ -166,7 +166,7 @@ static int PyAuxprm_set_crln_obs(PyAuxprm* self, PyObject* value, void* closure) } } -static PyObject* PyAuxprm_get_hgln_obs(PyAuxprm* self, void* closure) { +static PyObject* Auxprm_get_hgln_obs(Auxprm* self, void* closure) { if(self->x == NULL || self->x->hgln_obs == UNDEFINED) { Py_RETURN_NONE; } else { @@ -174,7 +174,7 @@ static PyObject* PyAuxprm_get_hgln_obs(PyAuxprm* self, void* closure) { } } -static int PyAuxprm_set_hgln_obs(PyAuxprm* self, PyObject* value, void* closure) { +static int Auxprm_set_hgln_obs(Auxprm* self, PyObject* value, void* closure) { if(self->x == NULL) { return -1; } else if (value == Py_None) { @@ -185,7 +185,7 @@ static int PyAuxprm_set_hgln_obs(PyAuxprm* self, PyObject* value, void* closure) } } -static PyObject* PyAuxprm_get_hglt_obs(PyAuxprm* self, void* closure) { +static PyObject* Auxprm_get_hglt_obs(Auxprm* self, void* closure) { if(self->x == NULL || self->x->hglt_obs == UNDEFINED) { Py_RETURN_NONE; } else { @@ -193,7 +193,7 @@ static PyObject* PyAuxprm_get_hglt_obs(PyAuxprm* self, void* closure) { } } -static int PyAuxprm_set_hglt_obs(PyAuxprm* self, PyObject* value, void* closure) { +static int Auxprm_set_hglt_obs(Auxprm* self, PyObject* value, void* closure) { if(self->x == NULL) { return -1; } else if (value == Py_None) { @@ -204,7 +204,7 @@ static int PyAuxprm_set_hglt_obs(PyAuxprm* self, PyObject* value, void* closure) } } -static PyObject* PyAuxprm_get_a_radius(PyAuxprm* self, void* closure) { +static PyObject* Auxprm_get_a_radius(Auxprm* self, void* closure) { if(self->x == NULL || self->x->a_radius == UNDEFINED) { Py_RETURN_NONE; } else { @@ -212,7 +212,7 @@ static PyObject* PyAuxprm_get_a_radius(PyAuxprm* self, void* closure) { } } -static int PyAuxprm_set_a_radius(PyAuxprm* self, PyObject* value, void* closure) { +static int Auxprm_set_a_radius(Auxprm* self, PyObject* value, void* closure) { if(self->x == NULL) { return -1; } else if (value == Py_None) { @@ -223,7 +223,7 @@ static int PyAuxprm_set_a_radius(PyAuxprm* self, PyObject* value, void* closure) } } -static PyObject* PyAuxprm_get_b_radius(PyAuxprm* self, void* closure) { +static PyObject* Auxprm_get_b_radius(Auxprm* self, void* closure) { if(self->x == NULL || self->x->b_radius == UNDEFINED) { Py_RETURN_NONE; } else { @@ -231,7 +231,7 @@ static PyObject* PyAuxprm_get_b_radius(PyAuxprm* self, void* closure) { } } -static int PyAuxprm_set_b_radius(PyAuxprm* self, PyObject* value, void* closure) { +static int Auxprm_set_b_radius(Auxprm* self, PyObject* value, void* closure) { if(self->x == NULL) { return -1; } else if (value == Py_None) { @@ -242,7 +242,7 @@ static int PyAuxprm_set_b_radius(PyAuxprm* self, PyObject* value, void* closure) } } -static PyObject* PyAuxprm_get_c_radius(PyAuxprm* self, void* closure) { +static PyObject* Auxprm_get_c_radius(Auxprm* self, void* closure) { if(self->x == NULL || self->x->c_radius == UNDEFINED) { Py_RETURN_NONE; } else { @@ -250,7 +250,7 @@ static PyObject* PyAuxprm_get_c_radius(PyAuxprm* self, void* closure) { } } -static int PyAuxprm_set_c_radius(PyAuxprm* self, PyObject* value, void* closure) { +static int Auxprm_set_c_radius(Auxprm* self, PyObject* value, void* closure) { if(self->x == NULL) { return -1; } else if (value == Py_None) { @@ -261,7 +261,7 @@ static int PyAuxprm_set_c_radius(PyAuxprm* self, PyObject* value, void* closure) } } -static PyObject* PyAuxprm_get_bdis_obs(PyAuxprm* self, void* closure) { +static PyObject* Auxprm_get_bdis_obs(Auxprm* self, void* closure) { if(self->x == NULL || self->x->bdis_obs == UNDEFINED) { Py_RETURN_NONE; } else { @@ -269,7 +269,7 @@ static PyObject* PyAuxprm_get_bdis_obs(PyAuxprm* self, void* closure) { } } -static int PyAuxprm_set_bdis_obs(PyAuxprm* self, PyObject* value, void* closure) { +static int Auxprm_set_bdis_obs(Auxprm* self, PyObject* value, void* closure) { if(self->x == NULL) { return -1; } else if (value == Py_None) { @@ -280,7 +280,7 @@ static int PyAuxprm_set_bdis_obs(PyAuxprm* self, PyObject* value, void* closure) } } -static PyObject* PyAuxprm_get_blon_obs(PyAuxprm* self, void* closure) { +static PyObject* Auxprm_get_blon_obs(Auxprm* self, void* closure) { if(self->x == NULL || self->x->blon_obs == UNDEFINED) { Py_RETURN_NONE; } else { @@ -288,7 +288,7 @@ static PyObject* PyAuxprm_get_blon_obs(PyAuxprm* self, void* closure) { } } -static int PyAuxprm_set_blon_obs(PyAuxprm* self, PyObject* value, void* closure) { +static int Auxprm_set_blon_obs(Auxprm* self, PyObject* value, void* closure) { if(self->x == NULL) { return -1; } else if (value == Py_None) { @@ -299,7 +299,7 @@ static int PyAuxprm_set_blon_obs(PyAuxprm* self, PyObject* value, void* closure) } } -static PyObject* PyAuxprm_get_blat_obs(PyAuxprm* self, void* closure) { +static PyObject* Auxprm_get_blat_obs(Auxprm* self, void* closure) { if(self->x == NULL || self->x->blat_obs == UNDEFINED) { Py_RETURN_NONE; } else { @@ -307,7 +307,7 @@ static PyObject* PyAuxprm_get_blat_obs(PyAuxprm* self, void* closure) { } } -static int PyAuxprm_set_blat_obs(PyAuxprm* self, PyObject* value, void* closure) { +static int Auxprm_set_blat_obs(Auxprm* self, PyObject* value, void* closure) { if(self->x == NULL) { return -1; } else if (value == Py_None) { @@ -320,53 +320,53 @@ static int PyAuxprm_set_blat_obs(PyAuxprm* self, PyObject* value, void* closure) /*************************************************************************** - * PyAuxprm definition structures + * Auxprm definition structures */ -static PyGetSetDef PyAuxprm_getset[] = { - {"rsun_ref", (getter)PyAuxprm_get_rsun_ref, (setter)PyAuxprm_set_rsun_ref, (char *)doc_rsun_ref}, - {"dsun_obs", (getter)PyAuxprm_get_dsun_obs, (setter)PyAuxprm_set_dsun_obs, (char *)doc_dsun_obs}, - {"crln_obs", (getter)PyAuxprm_get_crln_obs, (setter)PyAuxprm_set_crln_obs, (char *)doc_crln_obs}, - {"hgln_obs", (getter)PyAuxprm_get_hgln_obs, (setter)PyAuxprm_set_hgln_obs, (char *)doc_hgln_obs}, - {"hglt_obs", (getter)PyAuxprm_get_hglt_obs, (setter)PyAuxprm_set_hglt_obs, (char *)doc_hglt_obs}, - {"a_radius", (getter)PyAuxprm_get_a_radius, (setter)PyAuxprm_set_a_radius, (char *)doc_a_radius}, - {"b_radius", (getter)PyAuxprm_get_b_radius, (setter)PyAuxprm_set_b_radius, (char *)doc_b_radius}, - {"c_radius", (getter)PyAuxprm_get_c_radius, (setter)PyAuxprm_set_c_radius, (char *)doc_c_radius}, - {"bdis_obs", (getter)PyAuxprm_get_bdis_obs, (setter)PyAuxprm_set_bdis_obs, (char *)doc_bdis_obs}, - {"blon_obs", (getter)PyAuxprm_get_blon_obs, (setter)PyAuxprm_set_blon_obs, (char *)doc_blon_obs}, - {"blat_obs", (getter)PyAuxprm_get_blat_obs, (setter)PyAuxprm_set_blat_obs, (char *)doc_blat_obs}, +static PyGetSetDef Auxprm_getset[] = { + {"rsun_ref", (getter)Auxprm_get_rsun_ref, (setter)Auxprm_set_rsun_ref, (char *)doc_rsun_ref}, + {"dsun_obs", (getter)Auxprm_get_dsun_obs, (setter)Auxprm_set_dsun_obs, (char *)doc_dsun_obs}, + {"crln_obs", (getter)Auxprm_get_crln_obs, (setter)Auxprm_set_crln_obs, (char *)doc_crln_obs}, + {"hgln_obs", (getter)Auxprm_get_hgln_obs, (setter)Auxprm_set_hgln_obs, (char *)doc_hgln_obs}, + {"hglt_obs", (getter)Auxprm_get_hglt_obs, (setter)Auxprm_set_hglt_obs, (char *)doc_hglt_obs}, + {"a_radius", (getter)Auxprm_get_a_radius, (setter)Auxprm_set_a_radius, (char *)doc_a_radius}, + {"b_radius", (getter)Auxprm_get_b_radius, (setter)Auxprm_set_b_radius, (char *)doc_b_radius}, + {"c_radius", (getter)Auxprm_get_c_radius, (setter)Auxprm_set_c_radius, (char *)doc_c_radius}, + {"bdis_obs", (getter)Auxprm_get_bdis_obs, (setter)Auxprm_set_bdis_obs, (char *)doc_bdis_obs}, + {"blon_obs", (getter)Auxprm_get_blon_obs, (setter)Auxprm_set_blon_obs, (char *)doc_blon_obs}, + {"blat_obs", (getter)Auxprm_get_blat_obs, (setter)Auxprm_set_blat_obs, (char *)doc_blat_obs}, {NULL} }; -PyType_Spec PyAuxprmType_spec = { +PyType_Spec AuxprmType_spec = { .name = "astropy.wcs.Auxprm", - .basicsize = sizeof(PyAuxprm), + .basicsize = sizeof(Auxprm), .itemsize = 0, .flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_IMMUTABLETYPE, .slots = (PyType_Slot[]) { - {Py_tp_dealloc, (destructor)PyAuxprm_dealloc}, - {Py_tp_str, (reprfunc)PyAuxprm___str__}, + {Py_tp_dealloc, (destructor)Auxprm_dealloc}, + {Py_tp_str, (reprfunc)Auxprm___str__}, {Py_tp_doc, doc_Auxprm}, - {Py_tp_traverse, (traverseproc)PyAuxprm_traverse}, - {Py_tp_clear, (inquiry)PyAuxprm_clear}, - {Py_tp_getset, PyAuxprm_getset}, + {Py_tp_traverse, (traverseproc)Auxprm_traverse}, + {Py_tp_clear, (inquiry)Auxprm_clear}, + {Py_tp_getset, Auxprm_getset}, // FIXME: this seems logical but this slot wasn't previously set // maybe a mistake from https://github.com/astropy/astropy/pull/10333 ? - // {Py_tp_new, (void*)PyAuxprm_new}, + // {Py_tp_new, (void*)Auxprm_new}, {0, NULL} }, }; -PyObject* PyAuxprmType = NULL; +PyObject* AuxprmType = NULL; int _setup_auxprm_type(PyObject* m) { - PyAuxprmType = PyType_FromSpec(&PyAuxprmType_spec); - if (PyAuxprmType == NULL) { + AuxprmType = PyType_FromSpec(&AuxprmType_spec); + if (AuxprmType == NULL) { return -1; } - PyModule_AddObject(m, "Auxprm", PyAuxprmType); + PyModule_AddObject(m, "Auxprm", AuxprmType); return 0; } diff --git a/astropy/wcs/src/wcslib_celprm_wrap.c b/astropy/wcs/src/wcslib_celprm_wrap.c index e00c1809f7a8..4334cc30cc72 100644 --- a/astropy/wcs/src/wcslib_celprm_wrap.c +++ b/astropy/wcs/src/wcslib_celprm_wrap.c @@ -41,7 +41,7 @@ static int wcslib_cel_to_python_exc(int status) } -static int is_readonly(PyCelprm* self) +static int is_readonly(Celprm* self) { if (self != NULL && self->owner != NULL) { PyErr_SetString( @@ -54,7 +54,7 @@ static int is_readonly(PyCelprm* self) } -static int is_cel_null(PyCelprm* self) +static int is_cel_null(Celprm* self) { if (self->x == NULL) { PyErr_SetString( @@ -68,14 +68,14 @@ static int is_cel_null(PyCelprm* self) /*************************************************************************** - * PyCelprm methods * + * Celprm methods * ***************************************************************************/ -static PyObject* PyCelprm_new(PyTypeObject* type, PyObject* args, PyObject* kwds) +static PyObject* Celprm_new(PyTypeObject* type, PyObject* args, PyObject* kwds) { - PyCelprm* self; + Celprm* self; allocfunc alloc_func = PyType_GetSlot(type, Py_tp_alloc); - self = (PyCelprm*)alloc_func(type, 0); + self = (Celprm*)alloc_func(type, 0); if (self == NULL) return NULL; self->owner = NULL; self->prefcount = NULL; @@ -101,7 +101,7 @@ static PyObject* PyCelprm_new(PyTypeObject* type, PyObject* args, PyObject* kwds } -static int PyCelprm_traverse(PyCelprm* self, visitproc visit, void *arg) +static int Celprm_traverse(Celprm* self, visitproc visit, void *arg) { Py_VISIT(self->owner); Py_VISIT((PyObject*)Py_TYPE((PyObject*)self)); @@ -109,16 +109,16 @@ static int PyCelprm_traverse(PyCelprm* self, visitproc visit, void *arg) } -static int PyCelprm_clear(PyCelprm* self) +static int Celprm_clear(Celprm* self) { Py_CLEAR(self->owner); return 0; } -static void PyCelprm_dealloc(PyCelprm* self) +static void Celprm_dealloc(Celprm* self) { - PyCelprm_clear(self); + Celprm_clear(self); wcslib_cel_to_python_exc(celfree(self->x)); // free memory used for err msg if (self->prefcount && (--(*self->prefcount)) == 0) { free(self->x); @@ -131,7 +131,7 @@ static void PyCelprm_dealloc(PyCelprm* self) } -static int PyCelprm_cset(PyCelprm* self) +static int Celprm_cset(Celprm* self) { if (wcslib_cel_to_python_exc(celset(self->x))) { return -1; @@ -140,19 +140,19 @@ static int PyCelprm_cset(PyCelprm* self) } -static PyObject* PyCelprm_set(PyCelprm* self) +static PyObject* Celprm_set(Celprm* self) { - if (is_readonly(self) || PyCelprm_cset(self)) return NULL; + if (is_readonly(self) || Celprm_cset(self)) return NULL; Py_RETURN_NONE; } -PyCelprm* PyCelprm_cnew(PyObject* wcsprm_obj, struct celprm* x, int* prefcount) +Celprm* Celprm_cnew(PyObject* wcsprm_obj, struct celprm* x, int* prefcount) { - PyCelprm* self; - PyTypeObject* type = (PyTypeObject*)PyCelprmType; + Celprm* self; + PyTypeObject* type = (PyTypeObject*)CelprmType; allocfunc alloc_func = PyType_GetSlot(type, Py_tp_alloc); - self = (PyCelprm*)alloc_func(type, 0); + self = (Celprm*)alloc_func(type, 0); if (self == NULL) return NULL; self->x = x; Py_XINCREF(wcsprm_obj); @@ -163,18 +163,18 @@ PyCelprm* PyCelprm_cnew(PyObject* wcsprm_obj, struct celprm* x, int* prefcount) } -static PyObject* PyCelprm_copy(PyCelprm* self) +static PyObject* Celprm_copy(Celprm* self) { - PyCelprm* copy = NULL; - copy = PyCelprm_cnew(self->owner, self->x, self->prefcount); + Celprm* copy = NULL; + copy = Celprm_cnew(self->owner, self->x, self->prefcount); if (copy == NULL) return NULL; return (PyObject*)copy; } -static PyObject* PyCelprm_deepcopy(PyCelprm* self) +static PyObject* Celprm_deepcopy(Celprm* self) { - PyCelprm* copy = (PyCelprm*) PyCelprm_new((PyTypeObject*)PyCelprmType, NULL, NULL); + Celprm* copy = (Celprm*) Celprm_new((PyTypeObject*)CelprmType, NULL, NULL); if (copy == NULL) return NULL; memcpy(copy->x, self->x, sizeof(struct celprm)); @@ -183,8 +183,8 @@ static PyObject* PyCelprm_deepcopy(PyCelprm* self) } -static PyObject* PyCelprm___str__(PyCelprm* self) { - /* if (PyCelprm_cset(self)) return NULL; */ +static PyObject* Celprm___str__(Celprm* self) { + /* if (Celprm_cset(self)) return NULL; */ /* This is not thread-safe, but since we're holding onto the GIL, we can assume we won't have thread conflicts */ wcsprintf_set(NULL); @@ -200,7 +200,7 @@ static PyObject* PyCelprm___str__(PyCelprm* self) { */ -static PyObject* PyCelprm_get_flag(PyCelprm* self, void* closure) +static PyObject* Celprm_get_flag(Celprm* self, void* closure) { if (is_cel_null(self)) { return NULL; @@ -209,7 +209,7 @@ static PyObject* PyCelprm_get_flag(PyCelprm* self, void* closure) } } -static PyObject* PyCelprm_get_offset(PyCelprm* self, void* closure) +static PyObject* Celprm_get_offset(Celprm* self, void* closure) { if (is_cel_null(self)) { return NULL; @@ -219,7 +219,7 @@ static PyObject* PyCelprm_get_offset(PyCelprm* self, void* closure) } -static int PyCelprm_set_offset(PyCelprm* self, PyObject* value, void* closure) +static int Celprm_set_offset(Celprm* self, PyObject* value, void* closure) { if (is_cel_null(self) || is_readonly(self)) { return -1; @@ -232,7 +232,7 @@ static int PyCelprm_set_offset(PyCelprm* self, PyObject* value, void* closure) } -static PyObject* PyCelprm_get_phi0(PyCelprm* self, void* closure) +static PyObject* Celprm_get_phi0(Celprm* self, void* closure) { if (is_cel_null(self)) { return NULL; @@ -243,7 +243,7 @@ static PyObject* PyCelprm_get_phi0(PyCelprm* self, void* closure) } -static int PyCelprm_set_phi0(PyCelprm* self, PyObject* value, void* closure) +static int Celprm_set_phi0(Celprm* self, PyObject* value, void* closure) { int result; double phi0; @@ -267,7 +267,7 @@ static int PyCelprm_set_phi0(PyCelprm* self, PyObject* value, void* closure) } -static PyObject* PyCelprm_get_theta0(PyCelprm* self, void* closure) +static PyObject* Celprm_get_theta0(Celprm* self, void* closure) { if (is_cel_null(self)) { return NULL; @@ -278,7 +278,7 @@ static PyObject* PyCelprm_get_theta0(PyCelprm* self, void* closure) } -static int PyCelprm_set_theta0(PyCelprm* self, PyObject* value, void* closure) +static int Celprm_set_theta0(Celprm* self, PyObject* value, void* closure) { int result; double theta0; @@ -301,7 +301,7 @@ static int PyCelprm_set_theta0(PyCelprm* self, PyObject* value, void* closure) } -static PyObject* PyCelprm_get_ref(PyCelprm* self, void* closure) +static PyObject* Celprm_get_ref(Celprm* self, void* closure) { Py_ssize_t size = 4; if (is_cel_null(self)) { @@ -312,7 +312,7 @@ static PyObject* PyCelprm_get_ref(PyCelprm* self, void* closure) } -static int PyCelprm_set_ref(PyCelprm* self, PyObject* value, void* closure) +static int Celprm_set_ref(Celprm* self, PyObject* value, void* closure) { int i; int skip[4] = {0, 0, 0, 0}; @@ -375,14 +375,14 @@ static int PyCelprm_set_ref(PyCelprm* self, PyObject* value, void* closure) } -static PyObject* PyCelprm_get_prj(PyCelprm* self, void* closure) +static PyObject* Celprm_get_prj(Celprm* self, void* closure) { if (is_cel_null(self)) return NULL; - return (PyObject*)PyPrjprm_cnew((PyObject *)self, &(self->x->prj), NULL); + return (PyObject*)Prjprm_cnew((PyObject *)self, &(self->x->prj), NULL); } -static PyObject* PyCelprm_get_euler(PyCelprm* self, void* closure) +static PyObject* Celprm_get_euler(Celprm* self, void* closure) { Py_ssize_t size = 5; if (is_cel_null(self)) return NULL; @@ -390,14 +390,14 @@ static PyObject* PyCelprm_get_euler(PyCelprm* self, void* closure) } -static PyObject* PyCelprm_get_latpreq(PyCelprm* self, void* closure) +static PyObject* Celprm_get_latpreq(Celprm* self, void* closure) { if (is_cel_null(self)) return NULL; return get_int("lapreq", self->x->latpreq); } -static PyObject* PyCelprm_get_isolat(PyCelprm* self, void* closure) +static PyObject* Celprm_get_isolat(Celprm* self, void* closure) { if (is_cel_null(self)) { return NULL; @@ -408,55 +408,55 @@ static PyObject* PyCelprm_get_isolat(PyCelprm* self, void* closure) /*************************************************************************** - * PyCelprm definition structures + * Celprm definition structures */ -static PyGetSetDef PyCelprm_getset[] = { - {"offset", (getter)PyCelprm_get_offset, (setter)PyCelprm_set_offset, (char *)doc_cel_offset}, - {"phi0", (getter)PyCelprm_get_phi0, (setter)PyCelprm_set_phi0, (char *)doc_celprm_phi0}, - {"theta0", (getter)PyCelprm_get_theta0, (setter)PyCelprm_set_theta0, (char *)doc_celprm_theta0}, - {"ref", (getter)PyCelprm_get_ref, (setter)PyCelprm_set_ref, (char *)doc_celprm_ref}, - {"euler", (getter)PyCelprm_get_euler, NULL, (char *)doc_celprm_euler}, - {"latpreq", (getter)PyCelprm_get_latpreq, NULL, (char *)doc_celprm_latpreq}, - {"isolat", (getter)PyCelprm_get_isolat, NULL, (char *)doc_celprm_isolat}, - {"_flag", (getter)PyCelprm_get_flag, NULL, ""}, - {"prj", (getter)PyCelprm_get_prj, NULL, (char *)doc_celprm_prj}, +static PyGetSetDef Celprm_getset[] = { + {"offset", (getter)Celprm_get_offset, (setter)Celprm_set_offset, (char *)doc_cel_offset}, + {"phi0", (getter)Celprm_get_phi0, (setter)Celprm_set_phi0, (char *)doc_celprm_phi0}, + {"theta0", (getter)Celprm_get_theta0, (setter)Celprm_set_theta0, (char *)doc_celprm_theta0}, + {"ref", (getter)Celprm_get_ref, (setter)Celprm_set_ref, (char *)doc_celprm_ref}, + {"euler", (getter)Celprm_get_euler, NULL, (char *)doc_celprm_euler}, + {"latpreq", (getter)Celprm_get_latpreq, NULL, (char *)doc_celprm_latpreq}, + {"isolat", (getter)Celprm_get_isolat, NULL, (char *)doc_celprm_isolat}, + {"_flag", (getter)Celprm_get_flag, NULL, ""}, + {"prj", (getter)Celprm_get_prj, NULL, (char *)doc_celprm_prj}, {NULL} }; -static PyMethodDef PyCelprm_methods[] = { - {"set", (PyCFunction)PyCelprm_set, METH_NOARGS, doc_set_celprm}, - {"__copy__", (PyCFunction)PyCelprm_copy, METH_NOARGS, ""}, - {"__deepcopy__", (PyCFunction)PyCelprm_deepcopy, METH_O, ""}, +static PyMethodDef Celprm_methods[] = { + {"set", (PyCFunction)Celprm_set, METH_NOARGS, doc_set_celprm}, + {"__copy__", (PyCFunction)Celprm_copy, METH_NOARGS, ""}, + {"__deepcopy__", (PyCFunction)Celprm_deepcopy, METH_O, ""}, {NULL} }; -static PyType_Spec PyCelprmType_spec = { +static PyType_Spec CelprmType_spec = { .name = "astropy.wcs.Celprm", - .basicsize = sizeof(PyCelprm), + .basicsize = sizeof(Celprm), .itemsize = 0, .flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_IMMUTABLETYPE, .slots = (PyType_Slot[]){ - {Py_tp_dealloc, (destructor)PyCelprm_dealloc}, - {Py_tp_str, (reprfunc)PyCelprm___str__}, + {Py_tp_dealloc, (destructor)Celprm_dealloc}, + {Py_tp_str, (reprfunc)Celprm___str__}, {Py_tp_doc, doc_Celprm}, - {Py_tp_traverse, (traverseproc)PyCelprm_traverse}, - {Py_tp_clear, (inquiry)PyCelprm_clear}, - {Py_tp_methods, PyCelprm_methods}, - {Py_tp_getset, PyCelprm_getset}, - {Py_tp_new, PyCelprm_new}, + {Py_tp_traverse, (traverseproc)Celprm_traverse}, + {Py_tp_clear, (inquiry)Celprm_clear}, + {Py_tp_methods, Celprm_methods}, + {Py_tp_getset, Celprm_getset}, + {Py_tp_new, Celprm_new}, {0, NULL}, }, }; -PyObject* PyCelprmType = NULL; +PyObject* CelprmType = NULL; int _setup_celprm_type(PyObject* m) { - PyCelprmType = PyType_FromSpec(&PyCelprmType_spec); - if (PyCelprmType == NULL) return -1; - PyModule_AddObject(m, "Celprm", PyCelprmType); + CelprmType = PyType_FromSpec(&CelprmType_spec); + if (CelprmType == NULL) return -1; + PyModule_AddObject(m, "Celprm", CelprmType); cel_errexc[0] = NULL; /* Success */ cel_errexc[1] = &PyExc_MemoryError; /* Null celprm pointer passed */ diff --git a/astropy/wcs/src/wcslib_prjprm_wrap.c b/astropy/wcs/src/wcslib_prjprm_wrap.c index 7fcef537a087..8bd76feffe4d 100644 --- a/astropy/wcs/src/wcslib_prjprm_wrap.c +++ b/astropy/wcs/src/wcslib_prjprm_wrap.c @@ -40,10 +40,10 @@ static int wcslib_prj_to_python_exc(int status) } -static int is_readonly(PyPrjprm* self) +static int is_readonly(Prjprm* self) { if (self != NULL && self->owner != NULL && - ((PyCelprm*)self->owner)->owner != NULL) { + ((Celprm*)self->owner)->owner != NULL) { PyErr_SetString( PyExc_AttributeError, "Attribute 'prj' of 'astropy.wcs.Wcsprm.cel' objects is read-only."); @@ -54,7 +54,7 @@ static int is_readonly(PyPrjprm* self) } -static int is_prj_null(PyPrjprm* self) +static int is_prj_null(Prjprm* self) { if (self->x == NULL) { PyErr_SetString(PyExc_MemoryError, "Underlying 'prjprm' object is NULL."); @@ -66,14 +66,14 @@ static int is_prj_null(PyPrjprm* self) /*************************************************************************** - * PyPrjprm methods * + * Prjprm methods * ***************************************************************************/ -static PyObject* PyPrjprm_new(PyTypeObject* type, PyObject* args, PyObject* kwds) +static PyObject* Prjprm_new(PyTypeObject* type, PyObject* args, PyObject* kwds) { - PyPrjprm* self; + Prjprm* self; allocfunc alloc_func = PyType_GetSlot(type, Py_tp_alloc); - self = (PyPrjprm*)alloc_func(type, 0); + self = (Prjprm*)alloc_func(type, 0); if (self == NULL) return NULL; self->owner = NULL; self->x = NULL; @@ -98,7 +98,7 @@ static PyObject* PyPrjprm_new(PyTypeObject* type, PyObject* args, PyObject* kwds } -static int PyPrjprm_traverse(PyPrjprm* self, visitproc visit, void *arg) +static int Prjprm_traverse(Prjprm* self, visitproc visit, void *arg) { Py_VISIT(self->owner); Py_VISIT((PyObject*)Py_TYPE((PyObject*)self)); @@ -106,16 +106,16 @@ static int PyPrjprm_traverse(PyPrjprm* self, visitproc visit, void *arg) } -static int PyPrjprm_clear(PyPrjprm* self) +static int Prjprm_clear(Prjprm* self) { Py_CLEAR(self->owner); return 0; } -static void PyPrjprm_dealloc(PyPrjprm* self) +static void Prjprm_dealloc(Prjprm* self) { - PyPrjprm_clear(self); + Prjprm_clear(self); if (self->prefcount && (--(*self->prefcount)) == 0) { wcslib_prj_to_python_exc(prjfree(self->x)); free(self->x); @@ -128,12 +128,12 @@ static void PyPrjprm_dealloc(PyPrjprm* self) } -PyPrjprm* PyPrjprm_cnew(PyObject* celprm_obj, struct prjprm* x, int* prefcount) +Prjprm* Prjprm_cnew(PyObject* celprm_obj, struct prjprm* x, int* prefcount) { - PyPrjprm* self; - PyTypeObject* type = (PyTypeObject*)PyPrjprmType; + Prjprm* self; + PyTypeObject* type = (PyTypeObject*)PrjprmType; allocfunc alloc_func = PyType_GetSlot(type, Py_tp_alloc); - self = (PyPrjprm*)alloc_func(type, 0); + self = (Prjprm*)alloc_func(type, 0); if (self == NULL) return NULL; self->x = x; Py_XINCREF(celprm_obj); @@ -144,18 +144,18 @@ PyPrjprm* PyPrjprm_cnew(PyObject* celprm_obj, struct prjprm* x, int* prefcount) } -static PyObject* PyPrjprm_copy(PyPrjprm* self) +static PyObject* Prjprm_copy(Prjprm* self) { - PyPrjprm* copy = NULL; - copy = PyPrjprm_cnew(self->owner, self->x, self->prefcount); + Prjprm* copy = NULL; + copy = Prjprm_cnew(self->owner, self->x, self->prefcount); if (copy == NULL) return NULL; return (PyObject*)copy; } -static PyObject* PyPrjprm_deepcopy(PyPrjprm* self) +static PyObject* Prjprm_deepcopy(Prjprm* self) { - PyPrjprm* copy = (PyPrjprm*) PyPrjprm_new((PyTypeObject*)PyPrjprmType, NULL, NULL); + Prjprm* copy = (Prjprm*) Prjprm_new((PyTypeObject*)PrjprmType, NULL, NULL); if (copy == NULL) return NULL; memcpy(copy->x, self->x, sizeof(struct prjprm)); @@ -164,7 +164,7 @@ static PyObject* PyPrjprm_deepcopy(PyPrjprm* self) } -static PyObject* PyPrjprm___str__(PyPrjprm* self) +static PyObject* Prjprm___str__(Prjprm* self) { wcsprintf_set(NULL); if (wcslib_prj_to_python_exc(prjprt(self->x))) { @@ -174,7 +174,7 @@ static PyObject* PyPrjprm___str__(PyPrjprm* self) } -static int PyPrjprm_cset(PyPrjprm* self) +static int Prjprm_cset(Prjprm* self) { if (wcslib_prj_to_python_exc(prjset(self->x))) { return -1; @@ -183,14 +183,14 @@ static int PyPrjprm_cset(PyPrjprm* self) } -static PyObject* PyPrjprm_set(PyPrjprm* self) +static PyObject* Prjprm_set(Prjprm* self) { - if (is_readonly(self) || PyPrjprm_cset(self)) return NULL; + if (is_readonly(self) || Prjprm_cset(self)) return NULL; Py_RETURN_NONE; } -static PyObject* _prj_eval(PyPrjprm* self, int (*prjfn)(PRJX2S_ARGS), +static PyObject* _prj_eval(Prjprm* self, int (*prjfn)(PRJX2S_ARGS), PyObject* x1_in, PyObject* x2_in) { Py_ssize_t i, ndim; @@ -287,7 +287,7 @@ static PyObject* _prj_eval(PyPrjprm* self, int (*prjfn)(PRJX2S_ARGS), } -static PyObject* PyPrjprm_prjx2s(PyPrjprm* self, PyObject* args, PyObject* kwds) +static PyObject* Prjprm_prjx2s(Prjprm* self, PyObject* args, PyObject* kwds) { PyObject* x = NULL; PyObject* y = NULL; @@ -307,7 +307,7 @@ static PyObject* PyPrjprm_prjx2s(PyPrjprm* self, PyObject* args, PyObject* kwds) "Attribute 'prj' of 'astropy.wcs.Wcsprm.cel' objects is " "read-only and cannot be automatically set."); return NULL; - } else if (PyPrjprm_cset(self)) { + } else if (Prjprm_cset(self)) { return NULL; } } @@ -316,7 +316,7 @@ static PyObject* PyPrjprm_prjx2s(PyPrjprm* self, PyObject* args, PyObject* kwds) } -static PyObject* PyPrjprm_prjs2x(PyPrjprm* self, PyObject* args, PyObject* kwds) +static PyObject* Prjprm_prjs2x(Prjprm* self, PyObject* args, PyObject* kwds) { PyObject* phi = NULL; PyObject* theta = NULL; @@ -336,7 +336,7 @@ static PyObject* PyPrjprm_prjs2x(PyPrjprm* self, PyObject* args, PyObject* kwds) "Attribute 'prj' of 'astropy.wcs.Wcsprm.cel' objects is " "read-only and cannot be automatically set."); return NULL; - } else if (PyPrjprm_cset(self)) { + } else if (Prjprm_cset(self)) { return NULL; } } @@ -349,7 +349,7 @@ static PyObject* PyPrjprm_prjs2x(PyPrjprm* self, PyObject* args, PyObject* kwds) * Member getters/setters (properties) */ -static PyObject* PyPrjprm_get_flag(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_flag(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -359,7 +359,7 @@ static PyObject* PyPrjprm_get_flag(PyPrjprm* self, void* closure) } -static PyObject* PyPrjprm_get_code(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_code(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -369,7 +369,7 @@ static PyObject* PyPrjprm_get_code(PyPrjprm* self, void* closure) } -static int PyPrjprm_set_code(PyPrjprm* self, PyObject* value, void* closure) +static int Prjprm_set_code(Prjprm* self, PyObject* value, void* closure) { char code[4]; int code_len; @@ -380,7 +380,7 @@ static int PyPrjprm_set_code(PyPrjprm* self, PyObject* value, void* closure) if (strcmp(" ", self->x->code)) { strcpy(self->x->code, " "); self->x->flag = 0; - if (self->owner) ((PyCelprm*)self->owner)->x->flag = 0; + if (self->owner) ((Celprm*)self->owner)->x->flag = 0; } } else { if (set_string("code", value, code, 4)) return -1; @@ -396,14 +396,14 @@ static int PyPrjprm_set_code(PyPrjprm* self, PyObject* value, void* closure) strncpy(self->x->code, code, 4); self->x->code[3] = '\0'; /* just to be safe */ self->x->flag = 0; - if (self->owner) ((PyCelprm*)self->owner)->x->flag = 0; + if (self->owner) ((Celprm*)self->owner)->x->flag = 0; } } return 0; } -static PyObject* PyPrjprm_get_r0(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_r0(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -415,7 +415,7 @@ static PyObject* PyPrjprm_get_r0(PyPrjprm* self, void* closure) } -static int PyPrjprm_set_r0(PyPrjprm* self, PyObject* value, void* closure) +static int Prjprm_set_r0(Prjprm* self, PyObject* value, void* closure) { int result; double r0; @@ -425,7 +425,7 @@ static int PyPrjprm_set_r0(PyPrjprm* self, PyObject* value, void* closure) if (self->x->r0 != UNDEFINED) { self->x->r0 = UNDEFINED; self->x->flag = 0; - if (self->owner) ((PyCelprm*)self->owner)->x->flag = 0; + if (self->owner) ((Celprm*)self->owner)->x->flag = 0; } } else { result = set_double("r0", value, &r0); @@ -433,14 +433,14 @@ static int PyPrjprm_set_r0(PyPrjprm* self, PyObject* value, void* closure) if (r0 != self->x->r0) { self->x->r0 = r0; self->x->flag = 0; - if (self->owner) ((PyCelprm*)self->owner)->x->flag = 0; + if (self->owner) ((Celprm*)self->owner)->x->flag = 0; } } return 0; } -static PyObject* PyPrjprm_get_phi0(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_phi0(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -452,7 +452,7 @@ static PyObject* PyPrjprm_get_phi0(PyPrjprm* self, void* closure) } -static int PyPrjprm_set_phi0(PyPrjprm* self, PyObject* value, void* closure) +static int Prjprm_set_phi0(Prjprm* self, PyObject* value, void* closure) { int result; double phi0; @@ -462,7 +462,7 @@ static int PyPrjprm_set_phi0(PyPrjprm* self, PyObject* value, void* closure) if (self->x->phi0 != UNDEFINED) { self->x->phi0 = UNDEFINED; self->x->flag = 0; - if (self->owner) ((PyCelprm*)self->owner)->x->flag = 0; + if (self->owner) ((Celprm*)self->owner)->x->flag = 0; } } else { result = set_double("phi0", value, &phi0); @@ -470,14 +470,14 @@ static int PyPrjprm_set_phi0(PyPrjprm* self, PyObject* value, void* closure) if (phi0 != self->x->phi0) { self->x->phi0 = phi0; self->x->flag = 0; - if (self->owner) ((PyCelprm*)self->owner)->x->flag = 0; + if (self->owner) ((Celprm*)self->owner)->x->flag = 0; } } return 0; } -static PyObject* PyPrjprm_get_theta0(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_theta0(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -489,7 +489,7 @@ static PyObject* PyPrjprm_get_theta0(PyPrjprm* self, void* closure) } -static int PyPrjprm_set_theta0(PyPrjprm* self, PyObject* value, void* closure) +static int Prjprm_set_theta0(Prjprm* self, PyObject* value, void* closure) { int result; double theta0; @@ -499,7 +499,7 @@ static int PyPrjprm_set_theta0(PyPrjprm* self, PyObject* value, void* closure) if (self->x->theta0 != UNDEFINED) { self->x->theta0 = UNDEFINED; self->x->flag = 0; - if (self->owner) ((PyCelprm*)self->owner)->x->flag = 0; + if (self->owner) ((Celprm*)self->owner)->x->flag = 0; } } else { result = set_double("theta0", value, &theta0); @@ -507,14 +507,14 @@ static int PyPrjprm_set_theta0(PyPrjprm* self, PyObject* value, void* closure) if (theta0 != self->x->theta0) { self->x->theta0 = theta0; self->x->flag = 0; - if (self->owner) ((PyCelprm*)self->owner)->x->flag = 0; + if (self->owner) ((Celprm*)self->owner)->x->flag = 0; } } return 0; } -static PyObject* PyPrjprm_get_pv(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_pv(Prjprm* self, void* closure) { int k; Py_ssize_t size = PVN; @@ -540,7 +540,7 @@ static PyObject* PyPrjprm_get_pv(PyPrjprm* self, void* closure) } -static int PyPrjprm_set_pv(PyPrjprm* self, PyObject* value, void* closure) +static int Prjprm_set_pv(Prjprm* self, PyObject* value, void* closure) { int k, modified; npy_intp size; @@ -556,7 +556,7 @@ static int PyPrjprm_set_pv(PyPrjprm* self, PyObject* value, void* closure) for (k = 1; k < 4; self->x->pv[k++] = UNDEFINED); for (k = 4; k < PVN; self->x->pv[k++] = 0.0); self->x->flag = 0; - if (self->owner) ((PyCelprm*)self->owner)->x->flag = 0; + if (self->owner) ((Celprm*)self->owner)->x->flag = 0; return 0; } @@ -612,13 +612,13 @@ static int PyPrjprm_set_pv(PyPrjprm* self, PyObject* value, void* closure) if (modified) { self->x->flag = 0; - if (self->owner) ((PyCelprm*)self->owner)->x->flag = 0; + if (self->owner) ((Celprm*)self->owner)->x->flag = 0; } return 0; } -static PyObject* PyPrjprm_get_pvi(PyPrjprm* self, PyObject* args, PyObject* kwds) +static PyObject* Prjprm_get_pvi(Prjprm* self, PyObject* args, PyObject* kwds) { int idx; PyObject* index = NULL; @@ -655,7 +655,7 @@ static PyObject* PyPrjprm_get_pvi(PyPrjprm* self, PyObject* args, PyObject* kwds } -static PyObject* PyPrjprm_set_pvi(PyPrjprm* self, PyObject* args, PyObject* kwds) +static PyObject* Prjprm_set_pvi(Prjprm* self, PyObject* args, PyObject* kwds) { int idx, size; double data; @@ -695,7 +695,7 @@ static PyObject* PyPrjprm_set_pvi(PyPrjprm* self, PyObject* args, PyObject* kwds /* If pv is set to None - reset pv to prjini values: */ self->x->pv[idx] = (idx > 0 && idx < 4) ? UNDEFINED : 0.0; self->x->flag = 0; - if (self->owner) ((PyCelprm*)self->owner)->x->flag = 0; + if (self->owner) ((Celprm*)self->owner)->x->flag = 0; Py_RETURN_NONE; } @@ -746,7 +746,7 @@ static PyObject* PyPrjprm_set_pvi(PyPrjprm* self, PyObject* args, PyObject* kwds if (!is_dbl_equal(self->x->pv[idx], data)) { self->x->flag = 0; - if (self->owner) ((PyCelprm*)self->owner)->x->flag = 0; + if (self->owner) ((Celprm*)self->owner)->x->flag = 0; } self->x->pv[idx] = data; @@ -754,7 +754,7 @@ static PyObject* PyPrjprm_set_pvi(PyPrjprm* self, PyObject* args, PyObject* kwds } -static PyObject* PyPrjprm_get_bounds(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_bounds(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -764,7 +764,7 @@ static PyObject* PyPrjprm_get_bounds(PyPrjprm* self, void* closure) } -static int PyPrjprm_set_bounds(PyPrjprm* self, PyObject* value, void* closure) +static int Prjprm_set_bounds(Prjprm* self, PyObject* value, void* closure) { if (is_prj_null(self) || is_readonly(self)) { return -1; @@ -777,7 +777,7 @@ static int PyPrjprm_set_bounds(PyPrjprm* self, PyObject* value, void* closure) } -static PyObject* PyPrjprm_get_w(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_w(Prjprm* self, void* closure) { Py_ssize_t size = 10; int k; @@ -802,7 +802,7 @@ static PyObject* PyPrjprm_get_w(PyPrjprm* self, void* closure) } -static PyObject* PyPrjprm_get_name(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_name(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -812,7 +812,7 @@ static PyObject* PyPrjprm_get_name(PyPrjprm* self, void* closure) } -static PyObject* PyPrjprm_get_category(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_category(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -822,7 +822,7 @@ static PyObject* PyPrjprm_get_category(PyPrjprm* self, void* closure) } -static PyObject* PyPrjprm_get_pvrange(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_pvrange(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -832,7 +832,7 @@ static PyObject* PyPrjprm_get_pvrange(PyPrjprm* self, void* closure) } -static PyObject* PyPrjprm_get_simplezen(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_simplezen(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -842,7 +842,7 @@ static PyObject* PyPrjprm_get_simplezen(PyPrjprm* self, void* closure) } -static PyObject* PyPrjprm_get_equiareal(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_equiareal(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -852,7 +852,7 @@ static PyObject* PyPrjprm_get_equiareal(PyPrjprm* self, void* closure) } -static PyObject* PyPrjprm_get_conformal(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_conformal(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -862,7 +862,7 @@ static PyObject* PyPrjprm_get_conformal(PyPrjprm* self, void* closure) } -static PyObject* PyPrjprm_get_global_projection(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_global_projection(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -872,7 +872,7 @@ static PyObject* PyPrjprm_get_global_projection(PyPrjprm* self, void* closure) } -static PyObject* PyPrjprm_get_divergent(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_divergent(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -882,7 +882,7 @@ static PyObject* PyPrjprm_get_divergent(PyPrjprm* self, void* closure) } -static PyObject* PyPrjprm_get_x0(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_x0(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -892,7 +892,7 @@ static PyObject* PyPrjprm_get_x0(PyPrjprm* self, void* closure) } -static PyObject* PyPrjprm_get_y0(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_y0(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -902,7 +902,7 @@ static PyObject* PyPrjprm_get_y0(PyPrjprm* self, void* closure) } -static PyObject* PyPrjprm_get_m(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_m(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -912,7 +912,7 @@ static PyObject* PyPrjprm_get_m(PyPrjprm* self, void* closure) } -static PyObject* PyPrjprm_get_n(PyPrjprm* self, void* closure) +static PyObject* Prjprm_get_n(Prjprm* self, void* closure) { if (is_prj_null(self)) { return NULL; @@ -923,70 +923,70 @@ static PyObject* PyPrjprm_get_n(PyPrjprm* self, void* closure) /*************************************************************************** - * PyPrjprm definition structures + * Prjprm definition structures */ -static PyGetSetDef PyPrjprm_getset[] = { - {"r0", (getter)PyPrjprm_get_r0, (setter)PyPrjprm_set_r0, (char *)doc_prjprm_r0}, - {"phi0", (getter)PyPrjprm_get_phi0, (setter)PyPrjprm_set_phi0, (char *)doc_prjprm_phi0}, - {"theta0", (getter)PyPrjprm_get_theta0, (setter)PyPrjprm_set_theta0, (char *)doc_prjprm_theta0}, - {"pv", (getter)PyPrjprm_get_pv, (setter)PyPrjprm_set_pv, (char *)doc_prjprm_pv}, - {"w", (getter)PyPrjprm_get_w, NULL, (char *)doc_prjprm_w}, - {"name", (getter)PyPrjprm_get_name, NULL, (char *)doc_prjprm_name}, - {"code", (getter)PyPrjprm_get_code, (setter)PyPrjprm_set_code, (char *)doc_prjprm_code}, - {"bounds", (getter)PyPrjprm_get_bounds, (setter)PyPrjprm_set_bounds, (char *)doc_prjprm_bounds}, - {"category", (getter)PyPrjprm_get_category, NULL, (char *)doc_prjprm_category}, - {"pvrange", (getter)PyPrjprm_get_pvrange, NULL, (char *)doc_prjprm_pvrange}, - {"simplezen", (getter)PyPrjprm_get_simplezen, NULL, (char *)doc_prjprm_simplezen}, - {"equiareal", (getter)PyPrjprm_get_equiareal, NULL, (char *)doc_prjprm_equiareal}, - {"conformal", (getter)PyPrjprm_get_conformal, NULL, (char *)doc_prjprm_conformal}, - {"global_projection", (getter)PyPrjprm_get_global_projection, NULL, (char *)doc_prjprm_global_projection}, - {"divergent", (getter)PyPrjprm_get_divergent, NULL, (char *)doc_prjprm_divergent}, - {"x0", (getter)PyPrjprm_get_x0, NULL, (char *)doc_prjprm_x0}, - {"y0", (getter)PyPrjprm_get_y0, NULL, (char *)doc_prjprm_y0}, - {"m", (getter)PyPrjprm_get_m, NULL, (char *)doc_prjprm_m}, - {"n", (getter)PyPrjprm_get_n, NULL, (char *)doc_prjprm_n}, - {"_flag", (getter)PyPrjprm_get_flag, NULL, ""}, +static PyGetSetDef Prjprm_getset[] = { + {"r0", (getter)Prjprm_get_r0, (setter)Prjprm_set_r0, (char *)doc_prjprm_r0}, + {"phi0", (getter)Prjprm_get_phi0, (setter)Prjprm_set_phi0, (char *)doc_prjprm_phi0}, + {"theta0", (getter)Prjprm_get_theta0, (setter)Prjprm_set_theta0, (char *)doc_prjprm_theta0}, + {"pv", (getter)Prjprm_get_pv, (setter)Prjprm_set_pv, (char *)doc_prjprm_pv}, + {"w", (getter)Prjprm_get_w, NULL, (char *)doc_prjprm_w}, + {"name", (getter)Prjprm_get_name, NULL, (char *)doc_prjprm_name}, + {"code", (getter)Prjprm_get_code, (setter)Prjprm_set_code, (char *)doc_prjprm_code}, + {"bounds", (getter)Prjprm_get_bounds, (setter)Prjprm_set_bounds, (char *)doc_prjprm_bounds}, + {"category", (getter)Prjprm_get_category, NULL, (char *)doc_prjprm_category}, + {"pvrange", (getter)Prjprm_get_pvrange, NULL, (char *)doc_prjprm_pvrange}, + {"simplezen", (getter)Prjprm_get_simplezen, NULL, (char *)doc_prjprm_simplezen}, + {"equiareal", (getter)Prjprm_get_equiareal, NULL, (char *)doc_prjprm_equiareal}, + {"conformal", (getter)Prjprm_get_conformal, NULL, (char *)doc_prjprm_conformal}, + {"global_projection", (getter)Prjprm_get_global_projection, NULL, (char *)doc_prjprm_global_projection}, + {"divergent", (getter)Prjprm_get_divergent, NULL, (char *)doc_prjprm_divergent}, + {"x0", (getter)Prjprm_get_x0, NULL, (char *)doc_prjprm_x0}, + {"y0", (getter)Prjprm_get_y0, NULL, (char *)doc_prjprm_y0}, + {"m", (getter)Prjprm_get_m, NULL, (char *)doc_prjprm_m}, + {"n", (getter)Prjprm_get_n, NULL, (char *)doc_prjprm_n}, + {"_flag", (getter)Prjprm_get_flag, NULL, ""}, {NULL} }; -static PyMethodDef PyPrjprm_methods[] = { - {"set", (PyCFunction)PyPrjprm_set, METH_NOARGS, (char*)doc_prjprm_set}, - {"prjx2s", (PyCFunction)PyPrjprm_prjx2s, METH_VARARGS|METH_KEYWORDS, (char*)doc_prjprm_prjx2s}, - {"prjs2x", (PyCFunction)PyPrjprm_prjs2x, METH_VARARGS|METH_KEYWORDS, (char*)doc_prjprm_prjs2x}, - {"set_pvi", (PyCFunction)PyPrjprm_set_pvi, METH_VARARGS|METH_KEYWORDS, (char*)doc_prjprm_pvi}, - {"get_pvi", (PyCFunction)PyPrjprm_get_pvi, METH_VARARGS|METH_KEYWORDS, (char*)doc_prjprm_pvi}, - {"__copy__", (PyCFunction)PyPrjprm_copy, METH_NOARGS, ""}, - {"__deepcopy__", (PyCFunction)PyPrjprm_deepcopy, METH_O, ""}, +static PyMethodDef Prjprm_methods[] = { + {"set", (PyCFunction)Prjprm_set, METH_NOARGS, (char*)doc_prjprm_set}, + {"prjx2s", (PyCFunction)Prjprm_prjx2s, METH_VARARGS|METH_KEYWORDS, (char*)doc_prjprm_prjx2s}, + {"prjs2x", (PyCFunction)Prjprm_prjs2x, METH_VARARGS|METH_KEYWORDS, (char*)doc_prjprm_prjs2x}, + {"set_pvi", (PyCFunction)Prjprm_set_pvi, METH_VARARGS|METH_KEYWORDS, (char*)doc_prjprm_pvi}, + {"get_pvi", (PyCFunction)Prjprm_get_pvi, METH_VARARGS|METH_KEYWORDS, (char*)doc_prjprm_pvi}, + {"__copy__", (PyCFunction)Prjprm_copy, METH_NOARGS, ""}, + {"__deepcopy__", (PyCFunction)Prjprm_deepcopy, METH_O, ""}, {NULL} }; -static PyType_Spec PyPrjprm_spec = { +static PyType_Spec Prjprm_spec = { .name = "astropy.wcs.Prjprm", - .basicsize = sizeof(PyPrjprm), + .basicsize = sizeof(Prjprm), .itemsize = 0, .flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_IMMUTABLETYPE, .slots = (PyType_Slot[]) { - {Py_tp_dealloc, (destructor)PyPrjprm_dealloc}, - {Py_tp_str, (reprfunc)PyPrjprm___str__}, + {Py_tp_dealloc, (destructor)Prjprm_dealloc}, + {Py_tp_str, (reprfunc)Prjprm___str__}, {Py_tp_doc, doc_Prjprm}, - {Py_tp_traverse, (traverseproc)PyPrjprm_traverse}, - {Py_tp_clear, (inquiry)PyPrjprm_clear}, - {Py_tp_methods, PyPrjprm_methods}, - {Py_tp_getset, PyPrjprm_getset}, - {Py_tp_new, PyPrjprm_new}, + {Py_tp_traverse, (traverseproc)Prjprm_traverse}, + {Py_tp_clear, (inquiry)Prjprm_clear}, + {Py_tp_methods, Prjprm_methods}, + {Py_tp_getset, Prjprm_getset}, + {Py_tp_new, Prjprm_new}, {0, NULL}, }, }; -PyObject* PyPrjprmType = NULL; +PyObject* PrjprmType = NULL; int _setup_prjprm_type(PyObject* m) { - PyPrjprmType = PyType_FromSpec(&PyPrjprm_spec); - if (PyPrjprmType == NULL) return -1; - PyModule_AddObject(m, "Prjprm", PyPrjprmType); + PrjprmType = PyType_FromSpec(&Prjprm_spec); + if (PrjprmType == NULL) return -1; + PyModule_AddObject(m, "Prjprm", PrjprmType); prj_errexc[0] = NULL; /* Success */ prj_errexc[1] = &PyExc_MemoryError; /* Null prjprm pointer passed */ diff --git a/astropy/wcs/src/wcslib_tabprm_wrap.c b/astropy/wcs/src/wcslib_tabprm_wrap.c index 4df49cda7414..d57b8b69d8e8 100755 --- a/astropy/wcs/src/wcslib_tabprm_wrap.c +++ b/astropy/wcs/src/wcslib_tabprm_wrap.c @@ -24,12 +24,12 @@ ***************************************************************************/ static INLINE void -note_change(PyTabprm* self) { +note_change(Tabprm* self) { self->x->flag = 0; } static int -make_fancy_dims(PyTabprm* self, int* ndims, npy_intp* dims) { +make_fancy_dims(Tabprm* self, int* ndims, npy_intp* dims) { int i, M; M = self->x->M; @@ -63,20 +63,20 @@ wcslib_tab_to_python_exc(int status) { } /*************************************************************************** - * PyTabprm methods + * Tabprm methods */ static int -PyTabprm_traverse( - PyTabprm* self, visitproc visit, void *arg) { +Tabprm_traverse( + Tabprm* self, visitproc visit, void *arg) { Py_VISIT(self->owner); Py_VISIT(Py_TYPE((PyObject*)self)); return 0; } static int -PyTabprm_clear( - PyTabprm* self) { +Tabprm_clear( + Tabprm* self) { Py_CLEAR(self->owner); @@ -84,22 +84,22 @@ PyTabprm_clear( } static void -PyTabprm_dealloc( - PyTabprm* self) { +Tabprm_dealloc( + Tabprm* self) { - PyTabprm_clear(self); + Tabprm_clear(self); PyTypeObject *tp = Py_TYPE((PyObject*)self); freefunc free_func = PyType_GetSlot(tp, Py_tp_free); free_func((PyObject*)self); Py_DECREF(tp); } -PyTabprm* -PyTabprm_cnew(PyObject* wcsprm, struct tabprm* x) { - PyTabprm* self; - PyTypeObject* type = (PyTypeObject*)PyTabprmType; +Tabprm* +Tabprm_cnew(PyObject* wcsprm, struct tabprm* x) { + Tabprm* self; + PyTypeObject* type = (PyTypeObject*)TabprmType; allocfunc alloc_func = PyType_GetSlot(type, Py_tp_alloc); - self = (PyTabprm*)alloc_func(type, 0); + self = (Tabprm*)alloc_func(type, 0); if (self == NULL) return NULL; self->x = x; Py_INCREF(wcsprm); @@ -108,8 +108,8 @@ PyTabprm_cnew(PyObject* wcsprm, struct tabprm* x) { } static int -PyTabprm_cset( - PyTabprm* self) { +Tabprm_cset( + Tabprm* self) { int status = 0; @@ -124,10 +124,10 @@ PyTabprm_cset( } /*@null@*/ static PyObject* -PyTabprm_set( - PyTabprm* self) { +Tabprm_set( + Tabprm* self) { - if (PyTabprm_cset(self)) { + if (Tabprm_cset(self)) { return NULL; } @@ -135,10 +135,10 @@ PyTabprm_set( } /*@null@*/ static PyObject* -PyTabprm_print_contents( - PyTabprm* self) { +Tabprm_print_contents( + Tabprm* self) { - if (PyTabprm_cset(self)) { + if (Tabprm_cset(self)) { return NULL; } @@ -152,10 +152,10 @@ PyTabprm_print_contents( } /*@null@*/ static PyObject* -PyTabprm___str__( - PyTabprm* self) { +Tabprm___str__( + Tabprm* self) { - if (PyTabprm_cset(self)) { + if (Tabprm_cset(self)) { return NULL; } @@ -173,8 +173,8 @@ PyTabprm___str__( */ /*@null@*/ static PyObject* -PyTabprm_get_coord( - PyTabprm* self, +Tabprm_get_coord( + Tabprm* self, /*@unused@*/ void* closure) { int ndims; @@ -192,8 +192,8 @@ PyTabprm_get_coord( } /*@null@*/ static int -PyTabprm_set_coord( - PyTabprm* self, +Tabprm_set_coord( + Tabprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -212,8 +212,8 @@ PyTabprm_set_coord( } /*@null@*/ static PyObject* -PyTabprm_get_crval( - PyTabprm* self, +Tabprm_get_crval( + Tabprm* self, /*@unused@*/ void* closure) { Py_ssize_t M = 0; @@ -228,8 +228,8 @@ PyTabprm_get_crval( } static int -PyTabprm_set_crval( - PyTabprm* self, +Tabprm_set_crval( + Tabprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -247,8 +247,8 @@ PyTabprm_set_crval( } /*@null@*/ static PyObject* -PyTabprm_get_delta( - PyTabprm* self, +Tabprm_get_delta( + Tabprm* self, /*@unused@*/ void* closure) { Py_ssize_t M = 0; @@ -263,8 +263,8 @@ PyTabprm_get_delta( } /*@null@*/ static PyObject* -PyTabprm_get_extrema( - PyTabprm* self, +Tabprm_get_extrema( + Tabprm* self, /*@unused@*/ void* closure) { int ndims; @@ -284,8 +284,8 @@ PyTabprm_get_extrema( } /*@null@*/ static PyObject* -PyTabprm_get_K( - PyTabprm* self, +Tabprm_get_K( + Tabprm* self, /*@unused@*/ void* closure) { Py_ssize_t M = 0; @@ -300,16 +300,16 @@ PyTabprm_get_K( } /*@null@*/ static PyObject* -PyTabprm_get_M( - PyTabprm* self, +Tabprm_get_M( + Tabprm* self, /*@unused@*/ void* closure) { return get_int("M", self->x->M); } /*@null@*/ static PyObject* -PyTabprm_get_map( - PyTabprm* self, +Tabprm_get_map( + Tabprm* self, /*@unused@*/ void* closure) { Py_ssize_t M = 0; @@ -324,8 +324,8 @@ PyTabprm_get_map( } static int -PyTabprm_set_map( - PyTabprm* self, +Tabprm_set_map( + Tabprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -343,16 +343,16 @@ PyTabprm_set_map( } /*@null@*/ static PyObject* -PyTabprm_get_nc( - PyTabprm* self, +Tabprm_get_nc( + Tabprm* self, /*@unused@*/ void* closure) { return get_int("nc", self->x->nc); } /*@null@*/ static PyObject* -PyTabprm_get_p0( - PyTabprm* self, +Tabprm_get_p0( + Tabprm* self, /*@unused@*/ void* closure) { Py_ssize_t M = 0; @@ -367,8 +367,8 @@ PyTabprm_get_p0( } /*@null@*/ static PyObject* -PyTabprm_get_sense( - PyTabprm* self, +Tabprm_get_sense( + Tabprm* self, /*@unused@*/ void* closure) { Py_ssize_t M = 0; @@ -383,58 +383,58 @@ PyTabprm_get_sense( } /*************************************************************************** - * PyTabprm definition structures + * Tabprm definition structures */ -static PyGetSetDef PyTabprm_getset[] = { - {"coord", (getter)PyTabprm_get_coord, (setter)PyTabprm_set_coord, (char *)doc_coord}, - {"crval", (getter)PyTabprm_get_crval, (setter)PyTabprm_set_crval, (char *)doc_crval_tabprm}, - {"delta", (getter)PyTabprm_get_delta, NULL, (char *)doc_delta}, - {"extrema", (getter)PyTabprm_get_extrema, NULL, (char *)doc_extrema}, - {"K", (getter)PyTabprm_get_K, NULL, (char *)doc_K}, - {"M", (getter)PyTabprm_get_M, NULL, (char *)doc_M}, - {"map", (getter)PyTabprm_get_map, (setter)PyTabprm_set_map, (char *)doc_map}, - {"nc", (getter)PyTabprm_get_nc, NULL, (char *)doc_nc}, - {"p0", (getter)PyTabprm_get_p0, NULL, (char *)doc_p0}, - {"sense", (getter)PyTabprm_get_sense, NULL, (char *)doc_sense}, +static PyGetSetDef Tabprm_getset[] = { + {"coord", (getter)Tabprm_get_coord, (setter)Tabprm_set_coord, (char *)doc_coord}, + {"crval", (getter)Tabprm_get_crval, (setter)Tabprm_set_crval, (char *)doc_crval_tabprm}, + {"delta", (getter)Tabprm_get_delta, NULL, (char *)doc_delta}, + {"extrema", (getter)Tabprm_get_extrema, NULL, (char *)doc_extrema}, + {"K", (getter)Tabprm_get_K, NULL, (char *)doc_K}, + {"M", (getter)Tabprm_get_M, NULL, (char *)doc_M}, + {"map", (getter)Tabprm_get_map, (setter)Tabprm_set_map, (char *)doc_map}, + {"nc", (getter)Tabprm_get_nc, NULL, (char *)doc_nc}, + {"p0", (getter)Tabprm_get_p0, NULL, (char *)doc_p0}, + {"sense", (getter)Tabprm_get_sense, NULL, (char *)doc_sense}, {NULL} }; -static PyMethodDef PyTabprm_methods[] = { - {"print_contents", (PyCFunction)PyTabprm_print_contents, METH_NOARGS, doc_print_contents_tabprm}, - {"set", (PyCFunction)PyTabprm_set, METH_NOARGS, doc_set_tabprm}, +static PyMethodDef Tabprm_methods[] = { + {"print_contents", (PyCFunction)Tabprm_print_contents, METH_NOARGS, doc_print_contents_tabprm}, + {"set", (PyCFunction)Tabprm_set, METH_NOARGS, doc_set_tabprm}, {NULL} }; -static PyType_Spec PyTabprmType_spec = { +static PyType_Spec TabprmType_spec = { .name = "astropy.wcs.Tabprm", - .basicsize = sizeof(PyTabprm), + .basicsize = sizeof(Tabprm), .itemsize = 0, .flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_IMMUTABLETYPE, .slots = (PyType_Slot[]) { - {Py_tp_dealloc, (destructor)PyTabprm_dealloc}, - {Py_tp_str, (reprfunc)PyTabprm___str__}, + {Py_tp_dealloc, (destructor)Tabprm_dealloc}, + {Py_tp_str, (reprfunc)Tabprm___str__}, {Py_tp_doc, doc_Tabprm}, - {Py_tp_traverse, (traverseproc)PyTabprm_traverse}, - {Py_tp_clear, (inquiry)PyTabprm_clear}, - {Py_tp_getset, PyTabprm_getset}, - {Py_tp_methods, PyTabprm_methods}, + {Py_tp_traverse, (traverseproc)Tabprm_traverse}, + {Py_tp_clear, (inquiry)Tabprm_clear}, + {Py_tp_getset, Tabprm_getset}, + {Py_tp_methods, Tabprm_methods}, {0, NULL}, }, }; -PyObject* PyTabprmType = NULL; +PyObject* TabprmType = NULL; int _setup_tabprm_type( PyObject* m) { - PyTabprmType = PyType_FromSpec(&PyTabprmType_spec); - if (PyTabprmType == NULL) { + TabprmType = PyType_FromSpec(&TabprmType_spec); + if (TabprmType == NULL) { return -1; } - PyModule_AddObject(m, "Tabprm", PyTabprmType); + PyModule_AddObject(m, "Tabprm", TabprmType); tab_errexc[0] = NULL; /* Success */ tab_errexc[1] = &PyExc_MemoryError; /* Null wcsprm pointer passed */ diff --git a/astropy/wcs/src/wcslib_wrap.c b/astropy/wcs/src/wcslib_wrap.c index b0c8a8027266..d10aa07c4ca2 100755 --- a/astropy/wcs/src/wcslib_wrap.c +++ b/astropy/wcs/src/wcslib_wrap.c @@ -351,20 +351,20 @@ int _update_wtbarr_from_hdulist(PyObject *hdulist, struct wtbarr *wtb) { /*************************************************************************** - * PyWcsprm methods + * Wcsprm methods */ int -PyWcsprm_cset(PyWcsprm* self, const int convert); +Wcsprm_cset(Wcsprm* self, const int convert); static INLINE void -note_change(PyWcsprm* self) { +note_change(Wcsprm* self) { self->x.flag = 0; } static void -PyWcsprm_dealloc( - PyWcsprm* self) { +Wcsprm_dealloc( + Wcsprm* self) { wcsfree(&self->x); PyTypeObject *tp = Py_TYPE((PyObject*)self); @@ -373,30 +373,30 @@ PyWcsprm_dealloc( Py_DECREF(tp); } -static PyWcsprm* -PyWcsprm_cnew(void) { - PyWcsprm* self; - PyTypeObject* type = (PyTypeObject*)PyWcsprmType; +static Wcsprm* +Wcsprm_cnew(void) { + Wcsprm* self; + PyTypeObject* type = (PyTypeObject*)WcsprmType; allocfunc alloc_func = PyType_GetSlot(type, Py_tp_alloc); - self = (PyWcsprm*)alloc_func(type, 0); + self = (Wcsprm*)alloc_func(type, 0); return self; } static PyObject * -PyWcsprm_new( +Wcsprm_new( PyTypeObject* type, /*@unused@*/ PyObject* args, /*@unused@*/ PyObject* kwds) { - PyWcsprm* self; + Wcsprm* self; allocfunc alloc_func = PyType_GetSlot(type, Py_tp_alloc); - self = (PyWcsprm*)alloc_func(type, 0); + self = (Wcsprm*)alloc_func(type, 0); return (PyObject*)self; } static int -PyWcsprm_init( - PyWcsprm* self, +Wcsprm_init( + Wcsprm* self, PyObject* args, PyObject* kwds) { @@ -474,7 +474,7 @@ PyWcsprm_init( self->x.alt[0] = key[0]; - if (PyWcsprm_cset(self, 0)) { + if (Wcsprm_cset(self, 0)) { return -1; } wcsprm_c2python(&self->x); @@ -664,8 +664,8 @@ PyWcsprm_init( } /*@null@*/ static PyObject* -PyWcsprm_bounds_check( - PyWcsprm* self, +Wcsprm_bounds_check( + Wcsprm* self, PyObject* args, PyObject* kwds) { @@ -696,13 +696,13 @@ PyWcsprm_bounds_check( /*@null@*/ static PyObject* -PyWcsprm_copy( - PyWcsprm* self) { +Wcsprm_copy( + Wcsprm* self) { - PyWcsprm* copy = NULL; + Wcsprm* copy = NULL; int status; - copy = PyWcsprm_cnew(); + copy = Wcsprm_cnew(); if (copy == NULL) { return NULL; } @@ -741,7 +741,7 @@ PyWcsprm_copy( if (status == 0) { - if (PyWcsprm_cset(copy, 0)) { + if (Wcsprm_cset(copy, 0)) { Py_XDECREF((PyObject*)copy); return NULL; } @@ -755,16 +755,16 @@ PyWcsprm_copy( } } -static PyWcsprm* PyWcsprm_copy_with_patched_units(PyWcsprm* source) { +static Wcsprm* Wcsprm_copy_with_patched_units(Wcsprm* source) { - // This function returns a copy of a PyWcsprm with units patched + // This function returns a copy of a Wcsprm with units patched // to match the original units (before WCSLIB changed them). The // returned object should not be used to do any kind of transformations // and is only for use in e.g. converting to a header, or printing // contents. int original_flag; - PyWcsprm* copy = (PyWcsprm*)PyWcsprm_copy(source); + Wcsprm* copy = (Wcsprm*)Wcsprm_copy(source); // We make sure wcsset is happy wcsset(©->x); @@ -789,7 +789,7 @@ static PyWcsprm* PyWcsprm_copy_with_patched_units(PyWcsprm* source) { } PyObject* -PyWcsprm_find_all_wcs( +Wcsprm_find_all_wcs( PyObject* __, PyObject* args, PyObject* kwds) { @@ -806,7 +806,7 @@ PyWcsprm_find_all_wcs( int nwcs = 0; struct wcsprm* wcs = NULL; PyObject* result = NULL; - PyWcsprm* subresult = NULL; + Wcsprm* subresult = NULL; int i = 0; const char* keywords[] = {"header", "relax", "keysel", "warnings", NULL}; int status = -1; @@ -919,7 +919,7 @@ PyWcsprm_find_all_wcs( } for (i = 0; i < nwcs; ++i) { - subresult = PyWcsprm_cnew(); + subresult = Wcsprm_cnew(); if (wcscopy(1, wcs + i, &subresult->x) != 0) { Py_DECREF(result); wcsvfree(&nwcs, &wcs); @@ -930,7 +930,6 @@ PyWcsprm_find_all_wcs( } if (PyList_SetItem(result, i, (PyObject *)subresult) == -1) { - Py_DECREF(subresult); Py_DECREF(result); wcsvfree(&nwcs, &wcs); return NULL; @@ -945,8 +944,8 @@ PyWcsprm_find_all_wcs( } static PyObject* -PyWcsprm_cdfix( - PyWcsprm* self) { +Wcsprm_cdfix( + Wcsprm* self) { int status = 0; @@ -963,8 +962,8 @@ PyWcsprm_cdfix( } static PyObject* -PyWcsprm_celfix( - PyWcsprm* self) { +Wcsprm_celfix( + Wcsprm* self) { int status = 0; @@ -981,13 +980,13 @@ PyWcsprm_celfix( } static PyObject * -PyWcsprm_compare( - PyWcsprm* self, +Wcsprm_compare( + Wcsprm* self, PyObject* args, PyObject* kwds) { int cmp = 0; - PyWcsprm *other; + Wcsprm *other; double tolerance = 0.0; int equal; int status; @@ -996,7 +995,7 @@ PyWcsprm_compare( if (!PyArg_ParseTupleAndKeywords( args, kwds, "O!|id:compare", (char **)keywords, - (PyTypeObject*)PyWcsprmType, &other, &cmp, &tolerance)) { + (PyTypeObject*)WcsprmType, &other, &cmp, &tolerance)) { return NULL; } @@ -1020,8 +1019,8 @@ PyWcsprm_compare( } /*@null@*/ static PyObject* -PyWcsprm_cylfix( - PyWcsprm* self, +Wcsprm_cylfix( + Wcsprm* self, PyObject* args, PyObject* kwds) { @@ -1070,8 +1069,8 @@ PyWcsprm_cylfix( } static PyObject* -PyWcsprm_datfix( - PyWcsprm* self) { +Wcsprm_datfix( + Wcsprm* self) { int status = 0; @@ -1087,7 +1086,7 @@ PyWcsprm_datfix( } } -int initialize_preserve_units(PyWcsprm* self) { +int initialize_preserve_units(Wcsprm* self) { // If the user has requested to preserve units, we keep track of what CUNIT // was before and after fixing so that we can store the scaling factor if @@ -1111,7 +1110,7 @@ int initialize_preserve_units(PyWcsprm* self) { } -int check_unit_changes(PyWcsprm* self) { +int check_unit_changes(Wcsprm* self) { double scale, offset, power; int status; @@ -1171,8 +1170,8 @@ int check_unit_changes(PyWcsprm* self) { /*@null@*/ static PyObject* -PyWcsprm_fix( - PyWcsprm* self, +Wcsprm_fix( + Wcsprm* self, PyObject* args, PyObject* kwds) { @@ -1279,8 +1278,8 @@ PyWcsprm_fix( } /*@null@*/ static PyObject* -PyWcsprm_get_cdelt_func( - PyWcsprm* self, +Wcsprm_get_cdelt_func( + Wcsprm* self, /*@unused@*/ PyObject* args, /*@unused@*/ PyObject* kwds) { @@ -1291,7 +1290,7 @@ PyWcsprm_get_cdelt_func( return NULL; } - if (PyWcsprm_cset(self, 1)) { + if (Wcsprm_cset(self, 1)) { return NULL; } @@ -1307,8 +1306,8 @@ PyWcsprm_get_cdelt_func( } /*@null@*/ static PyObject* -PyWcsprm_get_pc_func( - PyWcsprm* self, +Wcsprm_get_pc_func( + Wcsprm* self, /*@unused@*/ PyObject* args, /*@unused@*/ PyObject* kwds) { @@ -1318,7 +1317,7 @@ PyWcsprm_get_pc_func( return NULL; } - if (PyWcsprm_cset(self, 1)) { + if (Wcsprm_cset(self, 1)) { return NULL; } @@ -1329,8 +1328,8 @@ PyWcsprm_get_pc_func( } /*@null@*/ static PyObject* -PyWcsprm_get_ps( - PyWcsprm* self, +Wcsprm_get_ps( + Wcsprm* self, /*@unused@*/ PyObject* args, /*@unused@*/ PyObject* kwds) { @@ -1338,8 +1337,8 @@ PyWcsprm_get_ps( } /*@null@*/ static PyObject* -PyWcsprm_get_pv( - PyWcsprm* self, +Wcsprm_get_pv( + Wcsprm* self, /*@unused@*/ PyObject* args, /*@unused@*/ PyObject* kwds) { @@ -1347,8 +1346,8 @@ PyWcsprm_get_pv( } static PyObject* -PyWcsprm_has_cdi_ja( - PyWcsprm* self) { +Wcsprm_has_cdi_ja( + Wcsprm* self) { int result = 0; @@ -1358,8 +1357,8 @@ PyWcsprm_has_cdi_ja( } static PyObject* -PyWcsprm_has_crotaia( - PyWcsprm* self) { +Wcsprm_has_crotaia( + Wcsprm* self) { int result = 0; @@ -1369,8 +1368,8 @@ PyWcsprm_has_crotaia( } static PyObject* -PyWcsprm_has_pci_ja( - PyWcsprm* self) { +Wcsprm_has_pci_ja( + Wcsprm* self) { int result = 0; @@ -1380,10 +1379,10 @@ PyWcsprm_has_pci_ja( } static PyObject* -PyWcsprm_is_unity( - PyWcsprm* self) { +Wcsprm_is_unity( + Wcsprm* self) { - if (PyWcsprm_cset(self, 1)) { + if (Wcsprm_cset(self, 1)) { return NULL; } @@ -1391,8 +1390,8 @@ PyWcsprm_is_unity( } /*@null@*/ static PyObject* -PyWcsprm_mix( - PyWcsprm* self, +Wcsprm_mix( + Wcsprm* self, PyObject* args, PyObject* kwds) { @@ -1501,10 +1500,18 @@ PyWcsprm_mix( goto exit; } - /* Convert pixel coordinates to 1-based */ + /* Force a call to wcsset via Wcsprm_cset before entering the parallel + * region (see the matching note in Wcs_all_pix2world). This ensures + * wcs->flag == WCSSET so that wcsmix below does not invoke wcsset + * itself, allowing the wcsprm_python2c / wcsprm_c2python round-trip + * to be dropped. */ + if (Wcsprm_cset(self, 1)) { + goto exit; + } + + /* Convert pixel coordinates to 1-based. */ Py_BEGIN_ALLOW_THREADS preoffset_array(pixcrd, origin); - wcsprm_python2c(&self->x); status = wcsmix( &self->x, mixpix, @@ -1517,7 +1524,6 @@ PyWcsprm_mix( (double*)PyArray_DATA(theta), (double*)PyArray_DATA(imgcrd), (double*)PyArray_DATA(pixcrd)); - wcsprm_c2python(&self->x); unoffset_array(pixcrd, origin); unoffset_array(imgcrd, origin); Py_END_ALLOW_THREADS @@ -1555,8 +1561,8 @@ PyWcsprm_mix( } /*@null@*/ static PyObject* -PyWcsprm_p2s( - PyWcsprm* self, +Wcsprm_p2s( + Wcsprm* self, PyObject* args, PyObject* kwds) { @@ -1633,18 +1639,29 @@ PyWcsprm_p2s( goto exit; } - // Here we force a call to wcsset. Normally, WCSLIB will call wcsset automatically when - // calling wcsp2s, but we need to call it ourselves using PyWcsprm_cset so that we can - // catch cases where the units might change if e.g. they are not in SI to start with. - /* Force a call to wcsset here*/ - if (self->preserve_units && PyWcsprm_cset(self, 1)) { + // Force a call to wcsset via Wcsprm_cset before entering the parallel + // region (see the matching note in Wcs_all_pix2world). This ensures + // wcs->flag == WCSSET so that wcsp2s below does not invoke wcsset + // itself -- with wcsset moved out of the parallel region, wcsp2s is + // read-only on the wcsprm struct, and the wcsprm_python2c / + // wcsprm_c2python round-trip can be dropped. + if (Wcsprm_cset(self, 1)) { return NULL; } - /* Make the call */ + /* Make the call. + * + * After wcsset has run (now hoisted into Wcsprm_cset above), wcsp2s is + * read-only on the wcsprm struct: it consumes the precomputed sub-structs + * wcs->lin / wcs->cel / wcs->spc plus wcs->crval[i], and never re-reads + * the raw arrays (cd, cdelt, crpix, crota, obsgeo, mjdobs, ...) that the + * wcsprm_python2c / wcsprm_c2python pair was rewriting NaN <-> UNDEFINED + * in place. The round-trip can therefore be dropped here without + * changing the transform's output, and concurrent threads no longer + * race on those raw arrays. + */ Py_BEGIN_ALLOW_THREADS preoffset_array(pixcrd, origin); - wcsprm_python2c(&self->x); status = wcsp2s( &self->x, ncoord, @@ -1655,7 +1672,6 @@ PyWcsprm_p2s( (double*)PyArray_DATA(theta), (double*)PyArray_DATA(world), (int*)PyArray_DATA(stat)); - wcsprm_c2python(&self->x); unoffset_array(pixcrd, origin); /* unoffset_array(world, origin); */ unoffset_array(imgcrd, origin); @@ -1716,8 +1732,8 @@ PyWcsprm_p2s( } /*@null@*/ static PyObject* -PyWcsprm_s2p( - PyWcsprm* self, +Wcsprm_s2p( + Wcsprm* self, PyObject* args, PyObject* kwds) { @@ -1763,12 +1779,13 @@ PyWcsprm_s2p( goto exit; } - // Here we force a call to wcsset. Normally, WCSLIB will call wcsset automatically when - // calling wcsp2s, but we need to call it ourselves using PyWcsprm_cset so that we can - // catch cases where the units might change if e.g. they are not in SI to start with. - /* Force a call to wcsset here*/ + // Force a call to wcsset via Wcsprm_cset before entering the parallel + // region (see the matching note in Wcs_all_pix2world). This ensures + // wcs->flag == WCSSET so that wcss2p below does not invoke wcsset + // itself, allowing the wcsprm_python2c / wcsprm_c2python round-trip to + // be dropped. - if (self->preserve_units && PyWcsprm_cset(self, 1)) { + if (Wcsprm_cset(self, 1)) { return NULL; } @@ -1830,10 +1847,13 @@ PyWcsprm_s2p( goto exit; } - /* Make the call */ + /* Make the call. See the comment in Wcsprm_p2s for the rationale -- + * wcss2p is read-only on the wcsprm struct after wcsset has run, so + * the wcsprm_python2c / wcsprm_c2python round-trip can be dropped and + * concurrent transforms no longer race on the raw arrays + *. */ Py_BEGIN_ALLOW_THREADS /* preoffset_array(world, origin); */ - wcsprm_python2c(&self->x); status = wcss2p( &self->x, ncoord, @@ -1844,7 +1864,6 @@ PyWcsprm_s2p( (double*)PyArray_DATA(imgcrd), (double*)PyArray_DATA(pixcrd), (int*)PyArray_DATA(stat)); - wcsprm_c2python(&self->x); /* unoffset_array(world, origin); */ unoffset_array(pixcrd, origin); unoffset_array(imgcrd, origin); @@ -1900,8 +1919,8 @@ PyWcsprm_s2p( } int -PyWcsprm_cset( - PyWcsprm* self, +Wcsprm_cset( + Wcsprm* self, const int convert) { int status = 0; @@ -1912,6 +1931,26 @@ PyWcsprm_cset( return 0; } +#ifdef Py_GIL_DISABLED + // On a free-threaded build the GIL no longer serialises concurrent callers + // of Wcsprm_cset, so the wcsenq guard alone cannot prevent two threads from + // running wcsset simultaneously on the same struct (which is not thread + // safe). A single process-wide PyMutex around the wcsset path is enough: + // the fast path (wcsenq-succeeds) above is lock-free, so contention only + // happens the first time a WCS is set or after a user mutation, which is + // rare. On a GIL build this macro is undefined and the lock is compiled + // out -- the GIL itself serialises us since Wcsprm_cset never releases it. + static PyMutex wcsset_mutex; + PyMutex_Lock(&wcsset_mutex); + // Double-checked locking: re-check under the lock so that if two threads + // both missed the fast path above, only the first one actually runs + // wcsset and the second returns immediately. + if (wcsenq(&self->x, WCSENQ_CHK)) { + PyMutex_Unlock(&wcsset_mutex); + return 0; + } +#endif + initialize_preserve_units(self); if (convert) wcsprm_python2c(&self->x); @@ -1920,6 +1959,10 @@ PyWcsprm_cset( check_unit_changes(self); +#ifdef Py_GIL_DISABLED + PyMutex_Unlock(&wcsset_mutex); +#endif + if (status == 0) { return 0; } else { @@ -1929,10 +1972,10 @@ PyWcsprm_cset( } /*@null@*/ static PyObject* -PyWcsprm_set( - PyWcsprm* self) { +Wcsprm_set( + Wcsprm* self) { - if (PyWcsprm_cset(self, 1)) { + if (Wcsprm_cset(self, 1)) { return NULL; } @@ -1941,8 +1984,8 @@ PyWcsprm_set( } /*@null@*/ static PyObject* -PyWcsprm_set_ps( - PyWcsprm* self, +Wcsprm_set_ps( + Wcsprm* self, PyObject* arg, /*@unused@*/ PyObject* kwds) { @@ -1963,8 +2006,8 @@ PyWcsprm_set_ps( } /*@null@*/ static PyObject* -PyWcsprm_set_pv( - PyWcsprm* self, +Wcsprm_set_pv( + Wcsprm* self, PyObject* arg, /*@unused@*/ PyObject* kwds) { @@ -1984,23 +2027,23 @@ PyWcsprm_set_pv( * Pythonic. It should probably be hooked into __str__ or something. */ /*@null@*/ static PyObject* -PyWcsprm_print_contents( - PyWcsprm* self) { +Wcsprm_print_contents( + Wcsprm* self) { /* This is not thread-safe, but since we're holding onto the GIL, we can assume we won't have thread conflicts */ wcsprintf_set(NULL); wcsprm_python2c(&self->x); - if (PyWcsprm_cset(self, 0)) { + if (Wcsprm_cset(self, 0)) { wcsprm_c2python(&self->x); return NULL; } if (self->unit_scaling != NULL) { - PyWcsprm* copy = PyWcsprm_copy_with_patched_units(self); + Wcsprm* copy = Wcsprm_copy_with_patched_units(self); wcsprt(©->x); - PyWcsprm_dealloc(copy); + Wcsprm_dealloc(copy); } else { wcsprt(&self->x); wcsprm_c2python(&self->x); @@ -2014,8 +2057,8 @@ if (self->unit_scaling != NULL) { } /*@null@*/ static PyObject* -PyWcsprm_spcfix( - PyWcsprm* self) { +Wcsprm_spcfix( + Wcsprm* self) { int status = 0; @@ -2032,8 +2075,8 @@ PyWcsprm_spcfix( } /*@null@*/ static PyObject* -PyWcsprm_sptr( - PyWcsprm* self, +Wcsprm_sptr( + Wcsprm* self, PyObject* args, PyObject* kwds) { @@ -2071,23 +2114,23 @@ PyWcsprm_sptr( } /*@null@*/ static PyObject* -PyWcsprm___str__( - PyWcsprm* self) { +Wcsprm___str__( + Wcsprm* self) { /* This is not thread-safe, but since we're holding onto the GIL, we can assume we won't have thread conflicts */ wcsprintf_set(NULL); wcsprm_python2c(&self->x); - if (PyWcsprm_cset(self, 0)) { + if (Wcsprm_cset(self, 0)) { wcsprm_c2python(&self->x); return NULL; } if (self->unit_scaling != NULL) { - PyWcsprm* copy = PyWcsprm_copy_with_patched_units(self); + Wcsprm* copy = Wcsprm_copy_with_patched_units(self); wcsprt(©->x); - PyWcsprm_dealloc(copy); + Wcsprm_dealloc(copy); } else { wcsprt(&self->x); wcsprm_c2python(&self->x); @@ -2096,7 +2139,7 @@ PyWcsprm___str__( return PyUnicode_FromString(wcsprintf_buf()); } -PyObject *PyWcsprm_richcompare(PyObject *a, PyObject *b, int op) { +PyObject *Wcsprm_richcompare(PyObject *a, PyObject *b, int op) { int equal; int status; @@ -2104,9 +2147,9 @@ PyObject *PyWcsprm_richcompare(PyObject *a, PyObject *b, int op) { struct wcsprm *bx; if ((op == Py_EQ || op == Py_NE) && - PyObject_TypeCheck(b, (PyTypeObject*)PyWcsprmType)) { - ax = &((PyWcsprm *)a)->x; - bx = &((PyWcsprm *)b)->x; + PyObject_TypeCheck(b, (PyTypeObject*)WcsprmType)) { + ax = &((Wcsprm *)a)->x; + bx = &((Wcsprm *)b)->x; wcsprm_python2c(ax); wcsprm_python2c(bx); @@ -2126,7 +2169,7 @@ PyObject *PyWcsprm_richcompare(PyObject *a, PyObject *b, int op) { Py_RETURN_FALSE; } } else { - wcs_to_python_exc(&(((PyWcsprm *)a)->x)); + wcs_to_python_exc(&(((Wcsprm *)a)->x)); return NULL; } } @@ -2136,15 +2179,15 @@ PyObject *PyWcsprm_richcompare(PyObject *a, PyObject *b, int op) { } /*@null@*/ static PyObject* -PyWcsprm_sub( - PyWcsprm* self, +Wcsprm_sub( + Wcsprm* self, PyObject* args, PyObject* kwds) { int i = -1; Py_ssize_t tmp = 0; PyObject* py_axes = NULL; - PyWcsprm* py_dest_wcs = NULL; + Wcsprm* py_dest_wcs = NULL; PyObject* element = NULL; PyObject* element_utf8 = NULL; char* element_str = NULL; @@ -2271,7 +2314,7 @@ PyWcsprm_sub( goto exit; } - py_dest_wcs = (PyWcsprm*)PyWcsprm_cnew(); + py_dest_wcs = (Wcsprm*)Wcsprm_cnew(); py_dest_wcs->x.flag = -1; status = wcsini(0, nsub, &py_dest_wcs->x); if (status != 0) { @@ -2314,7 +2357,7 @@ PyWcsprm_sub( check_unit_changes(py_dest_wcs); } - if (PyWcsprm_cset(py_dest_wcs, 0)) { + if (Wcsprm_cset(py_dest_wcs, 0)) { status = -1; goto exit; } @@ -2343,8 +2386,8 @@ PyWcsprm_sub( } /*@null@*/ static PyObject* -PyWcsprm_to_header( - PyWcsprm* self, +Wcsprm_to_header( + Wcsprm* self, PyObject* args, PyObject* kwds) { @@ -2376,6 +2419,13 @@ PyWcsprm_to_header( } } + // We need to make sure wcsset() has been called on the WCS in case + // for example preserve_units is being used and units have been set + // programmatically, otherwise unit_scaling will be uninitialized. + if (Wcsprm_cset(self, 1)) { + return NULL; + } + // If the user has requested to preserve the original units, then we // could try and edit the header returned by wcshdo - however, this // might be tricky and not robust to future WCSLIB changes. Instead, @@ -2383,7 +2433,7 @@ PyWcsprm_to_header( // prevent WCSLIB fixing the units, then convert to a header. if (self->unit_scaling != NULL) { - PyWcsprm* copy = PyWcsprm_copy_with_patched_units(self); + Wcsprm* copy = Wcsprm_copy_with_patched_units(self); wcsprm_python2c(©->x); status = wcshdo(relax, ©->x, &nkeyrec, &header); @@ -2419,8 +2469,8 @@ PyWcsprm_to_header( } /*@null@*/ static PyObject* -PyWcsprm_unitfix( - PyWcsprm* self, +Wcsprm_unitfix( + Wcsprm* self, PyObject* args, PyObject* kwds) { @@ -2456,8 +2506,8 @@ PyWcsprm_unitfix( * Member getters/setters (properties) */ /*@null@*/ static PyObject* -PyWcsprm_get_alt( - PyWcsprm* self, +Wcsprm_get_alt( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.alt)) { @@ -2470,8 +2520,8 @@ PyWcsprm_get_alt( } static int -PyWcsprm_set_alt( - PyWcsprm* self, +Wcsprm_set_alt( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -2502,8 +2552,8 @@ PyWcsprm_set_alt( } /*@null@*/ static PyObject* -PyWcsprm_get_axis_types( - PyWcsprm* self, +Wcsprm_get_axis_types( + Wcsprm* self, /*@unused@*/ void* closure) { Py_ssize_t naxis = 0; @@ -2512,7 +2562,7 @@ PyWcsprm_get_axis_types( return NULL; } - if (PyWcsprm_cset(self, 1)) { + if (Wcsprm_cset(self, 1)) { return NULL; } @@ -2522,16 +2572,16 @@ PyWcsprm_get_axis_types( } static PyObject* -PyWcsprm_get_bepoch( - PyWcsprm* self, +Wcsprm_get_bepoch( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("bepoch", self->x.bepoch); } static int -PyWcsprm_set_bepoch( - PyWcsprm* self, +Wcsprm_set_bepoch( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -2545,8 +2595,8 @@ PyWcsprm_set_bepoch( /*@null@*/ static PyObject* -PyWcsprm_get_cd( - PyWcsprm* self, +Wcsprm_get_cd( + Wcsprm* self, /*@unused@*/ void* closure) { npy_intp dims[2]; @@ -2574,8 +2624,8 @@ PyWcsprm_get_cd( } static int -PyWcsprm_set_cd( - PyWcsprm* self, +Wcsprm_set_cd( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -2615,8 +2665,8 @@ PyWcsprm_set_cd( } /*@null@*/ static PyObject* -PyWcsprm_get_cdelt( - PyWcsprm* self, +Wcsprm_get_cdelt( + Wcsprm* self, /*@unused@*/ void* closure) { Py_ssize_t naxis = 0; @@ -2644,8 +2694,8 @@ PyWcsprm_get_cdelt( } /*@null@*/ static int -PyWcsprm_set_cdelt( - PyWcsprm* self, +Wcsprm_set_cdelt( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -2666,7 +2716,7 @@ PyWcsprm_set_cdelt( status = set_double_array("cdelt", value, 1, &dims, self->x.cdelt); - if (status == 0 && self->original_cunit != NULL) { + if (status == 0 && self->unit_scaling != NULL) { for (npy_intp i = 0; i < dims; ++i) { self->x.cdelt[i] *= self->unit_scaling[i]; } @@ -2676,16 +2726,16 @@ PyWcsprm_set_cdelt( } static PyObject* -PyWcsprm_get_cel_offset( - PyWcsprm* self, +Wcsprm_get_cel_offset( + Wcsprm* self, /*@unused@*/ void* closure) { return get_bool("cel_offset", self->x.cel.offset); } static int -PyWcsprm_set_cel_offset( - PyWcsprm* self, +Wcsprm_set_cel_offset( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -2696,8 +2746,8 @@ PyWcsprm_set_cel_offset( /*@null@*/ static PyObject* -PyWcsprm_get_cname( - PyWcsprm* self, +Wcsprm_get_cname( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.cname)) { @@ -2708,8 +2758,8 @@ PyWcsprm_get_cname( } /*@null@*/ static int -PyWcsprm_set_cname( - PyWcsprm* self, +Wcsprm_set_cname( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { if (is_null(self->x.cname)) { @@ -2720,8 +2770,8 @@ PyWcsprm_set_cname( } /*@null@*/ static PyObject* -PyWcsprm_get_colax( - PyWcsprm* self, +Wcsprm_get_colax( + Wcsprm* self, /*@unused@*/ void* closure) { Py_ssize_t naxis = 0; @@ -2736,8 +2786,8 @@ PyWcsprm_get_colax( } static int -PyWcsprm_set_colax( - PyWcsprm* self, +Wcsprm_set_colax( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -2753,16 +2803,16 @@ PyWcsprm_set_colax( } static PyObject* -PyWcsprm_get_colnum( - PyWcsprm* self, +Wcsprm_get_colnum( + Wcsprm* self, /*@unused@*/ void* closure) { return get_int("colnum", self->x.colnum); } static int -PyWcsprm_set_colnum( - PyWcsprm* self, +Wcsprm_set_colnum( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -2770,8 +2820,8 @@ PyWcsprm_set_colnum( } /*@null@*/ static PyObject* -PyWcsprm_get_crder( - PyWcsprm* self, +Wcsprm_get_crder( + Wcsprm* self, /*@unused@*/ void* closure) { Py_ssize_t naxis = 0; @@ -2786,8 +2836,8 @@ PyWcsprm_get_crder( } static int -PyWcsprm_set_crder( - PyWcsprm* self, +Wcsprm_set_crder( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -2803,8 +2853,8 @@ PyWcsprm_set_crder( } /*@null@*/ static PyObject* -PyWcsprm_get_crota( - PyWcsprm* self, +Wcsprm_get_crota( + Wcsprm* self, /*@unused@*/ void* closure) { Py_ssize_t naxis = 0; @@ -2824,8 +2874,8 @@ PyWcsprm_get_crota( } static int -PyWcsprm_set_crota( - PyWcsprm* self, +Wcsprm_set_crota( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -2855,8 +2905,8 @@ PyWcsprm_set_crota( } /*@null@*/ static PyObject* -PyWcsprm_get_crpix( - PyWcsprm* self, +Wcsprm_get_crpix( + Wcsprm* self, /*@unused@*/ void* closure) { Py_ssize_t naxis = 0; @@ -2871,8 +2921,8 @@ PyWcsprm_get_crpix( } static int -PyWcsprm_set_crpix( - PyWcsprm* self, +Wcsprm_set_crpix( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -2890,8 +2940,8 @@ PyWcsprm_set_crpix( } /*@null@*/ static PyObject* -PyWcsprm_get_crval( - PyWcsprm* self, +Wcsprm_get_crval( + Wcsprm* self, /*@unused@*/ void* closure) { Py_ssize_t naxis = 0; @@ -2914,8 +2964,8 @@ PyWcsprm_get_crval( } static int -PyWcsprm_set_crval( - PyWcsprm* self, +Wcsprm_set_crval( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -2932,7 +2982,7 @@ PyWcsprm_set_crval( status = set_double_array("crval", value, 1, &naxis, self->x.crval); - if (status == 0 && self->original_cunit != NULL) { + if (status == 0 && self->unit_scaling != NULL) { for (npy_intp i = 0; i < naxis; ++i) { self->x.crval[i] *= self->unit_scaling[i]; } @@ -2943,8 +2993,8 @@ PyWcsprm_set_crval( } /*@null@*/ static PyObject* -PyWcsprm_get_csyer( - PyWcsprm* self, +Wcsprm_get_csyer( + Wcsprm* self, /*@unused@*/ void* closure) { Py_ssize_t naxis; @@ -2959,8 +3009,8 @@ PyWcsprm_get_csyer( } static int -PyWcsprm_set_csyer( - PyWcsprm* self, +Wcsprm_set_csyer( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -2976,8 +3026,8 @@ PyWcsprm_set_csyer( } /*@null@*/ static PyObject* -PyWcsprm_get_ctype( - PyWcsprm* self, +Wcsprm_get_ctype( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.ctype)) { @@ -2988,8 +3038,8 @@ PyWcsprm_get_ctype( } static int -PyWcsprm_set_ctype( - PyWcsprm* self, +Wcsprm_set_ctype( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3003,16 +3053,16 @@ PyWcsprm_set_ctype( } static PyObject* -PyWcsprm_get_cubeface( - PyWcsprm* self, +Wcsprm_get_cubeface( + Wcsprm* self, /*@unused@*/ void* closure) { return get_int("cubeface", self->x.cubeface); } static int -PyWcsprm_set_cubeface( - PyWcsprm* self, +Wcsprm_set_cubeface( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3022,8 +3072,8 @@ PyWcsprm_set_cubeface( } /*@null@*/ static PyObject* -PyWcsprm_get_cunit( - PyWcsprm* self, +Wcsprm_get_cunit( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.cunit)) { @@ -3040,8 +3090,8 @@ PyWcsprm_get_cunit( } static int -PyWcsprm_set_cunit( - PyWcsprm* self, +Wcsprm_set_cunit( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3099,8 +3149,8 @@ PyWcsprm_set_cunit( } /*@null@*/ static PyObject* -PyWcsprm_get_czphs( - PyWcsprm* self, +Wcsprm_get_czphs( + Wcsprm* self, /*@unused@*/ void* closure) { Py_ssize_t naxis; @@ -3115,8 +3165,8 @@ PyWcsprm_get_czphs( } static int -PyWcsprm_set_czphs( - PyWcsprm* self, +Wcsprm_set_czphs( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3132,8 +3182,8 @@ PyWcsprm_set_czphs( } /*@null@*/ static PyObject* -PyWcsprm_get_cperi( - PyWcsprm* self, +Wcsprm_get_cperi( + Wcsprm* self, /*@unused@*/ void* closure) { Py_ssize_t naxis; @@ -3148,8 +3198,8 @@ PyWcsprm_get_cperi( } static int -PyWcsprm_set_cperi( - PyWcsprm* self, +Wcsprm_set_cperi( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3165,8 +3215,8 @@ PyWcsprm_set_cperi( } /*@null@*/ static PyObject* -PyWcsprm_get_dateavg( - PyWcsprm* self, +Wcsprm_get_dateavg( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.dateavg)) { @@ -3177,8 +3227,8 @@ PyWcsprm_get_dateavg( } static int -PyWcsprm_set_dateavg( - PyWcsprm* self, +Wcsprm_set_dateavg( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3192,8 +3242,8 @@ PyWcsprm_set_dateavg( } /*@null@*/ static PyObject* -PyWcsprm_get_datebeg( - PyWcsprm* self, +Wcsprm_get_datebeg( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.datebeg)) { @@ -3204,8 +3254,8 @@ PyWcsprm_get_datebeg( } static int -PyWcsprm_set_datebeg( - PyWcsprm* self, +Wcsprm_set_datebeg( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3217,8 +3267,8 @@ PyWcsprm_set_datebeg( } /*@null@*/ static PyObject* -PyWcsprm_get_dateend( - PyWcsprm* self, +Wcsprm_get_dateend( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.dateend)) { @@ -3229,8 +3279,8 @@ PyWcsprm_get_dateend( } static int -PyWcsprm_set_dateend( - PyWcsprm* self, +Wcsprm_set_dateend( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3243,8 +3293,8 @@ PyWcsprm_set_dateend( /*@null@*/ static PyObject* -PyWcsprm_get_dateobs( - PyWcsprm* self, +Wcsprm_get_dateobs( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.dateobs)) { @@ -3255,8 +3305,8 @@ PyWcsprm_get_dateobs( } static int -PyWcsprm_set_dateobs( - PyWcsprm* self, +Wcsprm_set_dateobs( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3268,8 +3318,8 @@ PyWcsprm_set_dateobs( } /*@null@*/ static PyObject* -PyWcsprm_get_dateref( - PyWcsprm* self, +Wcsprm_get_dateref( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.dateref)) { @@ -3280,8 +3330,8 @@ PyWcsprm_get_dateref( } static int -PyWcsprm_set_dateref( - PyWcsprm* self, +Wcsprm_set_dateref( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3293,16 +3343,16 @@ PyWcsprm_set_dateref( } static PyObject* -PyWcsprm_get_equinox( - PyWcsprm* self, +Wcsprm_get_equinox( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("equinox", self->x.equinox); } static int -PyWcsprm_set_equinox( - PyWcsprm* self, +Wcsprm_set_equinox( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3315,8 +3365,8 @@ PyWcsprm_set_equinox( } /*@null@*/ static PyObject* -PyWcsprm_get_imgpix_matrix( - PyWcsprm* self, +Wcsprm_get_imgpix_matrix( + Wcsprm* self, /*@unused@*/ void* closure) { npy_intp dims[2]; @@ -3325,7 +3375,7 @@ PyWcsprm_get_imgpix_matrix( return NULL; } - if (PyWcsprm_cset(self, 1)) { + if (Wcsprm_cset(self, 1)) { return NULL; } @@ -3337,16 +3387,16 @@ PyWcsprm_get_imgpix_matrix( } static PyObject* -PyWcsprm_get_jepoch( - PyWcsprm* self, +Wcsprm_get_jepoch( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("jepoch", self->x.jepoch); } static int -PyWcsprm_set_jepoch( - PyWcsprm* self, +Wcsprm_set_jepoch( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3362,11 +3412,11 @@ PyWcsprm_set_jepoch( static PyObject* -PyWcsprm_get_lat( - PyWcsprm* self, +Wcsprm_get_lat( + Wcsprm* self, /*@unused@*/ void* closure) { - if (PyWcsprm_cset(self, 1)) { + if (Wcsprm_cset(self, 1)) { return NULL; } @@ -3374,16 +3424,16 @@ PyWcsprm_get_lat( } static PyObject* -PyWcsprm_get_latpole( - PyWcsprm* self, +Wcsprm_get_latpole( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("latpole", self->x.latpole); } static int -PyWcsprm_set_latpole( - PyWcsprm* self, +Wcsprm_set_latpole( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3398,15 +3448,15 @@ PyWcsprm_set_latpole( } /*@null@*/ static PyObject* -PyWcsprm_get_lattyp( - PyWcsprm* self, +Wcsprm_get_lattyp( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.lattyp)) { return NULL; } - if (PyWcsprm_cset(self, 1)) { + if (Wcsprm_cset(self, 1)) { return NULL; } @@ -3414,11 +3464,11 @@ PyWcsprm_get_lattyp( } static PyObject* -PyWcsprm_get_lng( - PyWcsprm* self, +Wcsprm_get_lng( + Wcsprm* self, /*@unused@*/ void* closure) { - if (PyWcsprm_cset(self, 1)) { + if (Wcsprm_cset(self, 1)) { return NULL; } @@ -3426,15 +3476,15 @@ PyWcsprm_get_lng( } /*@null@*/ static PyObject* -PyWcsprm_get_lngtyp( - PyWcsprm* self, +Wcsprm_get_lngtyp( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.lngtyp)) { return NULL; } - if (PyWcsprm_cset(self, 1)) { + if (Wcsprm_cset(self, 1)) { return NULL; } @@ -3442,16 +3492,16 @@ PyWcsprm_get_lngtyp( } static PyObject* -PyWcsprm_get_lonpole( - PyWcsprm* self, +Wcsprm_get_lonpole( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("lonpole", self->x.lonpole); } static int -PyWcsprm_set_lonpole( - PyWcsprm* self, +Wcsprm_set_lonpole( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3466,16 +3516,16 @@ PyWcsprm_set_lonpole( } static PyObject* -PyWcsprm_get_mjdavg( - PyWcsprm* self, +Wcsprm_get_mjdavg( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("mjdavg", self->x.mjdavg); } static int -PyWcsprm_set_mjdavg( - PyWcsprm* self, +Wcsprm_set_mjdavg( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3488,16 +3538,16 @@ PyWcsprm_set_mjdavg( } static PyObject* -PyWcsprm_get_mjdbeg( - PyWcsprm* self, +Wcsprm_get_mjdbeg( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("mjdbeg", self->x.mjdbeg); } static int -PyWcsprm_set_mjdbeg( - PyWcsprm* self, +Wcsprm_set_mjdbeg( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3510,16 +3560,16 @@ PyWcsprm_set_mjdbeg( } static PyObject* -PyWcsprm_get_mjdend( - PyWcsprm* self, +Wcsprm_get_mjdend( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("mjdend", self->x.mjdend); } static int -PyWcsprm_set_mjdend( - PyWcsprm* self, +Wcsprm_set_mjdend( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3532,16 +3582,16 @@ PyWcsprm_set_mjdend( } static PyObject* -PyWcsprm_get_mjdobs( - PyWcsprm* self, +Wcsprm_get_mjdobs( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("mjdobs", self->x.mjdobs); } static int -PyWcsprm_set_mjdobs( - PyWcsprm* self, +Wcsprm_set_mjdobs( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3556,8 +3606,8 @@ PyWcsprm_set_mjdobs( } static PyObject* -PyWcsprm_get_mjdref( - PyWcsprm* self, +Wcsprm_get_mjdref( + Wcsprm* self, /*@unused@*/ void* closure) { npy_intp size = 2; @@ -3566,8 +3616,8 @@ PyWcsprm_get_mjdref( } static int -PyWcsprm_set_mjdref( - PyWcsprm* self, +Wcsprm_set_mjdref( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3583,8 +3633,8 @@ PyWcsprm_set_mjdref( /*@null@*/ static PyObject* -PyWcsprm_get_timesys( - PyWcsprm* self, +Wcsprm_get_timesys( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.timesys)) { @@ -3595,8 +3645,8 @@ PyWcsprm_get_timesys( } static int -PyWcsprm_set_timesys( - PyWcsprm* self, +Wcsprm_set_timesys( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3608,8 +3658,8 @@ PyWcsprm_set_timesys( } /*@null@*/ static PyObject* -PyWcsprm_get_trefpos( - PyWcsprm* self, +Wcsprm_get_trefpos( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.trefpos)) { @@ -3620,8 +3670,8 @@ PyWcsprm_get_trefpos( } static int -PyWcsprm_set_trefpos( - PyWcsprm* self, +Wcsprm_set_trefpos( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3633,8 +3683,8 @@ PyWcsprm_set_trefpos( } /*@null@*/ static PyObject* -PyWcsprm_get_trefdir( - PyWcsprm* self, +Wcsprm_get_trefdir( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.trefdir)) { @@ -3645,8 +3695,8 @@ PyWcsprm_get_trefdir( } static int -PyWcsprm_set_trefdir( - PyWcsprm* self, +Wcsprm_set_trefdir( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3658,8 +3708,8 @@ PyWcsprm_set_trefdir( } /*@null@*/ static PyObject* -PyWcsprm_get_timeunit( - PyWcsprm* self, +Wcsprm_get_timeunit( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.timeunit)) { @@ -3670,8 +3720,8 @@ PyWcsprm_get_timeunit( } static int -PyWcsprm_set_timeunit( - PyWcsprm* self, +Wcsprm_set_timeunit( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3683,8 +3733,8 @@ PyWcsprm_set_timeunit( } /*@null@*/ static PyObject* -PyWcsprm_get_plephem( - PyWcsprm* self, +Wcsprm_get_plephem( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.plephem)) { @@ -3695,8 +3745,8 @@ PyWcsprm_get_plephem( } static int -PyWcsprm_set_plephem( - PyWcsprm* self, +Wcsprm_set_plephem( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3708,16 +3758,16 @@ PyWcsprm_set_plephem( } static PyObject* -PyWcsprm_get_tstart( - PyWcsprm* self, +Wcsprm_get_tstart( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("tstart", self->x.tstart); } static int -PyWcsprm_set_tstart( - PyWcsprm* self, +Wcsprm_set_tstart( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3730,16 +3780,16 @@ PyWcsprm_set_tstart( } static PyObject* -PyWcsprm_get_tstop( - PyWcsprm* self, +Wcsprm_get_tstop( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("tstop", self->x.tstop); } static int -PyWcsprm_set_tstop( - PyWcsprm* self, +Wcsprm_set_tstop( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3752,16 +3802,16 @@ PyWcsprm_set_tstop( } static PyObject* -PyWcsprm_get_telapse( - PyWcsprm* self, +Wcsprm_get_telapse( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("telapse", self->x.telapse); } static int -PyWcsprm_set_telapse( - PyWcsprm* self, +Wcsprm_set_telapse( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3774,16 +3824,16 @@ PyWcsprm_set_telapse( } static PyObject* -PyWcsprm_get_timeoffs( - PyWcsprm* self, +Wcsprm_get_timeoffs( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("timeoffs", self->x.timeoffs); } static int -PyWcsprm_set_timeoffs( - PyWcsprm* self, +Wcsprm_set_timeoffs( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3796,16 +3846,16 @@ PyWcsprm_set_timeoffs( } static PyObject* -PyWcsprm_get_timsyer( - PyWcsprm* self, +Wcsprm_get_timsyer( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("timsyer", self->x.timsyer); } static int -PyWcsprm_set_timsyer( - PyWcsprm* self, +Wcsprm_set_timsyer( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3818,16 +3868,16 @@ PyWcsprm_set_timsyer( } static PyObject* -PyWcsprm_get_timrder( - PyWcsprm* self, +Wcsprm_get_timrder( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("timrder", self->x.timrder); } static int -PyWcsprm_set_timrder( - PyWcsprm* self, +Wcsprm_set_timrder( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3840,16 +3890,16 @@ PyWcsprm_set_timrder( } static PyObject* -PyWcsprm_get_timedel( - PyWcsprm* self, +Wcsprm_get_timedel( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("timedel", self->x.timedel); } static int -PyWcsprm_set_timedel( - PyWcsprm* self, +Wcsprm_set_timedel( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3862,16 +3912,16 @@ PyWcsprm_set_timedel( } static PyObject* -PyWcsprm_get_timepixr( - PyWcsprm* self, +Wcsprm_get_timepixr( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("timepixr", self->x.timepixr); } static int -PyWcsprm_set_timepixr( - PyWcsprm* self, +Wcsprm_set_timepixr( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3884,8 +3934,8 @@ PyWcsprm_set_timepixr( } /*@null@*/ static PyObject* -PyWcsprm_get_obsorbit( - PyWcsprm* self, +Wcsprm_get_obsorbit( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.obsorbit)) { @@ -3896,8 +3946,8 @@ PyWcsprm_get_obsorbit( } static int -PyWcsprm_set_obsorbit( - PyWcsprm* self, +Wcsprm_set_obsorbit( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3909,16 +3959,16 @@ PyWcsprm_set_obsorbit( } static PyObject* -PyWcsprm_get_xposure( - PyWcsprm* self, +Wcsprm_get_xposure( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("xposure", self->x.xposure); } static int -PyWcsprm_set_xposure( - PyWcsprm* self, +Wcsprm_set_xposure( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3931,8 +3981,8 @@ PyWcsprm_set_xposure( } /*@null@*/ static PyObject* -PyWcsprm_get_name( - PyWcsprm* self, +Wcsprm_get_name( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.wcsname)) { @@ -3943,8 +3993,8 @@ PyWcsprm_get_name( } static int -PyWcsprm_set_name( - PyWcsprm* self, +Wcsprm_set_name( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -3956,16 +4006,16 @@ PyWcsprm_set_name( } static PyObject* -PyWcsprm_get_naxis( - PyWcsprm* self, +Wcsprm_get_naxis( + Wcsprm* self, /*@unused@*/ void* closure) { return get_int("naxis", self->x.naxis); } /*@null@*/ static PyObject* -PyWcsprm_get_obsgeo( - PyWcsprm* self, +Wcsprm_get_obsgeo( + Wcsprm* self, /*@unused@*/ void* closure) { Py_ssize_t size = 6; @@ -3978,8 +4028,8 @@ PyWcsprm_get_obsgeo( } static int -PyWcsprm_set_obsgeo( - PyWcsprm* self, +Wcsprm_set_obsgeo( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -4003,8 +4053,8 @@ PyWcsprm_set_obsgeo( } /*@null@*/ static PyObject* -PyWcsprm_get_pc( - PyWcsprm* self, +Wcsprm_get_pc( + Wcsprm* self, /*@unused@*/ void* closure) { npy_intp dims[2]; @@ -4025,8 +4075,8 @@ PyWcsprm_get_pc( } static int -PyWcsprm_set_pc( - PyWcsprm* self, +Wcsprm_set_pc( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -4078,16 +4128,16 @@ PyWcsprm_set_pc( } static PyObject* -PyWcsprm_get_phi0( - PyWcsprm* self, +Wcsprm_get_phi0( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("phi0", self->x.cel.phi0); } static int -PyWcsprm_set_phi0( - PyWcsprm* self, +Wcsprm_set_phi0( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -4102,8 +4152,8 @@ PyWcsprm_set_phi0( } static PyObject* -PyWcsprm_get_piximg_matrix( - PyWcsprm* self, +Wcsprm_get_piximg_matrix( + Wcsprm* self, /*@unused@*/ void* closure) { npy_intp dims[2]; @@ -4112,7 +4162,7 @@ PyWcsprm_get_piximg_matrix( return NULL; } - if (PyWcsprm_cset(self, 1)) { + if (Wcsprm_cset(self, 1)) { return NULL; } @@ -4124,8 +4174,8 @@ PyWcsprm_get_piximg_matrix( } static PyObject* -PyWcsprm_get_radesys( - PyWcsprm* self, +Wcsprm_get_radesys( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.radesys)) { @@ -4136,8 +4186,8 @@ PyWcsprm_get_radesys( } static int -PyWcsprm_set_radesys( - PyWcsprm* self, +Wcsprm_set_radesys( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -4149,16 +4199,16 @@ PyWcsprm_set_radesys( } static PyObject* -PyWcsprm_get_restfrq( - PyWcsprm* self, +Wcsprm_get_restfrq( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("restfrq", self->x.restfrq); } static int -PyWcsprm_set_restfrq( - PyWcsprm* self, +Wcsprm_set_restfrq( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -4173,16 +4223,16 @@ PyWcsprm_set_restfrq( } static PyObject* -PyWcsprm_get_restwav( - PyWcsprm* self, +Wcsprm_get_restwav( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("restwav", self->x.restwav); } static int -PyWcsprm_set_restwav( - PyWcsprm* self, +Wcsprm_set_restwav( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -4197,16 +4247,16 @@ PyWcsprm_set_restwav( } static PyObject* -PyWcsprm_get_spec( - PyWcsprm* self, +Wcsprm_get_spec( + Wcsprm* self, /*@unused@*/ void* closure) { return get_int("spec", self->x.spec); } /*@null@*/ static PyObject* -PyWcsprm_get_specsys( - PyWcsprm* self, +Wcsprm_get_specsys( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.specsys)) { @@ -4217,8 +4267,8 @@ PyWcsprm_get_specsys( } static int -PyWcsprm_set_specsys( - PyWcsprm* self, +Wcsprm_set_specsys( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -4230,8 +4280,8 @@ PyWcsprm_set_specsys( } /*@null@*/ static PyObject* -PyWcsprm_get_ssysobs( - PyWcsprm* self, +Wcsprm_get_ssysobs( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.ssysobs)) { @@ -4242,8 +4292,8 @@ PyWcsprm_get_ssysobs( } static int -PyWcsprm_set_ssysobs( - PyWcsprm* self, +Wcsprm_set_ssysobs( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -4257,8 +4307,8 @@ PyWcsprm_set_ssysobs( } /*@null@*/ static PyObject* -PyWcsprm_get_ssyssrc( - PyWcsprm* self, +Wcsprm_get_ssyssrc( + Wcsprm* self, /*@unused@*/ void* closure) { if (is_null(self->x.ssyssrc)) { @@ -4269,8 +4319,8 @@ PyWcsprm_get_ssyssrc( } static int -PyWcsprm_set_ssyssrc( - PyWcsprm* self, +Wcsprm_set_ssyssrc( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -4282,8 +4332,8 @@ PyWcsprm_set_ssyssrc( } static PyObject* -PyWcsprm_get_tab( - PyWcsprm* self, +Wcsprm_get_tab( + Wcsprm* self, /*@unused@*/ void* closure) { PyObject* result; @@ -4298,14 +4348,13 @@ PyWcsprm_get_tab( } for (i = 0; i < ntab; ++i) { - subresult = (PyObject *)PyTabprm_cnew((PyObject *)self, &(self->x.tab[i])); + subresult = (PyObject *)Tabprm_cnew((PyObject *)self, &(self->x.tab[i])); if (subresult == NULL) { Py_DECREF(result); return NULL; } if (PyList_SetItem(result, i, subresult) == -1) { - Py_DECREF(subresult); Py_DECREF(result); return NULL; } @@ -4315,16 +4364,16 @@ PyWcsprm_get_tab( } static PyObject* -PyWcsprm_get_theta0( - PyWcsprm* self, +Wcsprm_get_theta0( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("theta0", self->x.cel.theta0); } static int -PyWcsprm_set_theta0( - PyWcsprm* self, +Wcsprm_set_theta0( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -4339,16 +4388,16 @@ PyWcsprm_set_theta0( } static PyObject* -PyWcsprm_get_velangl( - PyWcsprm* self, +Wcsprm_get_velangl( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("velangl", self->x.velangl); } static int -PyWcsprm_set_velangl( - PyWcsprm* self, +Wcsprm_set_velangl( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -4361,16 +4410,16 @@ PyWcsprm_set_velangl( } static PyObject* -PyWcsprm_get_velosys( - PyWcsprm* self, +Wcsprm_get_velosys( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("velosys", self->x.velosys); } static int -PyWcsprm_set_velosys( - PyWcsprm* self, +Wcsprm_set_velosys( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -4383,16 +4432,16 @@ PyWcsprm_set_velosys( } static PyObject* -PyWcsprm_get_velref( - PyWcsprm* self, +Wcsprm_get_velref( + Wcsprm* self, /*@unused@*/ void* closure) { return get_int("velref", self->x.velref); } static int -PyWcsprm_set_velref( - PyWcsprm* self, +Wcsprm_set_velref( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -4405,7 +4454,7 @@ PyWcsprm_set_velref( } -static PyObject* PyWcsprm_get_wtb(PyWcsprm* self, void* closure) { +static PyObject* Wcsprm_get_wtb(Wcsprm* self, void* closure) { PyObject* list; PyObject* elem; int i, nwtb; @@ -4416,7 +4465,7 @@ static PyObject* PyWcsprm_get_wtb(PyWcsprm* self, void* closure) { if (list == NULL) return NULL; for (i = 0; i < nwtb; ++i) { - elem = (PyObject *)PyWtbarr_cnew((PyObject *)self, &(self->x.wtb[i])); + elem = (PyObject *)Wtbarr_cnew((PyObject *)self, &(self->x.wtb[i])); if (elem == NULL) { Py_DECREF(list); return NULL; @@ -4430,16 +4479,16 @@ static PyObject* PyWcsprm_get_wtb(PyWcsprm* self, void* closure) { static PyObject* -PyWcsprm_get_zsource( - PyWcsprm* self, +Wcsprm_get_zsource( + Wcsprm* self, /*@unused@*/ void* closure) { return get_double("zsource", self->x.zsource); } static int -PyWcsprm_set_zsource( - PyWcsprm* self, +Wcsprm_set_zsource( + Wcsprm* self, PyObject* value, /*@unused@*/ void* closure) { @@ -4453,8 +4502,8 @@ PyWcsprm_set_zsource( /*@null@*/ static PyObject* -PyWcsprm_get_unit_scaling( - PyWcsprm* self, +Wcsprm_get_unit_scaling( + Wcsprm* self, /*@unused@*/ void* closure) { Py_ssize_t naxis = 0; @@ -4471,8 +4520,8 @@ PyWcsprm_get_unit_scaling( } static PyObject* -PyWcsprm_get_aux( - PyWcsprm* self, +Wcsprm_get_aux( + Wcsprm* self, /*@unused@*/ void* closure) { PyObject* result; @@ -4484,160 +4533,160 @@ PyWcsprm_get_aux( wcsauxi(1, &self->x); } - result = (PyObject *)PyAuxprm_cnew((PyObject *)self, self->x.aux); + result = (PyObject *)Auxprm_cnew((PyObject *)self, self->x.aux); return result; } static PyObject* -PyWcsprm_get_cel( - PyWcsprm* self, +Wcsprm_get_cel( + Wcsprm* self, /*@unused@*/ void* closure) { - return (PyObject *)PyCelprm_cnew((PyObject *)self, &(self->x.cel), NULL); + return (PyObject *)Celprm_cnew((PyObject *)self, &(self->x.cel), NULL); } /*************************************************************************** - * PyWcsprm definition structures + * Wcsprm definition structures */ -static PyGetSetDef PyWcsprm_getset[] = { - {"alt", (getter)PyWcsprm_get_alt, (setter)PyWcsprm_set_alt, (char *)doc_alt}, - {"aux", (getter)PyWcsprm_get_aux, NULL, (char *)doc_aux}, - {"cel", (getter)PyWcsprm_get_cel, NULL, (char *)doc_cel}, - {"axis_types", (getter)PyWcsprm_get_axis_types, NULL, (char *)doc_axis_types}, - {"bepoch", (getter)PyWcsprm_get_bepoch, (setter)PyWcsprm_set_bepoch, (char *)doc_bepoch}, - {"cd", (getter)PyWcsprm_get_cd, (setter)PyWcsprm_set_cd, (char *)doc_cd}, - {"cdelt", (getter)PyWcsprm_get_cdelt, (setter)PyWcsprm_set_cdelt, (char *)doc_cdelt}, - {"cel_offset", (getter)PyWcsprm_get_cel_offset, (setter)PyWcsprm_set_cel_offset, (char *)doc_cel_offset}, - {"cname", (getter)PyWcsprm_get_cname, (setter)PyWcsprm_set_cname, (char *)doc_cname}, - {"colax", (getter)PyWcsprm_get_colax, (setter)PyWcsprm_set_colax, (char *)doc_colax}, - {"colnum", (getter)PyWcsprm_get_colnum, (setter)PyWcsprm_set_colnum, (char *)doc_colnum}, - {"crder", (getter)PyWcsprm_get_crder, (setter)PyWcsprm_set_crder, (char *)doc_crder}, - {"crota", (getter)PyWcsprm_get_crota, (setter)PyWcsprm_set_crota, (char *)doc_crota}, - {"crpix", (getter)PyWcsprm_get_crpix, (setter)PyWcsprm_set_crpix, (char *)doc_crpix}, - {"crval", (getter)PyWcsprm_get_crval, (setter)PyWcsprm_set_crval, (char *)doc_crval}, - {"csyer", (getter)PyWcsprm_get_csyer, (setter)PyWcsprm_set_csyer, (char *)doc_csyer}, - {"ctype", (getter)PyWcsprm_get_ctype, (setter)PyWcsprm_set_ctype, (char *)doc_ctype}, - {"cubeface", (getter)PyWcsprm_get_cubeface, (setter)PyWcsprm_set_cubeface, (char *)doc_cubeface}, - {"cunit", (getter)PyWcsprm_get_cunit, (setter)PyWcsprm_set_cunit, (char *)doc_cunit}, - {"czphs", (getter)PyWcsprm_get_czphs, (setter)PyWcsprm_set_czphs, (char *)doc_czphs}, - {"cperi", (getter)PyWcsprm_get_cperi, (setter)PyWcsprm_set_cperi, (char *)doc_cperi}, - {"dateavg", (getter)PyWcsprm_get_dateavg, (setter)PyWcsprm_set_dateavg, (char *)doc_dateavg}, - {"datebeg", (getter)PyWcsprm_get_datebeg, (setter)PyWcsprm_set_datebeg, (char *)doc_datebeg}, - {"dateend", (getter)PyWcsprm_get_dateend, (setter)PyWcsprm_set_dateend, (char *)doc_dateend}, - {"dateobs", (getter)PyWcsprm_get_dateobs, (setter)PyWcsprm_set_dateobs, (char *)doc_dateobs}, - {"dateref", (getter)PyWcsprm_get_dateref, (setter)PyWcsprm_set_dateref, (char *)doc_dateref}, - {"equinox", (getter)PyWcsprm_get_equinox, (setter)PyWcsprm_set_equinox, (char *)doc_equinox}, - {"imgpix_matrix", (getter)PyWcsprm_get_imgpix_matrix, NULL, (char *)doc_imgpix_matrix}, - {"jepoch", (getter)PyWcsprm_get_jepoch, (setter)PyWcsprm_set_jepoch, (char *)doc_jepoch}, - {"lat", (getter)PyWcsprm_get_lat, NULL, (char *)doc_lat}, - {"latpole", (getter)PyWcsprm_get_latpole, (setter)PyWcsprm_set_latpole, (char *)doc_latpole}, - {"lattyp", (getter)PyWcsprm_get_lattyp, NULL, (char *)doc_lattyp}, - {"lng", (getter)PyWcsprm_get_lng, NULL, (char *)doc_lng}, - {"lngtyp", (getter)PyWcsprm_get_lngtyp, NULL, (char *)doc_lngtyp}, - {"lonpole", (getter)PyWcsprm_get_lonpole, (setter)PyWcsprm_set_lonpole, (char *)doc_lonpole}, - {"mjdavg", (getter)PyWcsprm_get_mjdavg, (setter)PyWcsprm_set_mjdavg, (char *)doc_mjdavg}, - {"mjdbeg", (getter)PyWcsprm_get_mjdbeg, (setter)PyWcsprm_set_mjdbeg, (char *)doc_mjdbeg}, - {"mjdend", (getter)PyWcsprm_get_mjdend, (setter)PyWcsprm_set_mjdend, (char *)doc_mjdend}, - {"mjdobs", (getter)PyWcsprm_get_mjdobs, (setter)PyWcsprm_set_mjdobs, (char *)doc_mjdobs}, - {"mjdref", (getter)PyWcsprm_get_mjdref, (setter)PyWcsprm_set_mjdref, (char *)doc_mjdref}, - {"name", (getter)PyWcsprm_get_name, (setter)PyWcsprm_set_name, (char *)doc_name}, - {"naxis", (getter)PyWcsprm_get_naxis, NULL, (char *)doc_naxis}, - {"obsgeo", (getter)PyWcsprm_get_obsgeo, (setter)PyWcsprm_set_obsgeo, (char *)doc_obsgeo}, - {"obsorbit", (getter)PyWcsprm_get_obsorbit, (setter)PyWcsprm_set_obsorbit, (char *)doc_obsorbit}, - {"pc", (getter)PyWcsprm_get_pc, (setter)PyWcsprm_set_pc, (char *)doc_pc}, - {"phi0", (getter)PyWcsprm_get_phi0, (setter)PyWcsprm_set_phi0, (char *)doc_phi0}, - {"piximg_matrix", (getter)PyWcsprm_get_piximg_matrix, NULL, (char *)doc_piximg_matrix}, - {"plephem", (getter)PyWcsprm_get_plephem, (setter)PyWcsprm_set_plephem, (char *) doc_plephem}, - {"radesys", (getter)PyWcsprm_get_radesys, (setter)PyWcsprm_set_radesys, (char *)doc_radesys}, - {"restfrq", (getter)PyWcsprm_get_restfrq, (setter)PyWcsprm_set_restfrq, (char *)doc_restfrq}, - {"restwav", (getter)PyWcsprm_get_restwav, (setter)PyWcsprm_set_restwav, (char *)doc_restwav}, - {"spec", (getter)PyWcsprm_get_spec, NULL, (char *)doc_spec}, - {"specsys", (getter)PyWcsprm_get_specsys, (setter)PyWcsprm_set_specsys, (char *)doc_specsys}, - {"ssysobs", (getter)PyWcsprm_get_ssysobs, (setter)PyWcsprm_set_ssysobs, (char *)doc_ssysobs}, - {"ssyssrc", (getter)PyWcsprm_get_ssyssrc, (setter)PyWcsprm_set_ssyssrc, (char *)doc_ssyssrc}, - {"tab", (getter)PyWcsprm_get_tab, NULL, (char *)doc_tab}, - {"theta0", (getter)PyWcsprm_get_theta0, (setter)PyWcsprm_set_theta0, (char *)doc_theta0}, - {"timesys", (getter)PyWcsprm_get_timesys, (setter)PyWcsprm_set_timesys, (char *) doc_timesys}, - {"trefpos", (getter)PyWcsprm_get_trefpos, (setter)PyWcsprm_set_trefpos, (char *) doc_trefpos}, - {"trefdir", (getter)PyWcsprm_get_trefdir, (setter)PyWcsprm_set_trefdir, (char *) doc_trefdir}, - {"tstart", (getter)PyWcsprm_get_tstart, (setter)PyWcsprm_set_tstart, (char *) doc_tstart}, - {"tstop", (getter)PyWcsprm_get_tstop, (setter)PyWcsprm_set_tstop, (char *) doc_tstop}, - {"telapse", (getter)PyWcsprm_get_telapse, (setter)PyWcsprm_set_telapse, (char *) doc_telapse}, - {"timeoffs", (getter)PyWcsprm_get_timeoffs, (setter)PyWcsprm_set_timeoffs, (char *) doc_timeoffs}, - {"timsyer", (getter)PyWcsprm_get_timsyer, (setter)PyWcsprm_set_timsyer, (char *) doc_timsyer}, - {"timrder", (getter)PyWcsprm_get_timrder, (setter)PyWcsprm_set_timrder, (char *) doc_timrder}, - {"timedel", (getter)PyWcsprm_get_timedel, (setter)PyWcsprm_set_timedel, (char *) doc_timedel}, - {"timepixr", (getter)PyWcsprm_get_timepixr, (setter)PyWcsprm_set_timepixr, (char *) doc_timepixr}, - {"timeunit", (getter)PyWcsprm_get_timeunit, (setter)PyWcsprm_set_timeunit, (char *) doc_timeunit}, - {"velangl", (getter)PyWcsprm_get_velangl, (setter)PyWcsprm_set_velangl, (char *)doc_velangl}, - {"velosys", (getter)PyWcsprm_get_velosys, (setter)PyWcsprm_set_velosys, (char *)doc_velosys}, - {"velref", (getter)PyWcsprm_get_velref, (setter)PyWcsprm_set_velref, (char *)doc_velref}, - {"xposure", (getter)PyWcsprm_get_xposure, (setter)PyWcsprm_set_xposure, (char *)doc_xposure}, - {"wtb", (getter)PyWcsprm_get_wtb, NULL, (char *) doc_wtb}, - {"zsource", (getter)PyWcsprm_get_zsource, (setter)PyWcsprm_set_zsource, (char *)doc_zsource}, - {"_unit_scaling", (getter)PyWcsprm_get_unit_scaling, NULL, NULL}, // For debugging +static PyGetSetDef Wcsprm_getset[] = { + {"alt", (getter)Wcsprm_get_alt, (setter)Wcsprm_set_alt, (char *)doc_alt}, + {"aux", (getter)Wcsprm_get_aux, NULL, (char *)doc_aux}, + {"cel", (getter)Wcsprm_get_cel, NULL, (char *)doc_cel}, + {"axis_types", (getter)Wcsprm_get_axis_types, NULL, (char *)doc_axis_types}, + {"bepoch", (getter)Wcsprm_get_bepoch, (setter)Wcsprm_set_bepoch, (char *)doc_bepoch}, + {"cd", (getter)Wcsprm_get_cd, (setter)Wcsprm_set_cd, (char *)doc_cd}, + {"cdelt", (getter)Wcsprm_get_cdelt, (setter)Wcsprm_set_cdelt, (char *)doc_cdelt}, + {"cel_offset", (getter)Wcsprm_get_cel_offset, (setter)Wcsprm_set_cel_offset, (char *)doc_cel_offset}, + {"cname", (getter)Wcsprm_get_cname, (setter)Wcsprm_set_cname, (char *)doc_cname}, + {"colax", (getter)Wcsprm_get_colax, (setter)Wcsprm_set_colax, (char *)doc_colax}, + {"colnum", (getter)Wcsprm_get_colnum, (setter)Wcsprm_set_colnum, (char *)doc_colnum}, + {"crder", (getter)Wcsprm_get_crder, (setter)Wcsprm_set_crder, (char *)doc_crder}, + {"crota", (getter)Wcsprm_get_crota, (setter)Wcsprm_set_crota, (char *)doc_crota}, + {"crpix", (getter)Wcsprm_get_crpix, (setter)Wcsprm_set_crpix, (char *)doc_crpix}, + {"crval", (getter)Wcsprm_get_crval, (setter)Wcsprm_set_crval, (char *)doc_crval}, + {"csyer", (getter)Wcsprm_get_csyer, (setter)Wcsprm_set_csyer, (char *)doc_csyer}, + {"ctype", (getter)Wcsprm_get_ctype, (setter)Wcsprm_set_ctype, (char *)doc_ctype}, + {"cubeface", (getter)Wcsprm_get_cubeface, (setter)Wcsprm_set_cubeface, (char *)doc_cubeface}, + {"cunit", (getter)Wcsprm_get_cunit, (setter)Wcsprm_set_cunit, (char *)doc_cunit}, + {"czphs", (getter)Wcsprm_get_czphs, (setter)Wcsprm_set_czphs, (char *)doc_czphs}, + {"cperi", (getter)Wcsprm_get_cperi, (setter)Wcsprm_set_cperi, (char *)doc_cperi}, + {"dateavg", (getter)Wcsprm_get_dateavg, (setter)Wcsprm_set_dateavg, (char *)doc_dateavg}, + {"datebeg", (getter)Wcsprm_get_datebeg, (setter)Wcsprm_set_datebeg, (char *)doc_datebeg}, + {"dateend", (getter)Wcsprm_get_dateend, (setter)Wcsprm_set_dateend, (char *)doc_dateend}, + {"dateobs", (getter)Wcsprm_get_dateobs, (setter)Wcsprm_set_dateobs, (char *)doc_dateobs}, + {"dateref", (getter)Wcsprm_get_dateref, (setter)Wcsprm_set_dateref, (char *)doc_dateref}, + {"equinox", (getter)Wcsprm_get_equinox, (setter)Wcsprm_set_equinox, (char *)doc_equinox}, + {"imgpix_matrix", (getter)Wcsprm_get_imgpix_matrix, NULL, (char *)doc_imgpix_matrix}, + {"jepoch", (getter)Wcsprm_get_jepoch, (setter)Wcsprm_set_jepoch, (char *)doc_jepoch}, + {"lat", (getter)Wcsprm_get_lat, NULL, (char *)doc_lat}, + {"latpole", (getter)Wcsprm_get_latpole, (setter)Wcsprm_set_latpole, (char *)doc_latpole}, + {"lattyp", (getter)Wcsprm_get_lattyp, NULL, (char *)doc_lattyp}, + {"lng", (getter)Wcsprm_get_lng, NULL, (char *)doc_lng}, + {"lngtyp", (getter)Wcsprm_get_lngtyp, NULL, (char *)doc_lngtyp}, + {"lonpole", (getter)Wcsprm_get_lonpole, (setter)Wcsprm_set_lonpole, (char *)doc_lonpole}, + {"mjdavg", (getter)Wcsprm_get_mjdavg, (setter)Wcsprm_set_mjdavg, (char *)doc_mjdavg}, + {"mjdbeg", (getter)Wcsprm_get_mjdbeg, (setter)Wcsprm_set_mjdbeg, (char *)doc_mjdbeg}, + {"mjdend", (getter)Wcsprm_get_mjdend, (setter)Wcsprm_set_mjdend, (char *)doc_mjdend}, + {"mjdobs", (getter)Wcsprm_get_mjdobs, (setter)Wcsprm_set_mjdobs, (char *)doc_mjdobs}, + {"mjdref", (getter)Wcsprm_get_mjdref, (setter)Wcsprm_set_mjdref, (char *)doc_mjdref}, + {"name", (getter)Wcsprm_get_name, (setter)Wcsprm_set_name, (char *)doc_name}, + {"naxis", (getter)Wcsprm_get_naxis, NULL, (char *)doc_naxis}, + {"obsgeo", (getter)Wcsprm_get_obsgeo, (setter)Wcsprm_set_obsgeo, (char *)doc_obsgeo}, + {"obsorbit", (getter)Wcsprm_get_obsorbit, (setter)Wcsprm_set_obsorbit, (char *)doc_obsorbit}, + {"pc", (getter)Wcsprm_get_pc, (setter)Wcsprm_set_pc, (char *)doc_pc}, + {"phi0", (getter)Wcsprm_get_phi0, (setter)Wcsprm_set_phi0, (char *)doc_phi0}, + {"piximg_matrix", (getter)Wcsprm_get_piximg_matrix, NULL, (char *)doc_piximg_matrix}, + {"plephem", (getter)Wcsprm_get_plephem, (setter)Wcsprm_set_plephem, (char *) doc_plephem}, + {"radesys", (getter)Wcsprm_get_radesys, (setter)Wcsprm_set_radesys, (char *)doc_radesys}, + {"restfrq", (getter)Wcsprm_get_restfrq, (setter)Wcsprm_set_restfrq, (char *)doc_restfrq}, + {"restwav", (getter)Wcsprm_get_restwav, (setter)Wcsprm_set_restwav, (char *)doc_restwav}, + {"spec", (getter)Wcsprm_get_spec, NULL, (char *)doc_spec}, + {"specsys", (getter)Wcsprm_get_specsys, (setter)Wcsprm_set_specsys, (char *)doc_specsys}, + {"ssysobs", (getter)Wcsprm_get_ssysobs, (setter)Wcsprm_set_ssysobs, (char *)doc_ssysobs}, + {"ssyssrc", (getter)Wcsprm_get_ssyssrc, (setter)Wcsprm_set_ssyssrc, (char *)doc_ssyssrc}, + {"tab", (getter)Wcsprm_get_tab, NULL, (char *)doc_tab}, + {"theta0", (getter)Wcsprm_get_theta0, (setter)Wcsprm_set_theta0, (char *)doc_theta0}, + {"timesys", (getter)Wcsprm_get_timesys, (setter)Wcsprm_set_timesys, (char *) doc_timesys}, + {"trefpos", (getter)Wcsprm_get_trefpos, (setter)Wcsprm_set_trefpos, (char *) doc_trefpos}, + {"trefdir", (getter)Wcsprm_get_trefdir, (setter)Wcsprm_set_trefdir, (char *) doc_trefdir}, + {"tstart", (getter)Wcsprm_get_tstart, (setter)Wcsprm_set_tstart, (char *) doc_tstart}, + {"tstop", (getter)Wcsprm_get_tstop, (setter)Wcsprm_set_tstop, (char *) doc_tstop}, + {"telapse", (getter)Wcsprm_get_telapse, (setter)Wcsprm_set_telapse, (char *) doc_telapse}, + {"timeoffs", (getter)Wcsprm_get_timeoffs, (setter)Wcsprm_set_timeoffs, (char *) doc_timeoffs}, + {"timsyer", (getter)Wcsprm_get_timsyer, (setter)Wcsprm_set_timsyer, (char *) doc_timsyer}, + {"timrder", (getter)Wcsprm_get_timrder, (setter)Wcsprm_set_timrder, (char *) doc_timrder}, + {"timedel", (getter)Wcsprm_get_timedel, (setter)Wcsprm_set_timedel, (char *) doc_timedel}, + {"timepixr", (getter)Wcsprm_get_timepixr, (setter)Wcsprm_set_timepixr, (char *) doc_timepixr}, + {"timeunit", (getter)Wcsprm_get_timeunit, (setter)Wcsprm_set_timeunit, (char *) doc_timeunit}, + {"velangl", (getter)Wcsprm_get_velangl, (setter)Wcsprm_set_velangl, (char *)doc_velangl}, + {"velosys", (getter)Wcsprm_get_velosys, (setter)Wcsprm_set_velosys, (char *)doc_velosys}, + {"velref", (getter)Wcsprm_get_velref, (setter)Wcsprm_set_velref, (char *)doc_velref}, + {"xposure", (getter)Wcsprm_get_xposure, (setter)Wcsprm_set_xposure, (char *)doc_xposure}, + {"wtb", (getter)Wcsprm_get_wtb, NULL, (char *) doc_wtb}, + {"zsource", (getter)Wcsprm_get_zsource, (setter)Wcsprm_set_zsource, (char *)doc_zsource}, + {"_unit_scaling", (getter)Wcsprm_get_unit_scaling, NULL, NULL}, // For debugging {NULL} }; -static PyMethodDef PyWcsprm_methods[] = { - {"bounds_check", (PyCFunction)PyWcsprm_bounds_check, METH_VARARGS|METH_KEYWORDS, doc_bounds_check}, - {"cdfix", (PyCFunction)PyWcsprm_cdfix, METH_NOARGS, doc_cdfix}, - {"celfix", (PyCFunction)PyWcsprm_celfix, METH_NOARGS, doc_celfix}, - {"compare", (PyCFunction)PyWcsprm_compare, METH_VARARGS|METH_KEYWORDS, doc_compare}, - {"__copy__", (PyCFunction)PyWcsprm_copy, METH_NOARGS, doc_copy}, - {"cylfix", (PyCFunction)PyWcsprm_cylfix, METH_VARARGS|METH_KEYWORDS, doc_cylfix}, - {"datfix", (PyCFunction)PyWcsprm_datfix, METH_NOARGS, doc_datfix}, - {"__deepcopy__", (PyCFunction)PyWcsprm_copy, METH_O, doc_copy}, - {"fix", (PyCFunction)PyWcsprm_fix, METH_VARARGS|METH_KEYWORDS, doc_fix}, - {"get_cdelt", (PyCFunction)PyWcsprm_get_cdelt_func, METH_NOARGS, doc_get_cdelt}, - {"get_pc", (PyCFunction)PyWcsprm_get_pc_func, METH_NOARGS, doc_get_pc}, - {"get_ps", (PyCFunction)PyWcsprm_get_ps, METH_NOARGS, doc_get_ps}, - {"get_pv", (PyCFunction)PyWcsprm_get_pv, METH_NOARGS, doc_get_pv}, - {"has_cd", (PyCFunction)PyWcsprm_has_cdi_ja, METH_NOARGS, doc_has_cd}, - {"has_cdi_ja", (PyCFunction)PyWcsprm_has_cdi_ja, METH_NOARGS, doc_has_cdi_ja}, - {"has_crota", (PyCFunction)PyWcsprm_has_crotaia, METH_NOARGS, doc_has_crota}, - {"has_crotaia", (PyCFunction)PyWcsprm_has_crotaia, METH_NOARGS, doc_has_crotaia}, - {"has_pc", (PyCFunction)PyWcsprm_has_pci_ja, METH_NOARGS, doc_has_pc}, - {"has_pci_ja", (PyCFunction)PyWcsprm_has_pci_ja, METH_NOARGS, doc_has_pci_ja}, - {"is_unity", (PyCFunction)PyWcsprm_is_unity, METH_NOARGS, doc_is_unity}, - {"mix", (PyCFunction)PyWcsprm_mix, METH_VARARGS|METH_KEYWORDS, doc_mix}, - {"p2s", (PyCFunction)PyWcsprm_p2s, METH_VARARGS|METH_KEYWORDS, doc_p2s}, - {"print_contents", (PyCFunction)PyWcsprm_print_contents, METH_NOARGS, doc_print_contents}, - {"s2p", (PyCFunction)PyWcsprm_s2p, METH_VARARGS|METH_KEYWORDS, doc_s2p}, - {"set", (PyCFunction)PyWcsprm_set, METH_NOARGS, doc_set}, - {"set_ps", (PyCFunction)PyWcsprm_set_ps, METH_O, doc_set_ps}, - {"set_pv", (PyCFunction)PyWcsprm_set_pv, METH_O, doc_set_pv}, - {"spcfix", (PyCFunction)PyWcsprm_spcfix, METH_NOARGS, doc_spcfix}, - {"sptr", (PyCFunction)PyWcsprm_sptr, METH_VARARGS|METH_KEYWORDS, doc_sptr}, - {"sub", (PyCFunction)PyWcsprm_sub, METH_VARARGS|METH_KEYWORDS, doc_sub}, - {"to_header", (PyCFunction)PyWcsprm_to_header, METH_VARARGS|METH_KEYWORDS, doc_to_header}, - {"unitfix", (PyCFunction)PyWcsprm_unitfix, METH_VARARGS|METH_KEYWORDS, doc_unitfix}, +static PyMethodDef Wcsprm_methods[] = { + {"bounds_check", (PyCFunction)Wcsprm_bounds_check, METH_VARARGS|METH_KEYWORDS, doc_bounds_check}, + {"cdfix", (PyCFunction)Wcsprm_cdfix, METH_NOARGS, doc_cdfix}, + {"celfix", (PyCFunction)Wcsprm_celfix, METH_NOARGS, doc_celfix}, + {"compare", (PyCFunction)Wcsprm_compare, METH_VARARGS|METH_KEYWORDS, doc_compare}, + {"__copy__", (PyCFunction)Wcsprm_copy, METH_NOARGS, doc_copy}, + {"cylfix", (PyCFunction)Wcsprm_cylfix, METH_VARARGS|METH_KEYWORDS, doc_cylfix}, + {"datfix", (PyCFunction)Wcsprm_datfix, METH_NOARGS, doc_datfix}, + {"__deepcopy__", (PyCFunction)Wcsprm_copy, METH_O, doc_copy}, + {"fix", (PyCFunction)Wcsprm_fix, METH_VARARGS|METH_KEYWORDS, doc_fix}, + {"get_cdelt", (PyCFunction)Wcsprm_get_cdelt_func, METH_NOARGS, doc_get_cdelt}, + {"get_pc", (PyCFunction)Wcsprm_get_pc_func, METH_NOARGS, doc_get_pc}, + {"get_ps", (PyCFunction)Wcsprm_get_ps, METH_NOARGS, doc_get_ps}, + {"get_pv", (PyCFunction)Wcsprm_get_pv, METH_NOARGS, doc_get_pv}, + {"has_cd", (PyCFunction)Wcsprm_has_cdi_ja, METH_NOARGS, doc_has_cd}, + {"has_cdi_ja", (PyCFunction)Wcsprm_has_cdi_ja, METH_NOARGS, doc_has_cdi_ja}, + {"has_crota", (PyCFunction)Wcsprm_has_crotaia, METH_NOARGS, doc_has_crota}, + {"has_crotaia", (PyCFunction)Wcsprm_has_crotaia, METH_NOARGS, doc_has_crotaia}, + {"has_pc", (PyCFunction)Wcsprm_has_pci_ja, METH_NOARGS, doc_has_pc}, + {"has_pci_ja", (PyCFunction)Wcsprm_has_pci_ja, METH_NOARGS, doc_has_pci_ja}, + {"is_unity", (PyCFunction)Wcsprm_is_unity, METH_NOARGS, doc_is_unity}, + {"mix", (PyCFunction)Wcsprm_mix, METH_VARARGS|METH_KEYWORDS, doc_mix}, + {"p2s", (PyCFunction)Wcsprm_p2s, METH_VARARGS|METH_KEYWORDS, doc_p2s}, + {"print_contents", (PyCFunction)Wcsprm_print_contents, METH_NOARGS, doc_print_contents}, + {"s2p", (PyCFunction)Wcsprm_s2p, METH_VARARGS|METH_KEYWORDS, doc_s2p}, + {"set", (PyCFunction)Wcsprm_set, METH_NOARGS, doc_set}, + {"set_ps", (PyCFunction)Wcsprm_set_ps, METH_O, doc_set_ps}, + {"set_pv", (PyCFunction)Wcsprm_set_pv, METH_O, doc_set_pv}, + {"spcfix", (PyCFunction)Wcsprm_spcfix, METH_NOARGS, doc_spcfix}, + {"sptr", (PyCFunction)Wcsprm_sptr, METH_VARARGS|METH_KEYWORDS, doc_sptr}, + {"sub", (PyCFunction)Wcsprm_sub, METH_VARARGS|METH_KEYWORDS, doc_sub}, + {"to_header", (PyCFunction)Wcsprm_to_header, METH_VARARGS|METH_KEYWORDS, doc_to_header}, + {"unitfix", (PyCFunction)Wcsprm_unitfix, METH_VARARGS|METH_KEYWORDS, doc_unitfix}, {NULL} }; -static PyType_Spec PyWcsprm_spec = { +static PyType_Spec Wcsprm_spec = { .name = "astropy.wcs.Wcsprm", - .basicsize = sizeof(PyWcsprm), + .basicsize = sizeof(Wcsprm), .itemsize = 0, .flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_IMMUTABLETYPE, .slots = (PyType_Slot[]) { - {Py_tp_dealloc, (destructor)PyWcsprm_dealloc}, - {Py_tp_repr, (reprfunc)PyWcsprm___str__}, - {Py_tp_str, (reprfunc)PyWcsprm___str__}, + {Py_tp_dealloc, (destructor)Wcsprm_dealloc}, + {Py_tp_repr, (reprfunc)Wcsprm___str__}, + {Py_tp_str, (reprfunc)Wcsprm___str__}, {Py_tp_doc, doc_Wcsprm}, - {Py_tp_richcompare, PyWcsprm_richcompare}, - {Py_tp_methods, PyWcsprm_methods}, - {Py_tp_getset, PyWcsprm_getset}, - {Py_tp_init, (initproc)PyWcsprm_init}, - {Py_tp_new, PyWcsprm_new}, + {Py_tp_richcompare, Wcsprm_richcompare}, + {Py_tp_methods, Wcsprm_methods}, + {Py_tp_getset, Wcsprm_getset}, + {Py_tp_init, (initproc)Wcsprm_init}, + {Py_tp_new, Wcsprm_new}, {0, NULL}, }, }; -PyObject* PyWcsprmType = NULL; +PyObject* WcsprmType = NULL; #define CONSTANT(a) PyModule_AddIntConstant(m, #a, a) #define CONSTANT2(n, v) PyModule_AddIntConstant(m, n, v) @@ -4657,7 +4706,6 @@ int add_prj_codes(PyObject* module) for (k = 0; k < prj_ncode; k++) { code = PyUnicode_FromString(prj_codes[k]); if (PyList_SetItem(list, k, code)) { - Py_DECREF(code); Py_DECREF(list); return -1; } @@ -4673,9 +4721,9 @@ int add_prj_codes(PyObject* module) int _setup_wcsprm_type( PyObject* m) { - PyWcsprmType = PyType_FromSpec(&PyWcsprm_spec); + WcsprmType = PyType_FromSpec(&Wcsprm_spec); - if (PyWcsprmType == NULL) { + if (WcsprmType == NULL) { return -1; } @@ -4683,7 +4731,7 @@ _setup_wcsprm_type( wcserr_enable(1); return ( - PyModule_AddObject(m, "Wcsprm", PyWcsprmType) || + PyModule_AddObject(m, "Wcsprm", WcsprmType) || CONSTANT(WCSSUB_LONGITUDE) || CONSTANT(WCSSUB_LATITUDE) || CONSTANT(WCSSUB_CUBEFACE) || diff --git a/astropy/wcs/src/wcslib_wtbarr_wrap.c b/astropy/wcs/src/wcslib_wtbarr_wrap.c index 23c570df3f49..8f2fe3890dbd 100644 --- a/astropy/wcs/src/wcslib_wtbarr_wrap.c +++ b/astropy/wcs/src/wcslib_wtbarr_wrap.c @@ -17,20 +17,20 @@ /*************************************************************************** - * PyWtbarr methods * + * Wtbarr methods * ***************************************************************************/ static PyObject* -PyWtbarr_new(PyTypeObject* type, PyObject* args, PyObject* kwds) { - PyWtbarr* self; +Wtbarr_new(PyTypeObject* type, PyObject* args, PyObject* kwds) { + Wtbarr* self; allocfunc alloc_func = PyType_GetSlot(type, Py_tp_alloc); - self = (PyWtbarr*)alloc_func(type, 0); + self = (Wtbarr*)alloc_func(type, 0); return (PyObject*)self; } static int -PyWtbarr_traverse(PyWtbarr* self, visitproc visit, void *arg) { +Wtbarr_traverse(Wtbarr* self, visitproc visit, void *arg) { Py_VISIT(self->owner); Py_VISIT(Py_TYPE((PyObject*)self)); return 0; @@ -38,14 +38,14 @@ PyWtbarr_traverse(PyWtbarr* self, visitproc visit, void *arg) { static int -PyWtbarr_clear(PyWtbarr* self) { +Wtbarr_clear(Wtbarr* self) { Py_CLEAR(self->owner); return 0; } -static void PyWtbarr_dealloc(PyWtbarr* self) { - PyWtbarr_clear(self); +static void Wtbarr_dealloc(Wtbarr* self) { + Wtbarr_clear(self); PyTypeObject *tp = Py_TYPE((PyObject*)self); freefunc free_func = PyType_GetSlot(tp, Py_tp_free); free_func((PyObject*)self); @@ -53,11 +53,11 @@ static void PyWtbarr_dealloc(PyWtbarr* self) { } -PyWtbarr* PyWtbarr_cnew(PyObject* wcsprm, struct wtbarr* x) { - PyWtbarr* self; - PyTypeObject* type = (PyTypeObject*)PyWtbarrType; +Wtbarr* Wtbarr_cnew(PyObject* wcsprm, struct wtbarr* x) { + Wtbarr* self; + PyTypeObject* type = (PyTypeObject*)WtbarrType; allocfunc alloc_func = PyType_GetSlot(type, Py_tp_alloc); - self = (PyWtbarr*)alloc_func(type, 0); + self = (Wtbarr*)alloc_func(type, 0); if (self == NULL) return NULL; self->x = x; Py_INCREF(wcsprm); @@ -93,7 +93,7 @@ static void wtbarrprt(const struct wtbarr *wtb) { } -static PyObject* PyWtbarr_print_contents(PyWtbarr* self) { +static PyObject* Wtbarr_print_contents(Wtbarr* self) { /* This is not thread-safe, but since we're holding onto the GIL, we can assume we won't have thread conflicts */ wcsprintf_set(NULL); @@ -104,7 +104,7 @@ static PyObject* PyWtbarr_print_contents(PyWtbarr* self) { } -static PyObject* PyWtbarr___str__(PyWtbarr* self) { +static PyObject* Wtbarr___str__(Wtbarr* self) { /* This is not thread-safe, but since we're holding onto the GIL, we can assume we won't have thread conflicts */ wcsprintf_set(NULL); @@ -118,108 +118,108 @@ static PyObject* PyWtbarr___str__(PyWtbarr* self) { */ -static PyObject* PyWtbarr_get_i(PyWtbarr* self, void* closure) { +static PyObject* Wtbarr_get_i(Wtbarr* self, void* closure) { return get_int("i", self->x->i); } -static PyObject* PyWtbarr_get_m(PyWtbarr* self, void* closure) { +static PyObject* Wtbarr_get_m(Wtbarr* self, void* closure) { return get_int("m", self->x->m); } -static PyObject* PyWtbarr_get_extver(PyWtbarr* self, void* closure) { +static PyObject* Wtbarr_get_extver(Wtbarr* self, void* closure) { return get_int("extver", self->x->extver); } -static PyObject* PyWtbarr_get_extlev(PyWtbarr* self, void* closure) { +static PyObject* Wtbarr_get_extlev(Wtbarr* self, void* closure) { return get_int("extlev", self->x->extlev); } -static PyObject* PyWtbarr_get_ndim(PyWtbarr* self, void* closure) { +static PyObject* Wtbarr_get_ndim(Wtbarr* self, void* closure) { return get_int("ndim", self->x->ndim); } -static PyObject* PyWtbarr_get_row(PyWtbarr* self, void* closure) { +static PyObject* Wtbarr_get_row(Wtbarr* self, void* closure) { return get_int("row", self->x->row); } -static PyObject* PyWtbarr_get_extnam(PyWtbarr* self, void* closure) { +static PyObject* Wtbarr_get_extnam(Wtbarr* self, void* closure) { if (is_null(self->x->extnam)) return NULL; return get_string("extnam", self->x->extnam); } -static PyObject* PyWtbarr_get_ttype(PyWtbarr* self, void* closure) { +static PyObject* Wtbarr_get_ttype(Wtbarr* self, void* closure) { if (is_null(self->x->ttype)) return NULL; return get_string("ttype", self->x->ttype); } -static PyObject* PyWtbarr_get_kind(PyWtbarr* self, void* closure) { +static PyObject* Wtbarr_get_kind(Wtbarr* self, void* closure) { return PyUnicode_FromFormat("%c", self->x->kind); } /*************************************************************************** - * PyWtbarr definition structures + * Wtbarr definition structures */ -static PyGetSetDef PyWtbarr_getset[] = { - {"i", (getter)PyWtbarr_get_i, NULL, (char *) doc_i}, - {"m", (getter)PyWtbarr_get_m, NULL, (char *) doc_m}, - {"kind", (getter)PyWtbarr_get_kind, NULL, (char *) doc_kind}, - {"extnam", (getter)PyWtbarr_get_extnam, NULL, (char *) doc_extnam}, - {"extver", (getter)PyWtbarr_get_extver, NULL, (char *) doc_extver}, - {"extlev", (getter)PyWtbarr_get_extlev, NULL, (char *) doc_extlev}, - {"ttype", (getter)PyWtbarr_get_ttype, NULL, (char *) doc_ttype}, - {"row", (getter)PyWtbarr_get_row, NULL, (char *) doc_row}, - {"ndim", (getter)PyWtbarr_get_ndim, NULL, (char *) doc_ndim}, -/* {"dimlen", (getter)PyWtbarr_get_dimlen, NULL, (char *) NULL}, */ -/* {"arrayp", (getter)PyWtbarr_get_arrayp, NULL, (char *) NULL}, */ +static PyGetSetDef Wtbarr_getset[] = { + {"i", (getter)Wtbarr_get_i, NULL, (char *) doc_i}, + {"m", (getter)Wtbarr_get_m, NULL, (char *) doc_m}, + {"kind", (getter)Wtbarr_get_kind, NULL, (char *) doc_kind}, + {"extnam", (getter)Wtbarr_get_extnam, NULL, (char *) doc_extnam}, + {"extver", (getter)Wtbarr_get_extver, NULL, (char *) doc_extver}, + {"extlev", (getter)Wtbarr_get_extlev, NULL, (char *) doc_extlev}, + {"ttype", (getter)Wtbarr_get_ttype, NULL, (char *) doc_ttype}, + {"row", (getter)Wtbarr_get_row, NULL, (char *) doc_row}, + {"ndim", (getter)Wtbarr_get_ndim, NULL, (char *) doc_ndim}, +/* {"dimlen", (getter)Wtbarr_get_dimlen, NULL, (char *) NULL}, */ +/* {"arrayp", (getter)Wtbarr_get_arrayp, NULL, (char *) NULL}, */ {NULL} }; -static PyMethodDef PyWtbarr_methods[] = { - {"print_contents", (PyCFunction)PyWtbarr_print_contents, METH_NOARGS, doc_print_contents_wtbarr}, +static PyMethodDef Wtbarr_methods[] = { + {"print_contents", (PyCFunction)Wtbarr_print_contents, METH_NOARGS, doc_print_contents_wtbarr}, {NULL} }; -static PyType_Spec PyWtbarrType_spec = { +static PyType_Spec WtbarrType_spec = { .name = "astropy.wcs.Wtbarr", - .basicsize = sizeof(PyWtbarr), + .basicsize = sizeof(Wtbarr), .itemsize = 0, .flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_IMMUTABLETYPE, .slots = (PyType_Slot[]){ - {Py_tp_dealloc, (destructor)PyWtbarr_dealloc}, - {Py_tp_str, (reprfunc)PyWtbarr___str__}, + {Py_tp_dealloc, (destructor)Wtbarr_dealloc}, + {Py_tp_str, (reprfunc)Wtbarr___str__}, {Py_tp_doc, doc_Wtbarr}, - {Py_tp_traverse, (traverseproc)PyWtbarr_traverse}, - {Py_tp_clear, (inquiry)PyWtbarr_clear}, - {Py_tp_getset, PyWtbarr_getset}, - {Py_tp_methods, PyWtbarr_methods}, + {Py_tp_traverse, (traverseproc)Wtbarr_traverse}, + {Py_tp_clear, (inquiry)Wtbarr_clear}, + {Py_tp_getset, Wtbarr_getset}, + {Py_tp_methods, Wtbarr_methods}, // FIXME: this seems logical but this slot was not previously defined // maybe an error from https://github.com/astropy/astropy/pull/9641 ? - // {Py_tp_new, (newfunc)PyWtbarr_new}, + // {Py_tp_new, (newfunc)Wtbarr_new}, {0, NULL}, }, }; -PyObject* PyWtbarrType = NULL; +PyObject* WtbarrType = NULL; int _setup_wtbarr_type(PyObject* m) { - PyWtbarrType = PyType_FromSpec(&PyWtbarrType_spec); - if (PyWtbarrType == NULL) { + WtbarrType = PyType_FromSpec(&WtbarrType_spec); + if (WtbarrType == NULL) { return -1; } - PyModule_AddObject(m, "Wtbarr", PyWtbarrType); + PyModule_AddObject(m, "Wtbarr", WtbarrType); return 0; } diff --git a/astropy/wcs/tests/test_celprm.py b/astropy/wcs/tests/test_celprm.py index 5d5f07cdc862..cf40d5a89c28 100644 --- a/astropy/wcs/tests/test_celprm.py +++ b/astropy/wcs/tests/test_celprm.py @@ -10,10 +10,10 @@ def test_celprm_init(): - # test PyCelprm_cnew + # test Celprm_cnew assert wcs.WCS().wcs.cel - # test PyCelprm_new + # test Celprm_new assert wcs.Celprm() with pytest.raises(wcs.InvalidPrjParametersError): diff --git a/astropy/wcs/tests/test_prjprm.py b/astropy/wcs/tests/test_prjprm.py index e1e18f5e8ba1..70eae6cef9a8 100644 --- a/astropy/wcs/tests/test_prjprm.py +++ b/astropy/wcs/tests/test_prjprm.py @@ -8,10 +8,10 @@ def test_prjprm_init(): - # test PyPrjprm_cnew + # test Prjprm_cnew assert wcs.WCS().wcs.cel.prj - # test PyPrjprm_new + # test Prjprm_new assert wcs.Prjprm() with pytest.raises(wcs.InvalidPrjParametersError): diff --git a/astropy/wcs/tests/test_wcs.py b/astropy/wcs/tests/test_wcs.py index 240c3ccc5955..843597979c6a 100644 --- a/astropy/wcs/tests/test_wcs.py +++ b/astropy/wcs/tests/test_wcs.py @@ -1,5 +1,6 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst +import copy import io import os import re @@ -2201,7 +2202,7 @@ def test_header(self): "all_world2pix", ), ) - def test_programmatic(self, explicit_set, function): + def test_programmatic_conversions(self, explicit_set, function): # Make sure that things work fine if we make the WCS programmatically # and not from a header @@ -2215,10 +2216,6 @@ def test_programmatic(self, explicit_set, function): if explicit_set: wcs_prog.wcs.set() - # FIXME: the following fails if explicit_set is False because wcsset - # gets called implicitly during the coordinate conversion but we don't - # catch this and store the before/after units. - if function == "p2s": assert_allclose( wcs_prog.wcs.p2s(np.array([[1, 1]]), 1)["world"], [[10, 20]] @@ -2236,6 +2233,46 @@ def test_programmatic(self, explicit_set, function): elif function == "all_world2pix": assert_allclose(wcs_prog.all_world2pix(10, 20, 1), [1, 1]) + @pytest.mark.parametrize("explicit_set", (False, True)) + def test_programmatic_to_header(self, explicit_set): + # Make sure that to_header works fine if we make the WCS programmatically + # and not from a header + + wcs_prog = wcs.WCS(naxis=2, preserve_units=True) + wcs_prog.wcs.ctype = "RA---TAN", "DEC--TAN" + wcs_prog.wcs.cunit = "arcsec", "arcsec" + wcs_prog.wcs.crval = 10, 20 + wcs_prog.wcs.cdelt = 1, 2 + wcs_prog.wcs.crpix = 1, 1 + + if explicit_set: + wcs_prog.wcs.set() + + header = wcs_prog.to_header() + + expected_header = """ +WCSAXES = 2 / Number of coordinate axes +CRPIX1 = 1.0 / Pixel coordinate of reference point +CRPIX2 = 1.0 / Pixel coordinate of reference point +CDELT1 = 1.0 / [arcsec] Coordinate increment at reference poin +CDELT2 = 2.0 / [arcsec] Coordinate increment at reference poin +CUNIT1 = 'arcsec' / Units of coordinate increment and value +CUNIT2 = 'arcsec' / Units of coordinate increment and value +CTYPE1 = 'RA---TAN' / Right ascension, gnomonic projection +CTYPE2 = 'DEC--TAN' / Declination, gnomonic projection +CRVAL1 = 10.0 / [arcsec] Coordinate value at reference point +CRVAL2 = 20.0 / [arcsec] Coordinate value at reference point +LONPOLE = 180.0 / [deg] Native longitude of celestial pole +LATPOLE = 0.0055555555555556 / [deg] Native latitude of celestial pole +MJDREF = 0.0 / [d] MJD of fiducial time +RADESYS = 'ICRS' / Equatorial coordinate system +END +""".strip() + + assert header.tostring(sep="\n") == fits.Header.fromstring( + expected_header, sep="\n" + ).tostring(sep="\n") + def test_change_cunit(self): # Make sure that things work fine if we make the WCS programmatically # and not from a header, and check that we can change the units after @@ -2579,6 +2616,19 @@ def test_sub(self): np.array([1 / 3600, 1e9, 1.0, 1 / 3600, 1e-9, 1]), ) + def test_safe_set_cdelt_crval(self): + + simple_wcs = wcs.WCS(naxis=2, preserve_units=True) + simple_wcs.wcs.cunit = "arcsec", "arcsec" + simple_wcs.wcs.set() + + # At this point original_cunit is set but cunit_scaling has been + # set then freed since all values in it are 1 (units only get + # converted to degrees once ctype is set) + + simple_wcs.wcs.cdelt = -1, 2 + simple_wcs.wcs.crval = 3, 4 + def test_thread_safe_conversions(): # This is a regression test for a bug which caused wcsset to be called @@ -2610,3 +2660,23 @@ def round_trip_transform(pixel): results = pool.map(round_trip_transform, (pixel,) * 8) for pixel2 in results: assert_allclose(pixel, pixel2, atol=1e-7) + + +@pytest.fixture +def shared_tan_wcs(): + w = wcs.WCS(naxis=2) + w.wcs.ctype = ["RA---TAN", "DEC--TAN"] + w.wcs.set() + return w + + +@pytest.mark.force_parallel_threads(8) +@pytest.mark.iterations(25) +def test_deepcopy_during_lazy_cache_population(shared_tan_wcs): + # Deepcopying a shared WCS while another thread's first pixel_to_world + # call lazily inserts _components_and_classes_cache into __dict__ raised + # RuntimeError; the pop re-arms that lazy insertion on every iteration. + shared_tan_wcs.__dict__.pop("_components_and_classes_cache", None) + copy.deepcopy(shared_tan_wcs) + shared_tan_wcs.pixel_to_world(0, 0) + copy.deepcopy(shared_tan_wcs) diff --git a/astropy/wcs/wcs.py b/astropy/wcs/wcs.py index ae925afe0406..37d150acc66a 100644 --- a/astropy/wcs/wcs.py +++ b/astropy/wcs/wcs.py @@ -732,6 +732,12 @@ def __copy__(self): def __deepcopy__(self, memo): from copy import deepcopy + # Snapshot the instance dict up front so that we are not sensitive + # to it changing while this method runs (e.g. lazy caches inserted + # by another thread); unlike iteration, dict.copy() is atomic on + # free-threaded builds as it holds the dict's per-object lock + state = self.__dict__.copy() + new_copy = self.__class__() new_copy.naxis = deepcopy(self.naxis, memo) WCSBase.__init__( @@ -741,7 +747,7 @@ def __deepcopy__(self, memo): deepcopy(self.wcs, memo), (deepcopy(self.det2im1, memo), deepcopy(self.det2im2, memo)), ) - for key, val in self.__dict__.items(): + for key, val in state.items(): new_copy.__dict__[key] = deepcopy(val, memo) return new_copy @@ -3375,8 +3381,6 @@ def dropaxis(self, dropax): Parameters ---------- - wcs : `~astropy.wcs.WCS` - The WCS with naxis to be chopped to naxis-1 dropax : int The index of the WCS to drop, counting from 0 (i.e., python convention, not FITS convention) @@ -3400,8 +3404,6 @@ def swapaxes(self, ax0, ax1): Parameters ---------- - wcs : `~astropy.wcs.WCS` - The WCS to have its axes swapped ax0 : int ax1 : int The indices of the WCS to be swapped, counting from 0 (i.e., python diff --git a/astropy/wcs/wcsapi/fitswcs.py b/astropy/wcs/wcsapi/fitswcs.py index 3f76df474395..a0fa1104c094 100644 --- a/astropy/wcs/wcsapi/fitswcs.py +++ b/astropy/wcs/wcsapi/fitswcs.py @@ -397,13 +397,16 @@ def _get_components_and_classes(self): # it. We start off by defining a hash based on the attributes of the # WCS that matter here (we can't just use the WCS object as a hash since # it is mutable) + # NaN values must be normalized since NaN != NaN would otherwise + # defeat the cache comparison below. + equinox = self.wcs.equinox wcs_hash = ( self.naxis, list(self.wcs.ctype), list(self.wcs.cunit), self.wcs.radesys, self.wcs.specsys, - self.wcs.equinox, + None if np.isnan(equinox) else equinox, self.wcs.dateobs, self.wcs.lng, self.wcs.lat, @@ -572,6 +575,31 @@ def _get_components_and_classes(self): # of SpectralCoord - this is because we want to also be able to # accept plain quantities. + def apply_velocity_frame_change(spectralcoord): + if observer is None and spectralcoord.observer is None: + # When both observers are missing we silently skip the frame + # change since this is a common case and not worth warning + # about. + return spectralcoord + + if observer is None: + msg = "No observer defined on WCS" + elif spectralcoord.observer is None: + msg = "No observer defined on SpectralCoord" + elif spectralcoord.target is None: + msg = "No target defined on SpectralCoord" + else: + return spectralcoord.with_observer_stationary_relative_to(observer) + + warnings.warn( + f"{msg}, SpectralCoord " + "will be converted without any velocity " + "frame change", + AstropyUserWarning, + ) + + return spectralcoord + if ctype == "ZOPT": def spectralcoord_from_redshift(redshift): @@ -587,38 +615,11 @@ def spectralcoord_from_redshift(redshift): def redshift_from_spectralcoord(spectralcoord): # TODO: check target is consistent between WCS and SpectralCoord, # if they are not the transformation doesn't make conceptual sense. - if ( - observer is None - or spectralcoord.observer is None - or spectralcoord.target is None - ): - # Note that if observer and spectralcoord.observer are - # both None, we don't emit any warning since this is - # likely to be fine and is a very common use case. It - # is more important to emit a warning if one has - # observer information and the other does not. - if not (observer is None and spectralcoord.observer is None): - if observer is None: - msg = "No observer defined on WCS" - elif spectralcoord.observer is None: - msg = "No observer defined on SpectralCoord" - else: - msg = "No target defined on SpectralCoord" - warnings.warn( - f"{msg}, SpectralCoord " - "will be converted without any velocity " - "frame change", - AstropyUserWarning, - ) - return spectralcoord.to_value(u.m) / self.wcs.restwav - 1.0 - else: - return ( - spectralcoord.with_observer_stationary_relative_to( - observer - ).to_value(u.m) - / self.wcs.restwav - - 1.0 - ) + return ( + apply_velocity_frame_change(spectralcoord).to_value(u.m) + / self.wcs.restwav + - 1.0 + ) classes["spectral"] = (u.Quantity, (), {}, spectralcoord_from_redshift) components[self.wcs.spec] = ("spectral", 0, redshift_from_spectralcoord) @@ -641,37 +642,12 @@ def beta_from_spectralcoord(spectralcoord): # TODO: check target is consistent between WCS and SpectralCoord, # if they are not the transformation doesn't make conceptual sense. doppler_equiv = u.doppler_relativistic(self.wcs.restwav * u.m) - if ( - observer is None - or spectralcoord.observer is None - or spectralcoord.target is None - ): - # Note that if observer and spectralcoord.observer are - # both None, we don't emit any warning since this is - # likely to be fine and is a very common use case. It - # is more important to emit a warning if one has - # observer information and the other does not. - if not (observer is None and spectralcoord.observer is None): - if observer is None: - msg = "No observer defined on WCS" - elif spectralcoord.observer is None: - msg = "No observer defined on SpectralCoord" - else: - msg = "No target defined on SpectralCoord" - warnings.warn( - f"{msg}, SpectralCoord " - "will be converted without any velocity " - "frame change", - AstropyUserWarning, - ) - return spectralcoord.to_value(u.m / u.s, doppler_equiv) / C_SI - else: - return ( - spectralcoord.with_observer_stationary_relative_to( - observer - ).to_value(u.m / u.s, doppler_equiv) - / C_SI + return ( + apply_velocity_frame_change(spectralcoord).to_value( + u.m / u.s, doppler_equiv ) + / C_SI + ) classes["spectral"] = (u.Quantity, (), {}, spectralcoord_from_beta) components[self.wcs.spec] = ("spectral", 0, beta_from_spectralcoord) @@ -730,34 +706,7 @@ def spectralcoord_from_value(value): def value_from_spectralcoord(spectralcoord): # TODO: check target is consistent between WCS and SpectralCoord, # if they are not the transformation doesn't make conceptual sense. - if ( - observer is None - or spectralcoord.observer is None - or spectralcoord.target is None - ): - # Note that if observer and spectralcoord.observer are - # both None, we don't emit any warning since this is - # likely to be fine and is a very common use case. It - # is more important to emit a warning if one has - # observer information and the other does not. - if not (observer is None and spectralcoord.observer is None): - if observer is None: - msg = "No observer defined on WCS" - elif spectralcoord.observer is None: - msg = "No observer defined on SpectralCoord" - else: - msg = "No target defined on SpectralCoord" - warnings.warn( - f"{msg}, SpectralCoord " - "will be converted without any velocity " - "frame change", - AstropyUserWarning, - ) - return spectralcoord.to_value(**kwargs) - else: - return spectralcoord.with_observer_stationary_relative_to( - observer - ).to_value(**kwargs) + return apply_velocity_frame_change(spectralcoord).to_value(**kwargs) classes["spectral"] = (u.Quantity, (), {}, spectralcoord_from_value) components[self.wcs.spec] = ("spectral", 0, value_from_spectralcoord) diff --git a/astropy/wcs/wcsapi/tests/test_fitswcs.py b/astropy/wcs/wcsapi/tests/test_fitswcs.py index 4ab31012ca33..77129cb54c1f 100644 --- a/astropy/wcs/wcsapi/tests/test_fitswcs.py +++ b/astropy/wcs/wcsapi/tests/test_fitswcs.py @@ -1691,3 +1691,13 @@ def test_world_to_pixel(self): x, y = self.wcs.world_to_pixel(coord) assert_array_equal(x.mask, coord.mask) assert_array_equal(y.mask, coord.mask) + + +def test_components_and_classes_cache(): + # Regression test for the components and classes cache never hitting + # when the equinox was undefined, because the NaN it contributed to the + # cache key compares unequal to itself + wcs = WCS(naxis=2) + wcs.wcs.ctype = ["RA---TAN", "DEC--TAN"] + wcs.wcs.set() + assert wcs.world_axis_object_components is wcs.world_axis_object_components diff --git a/conftest.py b/conftest.py index c265cd9107c0..08d4c0fa1f6f 100644 --- a/conftest.py +++ b/conftest.py @@ -18,6 +18,13 @@ # This has to be in the root dir or it will not display in CI. def pytest_configure(config): + # Strip COLUMNS/LINES so shutil.get_terminal_size() falls back to (80, 24) + # in the test session. Interactive shells (bash, zsh) export these on + # window resize, and pytest inherits them, so otherwise tests and doctests + # that read the terminal size are non-deterministic across environments. + os.environ.pop("COLUMNS", None) + os.environ.pop("LINES", None) + PYTEST_HEADER_MODULES["PyERFA"] = "erfa" PYTEST_HEADER_MODULES["Cython"] = "cython" PYTEST_HEADER_MODULES["Scikit-image"] = "skimage" diff --git a/docs/_static/astropy.css b/docs/_static/astropy.css index 50e0e161a65d..fffe2adbdc8a 100644 --- a/docs/_static/astropy.css +++ b/docs/_static/astropy.css @@ -1,25 +1,3 @@ -/* Main page overview cards */ - -.sd-card .sd-card-img-top { - height: 52px; - width: 52px; - margin-left: auto; - margin-right: auto; - margin-top: 10px; -} - -/* Dark theme tweaking */ -html[data-theme=dark] .sd-card img[src*='.svg'] { - filter: invert(0.82) brightness(0.8) contrast(1.2); -} - -/* Flip the colours on graphviz graphs on dark mode */ -html[data-theme="dark"] div.graphviz > object.inheritance { - filter: invert(0.82) brightness(0.8) contrast(1.2); -} -html[data-theme="dark"] div.graphviz > object.graphviz { - filter: invert(0.82) brightness(0.8) contrast(1.2); -} html[data-theme="dark"] ul.cooframelegend { filter: invert(0.82) brightness(0.8) contrast(1.2); } diff --git a/docs/_static/index-images/api.svg b/docs/_static/index-images/api.svg deleted file mode 100644 index e637525cc0b6..000000000000 --- a/docs/_static/index-images/api.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/_static/index-images/contributor.svg b/docs/_static/index-images/contributor.svg deleted file mode 100644 index 3a689e0e4cb2..000000000000 --- a/docs/_static/index-images/contributor.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/_static/index-images/getting_started.svg b/docs/_static/index-images/getting_started.svg deleted file mode 100644 index 04db7e615671..000000000000 --- a/docs/_static/index-images/getting_started.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/_static/index-images/learn.svg b/docs/_static/index-images/learn.svg deleted file mode 100644 index fa085c2211b0..000000000000 --- a/docs/_static/index-images/learn.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/_static/index-images/packages.svg b/docs/_static/index-images/packages.svg deleted file mode 100644 index c6118fd5ed6a..000000000000 --- a/docs/_static/index-images/packages.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/_static/index-images/user_guide.svg b/docs/_static/index-images/user_guide.svg deleted file mode 100644 index d61b0937da75..000000000000 --- a/docs/_static/index-images/user_guide.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/changes/18342.other.rst b/docs/changes/18342.other.rst deleted file mode 100644 index 0f731c0669a0..000000000000 --- a/docs/changes/18342.other.rst +++ /dev/null @@ -1,3 +0,0 @@ -Development-only dependencies, previously defined as user-visible -extras ``dev`` and ``dev_all``, were moved to PEP 735 dependency groups, and -are thus only accessible when building astropy from source. diff --git a/docs/changes/18962.other.rst b/docs/changes/18962.other.rst deleted file mode 100644 index c83bcf571eb8..000000000000 --- a/docs/changes/18962.other.rst +++ /dev/null @@ -1 +0,0 @@ -The minimum required NumPy version is now 1.25. diff --git a/docs/changes/18986.other.rst b/docs/changes/18986.other.rst deleted file mode 100644 index b2e557579749..000000000000 --- a/docs/changes/18986.other.rst +++ /dev/null @@ -1 +0,0 @@ -The minimum supported version of numpy is now 2.0. diff --git a/docs/changes/19050.other.rst b/docs/changes/19050.other.rst deleted file mode 100644 index 5de335b5f3d6..000000000000 --- a/docs/changes/19050.other.rst +++ /dev/null @@ -1 +0,0 @@ -Upgraded WCSLIB to version 8.5, fixing NaN handling in ``linp2x()`` and ``linx2p()``. For a full list of changes - see ``astropy/cextern/wcslib/CHANGES``. diff --git a/docs/changes/19347.other.rst b/docs/changes/19347.other.rst deleted file mode 100644 index dae27136e928..000000000000 --- a/docs/changes/19347.other.rst +++ /dev/null @@ -1 +0,0 @@ -Publishing files to PyPI is now done using the Trusted Publisher mechanism (https://docs.pypi.org/trusted-publishers/). diff --git a/docs/changes/19514.other.rst b/docs/changes/19514.other.rst deleted file mode 100644 index af2d42d4b0c7..000000000000 --- a/docs/changes/19514.other.rst +++ /dev/null @@ -1 +0,0 @@ -Upgraded WCSLIB to version 8.6. For a full list of changes - see ``astropy/cextern/wcslib/CHANGES``. diff --git a/docs/changes/19662.other.rst b/docs/changes/19662.other.rst deleted file mode 100644 index 7beb7eb12b64..000000000000 --- a/docs/changes/19662.other.rst +++ /dev/null @@ -1 +0,0 @@ -Updated the bundled CFITSIO library to 4.6.4. diff --git a/docs/changes/config/17571.api.rst b/docs/changes/config/17571.api.rst deleted file mode 100644 index c025cccb68f0..000000000000 --- a/docs/changes/config/17571.api.rst +++ /dev/null @@ -1,2 +0,0 @@ -The ``ConfNamespace.help`` method now raises an exception if called under -Python's optimized mode (``-OO`` flag). diff --git a/docs/changes/config/19559.bugfix.rst b/docs/changes/config/19559.bugfix.rst deleted file mode 100644 index 7da143c2926d..000000000000 --- a/docs/changes/config/19559.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -Disabling thread concurrency within ``set_temp_cache`` and ``set_temp_config`` -context managers, ensuring thread safety. diff --git a/docs/changes/config/19575.api.rst b/docs/changes/config/19575.api.rst deleted file mode 100644 index b6eb91b3be9e..000000000000 --- a/docs/changes/config/19575.api.rst +++ /dev/null @@ -1,16 +0,0 @@ -Cache and configuration directories now adhere to the XDG specification -by default. For example, the default cache location was changed from -``$HOME/.astropy/cache`` to ``$XDG_CACHE_HOME/astropy``, where -``XDG_CACHE_HOME`` itself defaults to ``$HOME/.cache``. - -Affected functions from the ``astropy.config`` namespace: - -- ``get_cache_dir`` -- ``get_cache_dir_path`` -- ``get_config_dir`` -- ``get_config_dir_path`` - -In addition, temporary directories set through ``set_temp_cache`` -or ``set_temp_config`` will now not be symlinked to default locations. -The new behavior is also meant as 100% cross platform: Windows isn't -special cased like it used to. diff --git a/docs/changes/config/19575.feature.rst b/docs/changes/config/19575.feature.rst deleted file mode 100644 index c6aae82fcec1..000000000000 --- a/docs/changes/config/19575.feature.rst +++ /dev/null @@ -1,11 +0,0 @@ -Added ``astropy.config.temporary_cache_dir_path`` and -``astropy.config.temporary_config_dir_path`` context managers, which are -safer alternatives to ``astropy.config.set_temp_cache`` and -``astropy.config.set_temp_config`` respectively, and should be preferred in -new code, but are not drop-in replacements. - -Added support for ``ASTROPY_CACHE_DIR`` and ``ASTROPY_CONFIG_DIR`` -environment variables, offering a more tightly scoped alternative to -``XDG_CACHE_HOME`` and ``XDG_CONFIG_HOME`` respectively. -When both are defined, ``ASTROPY_`` -prefixed variables take precedence -over ``XDG_`` -prefixed ones. diff --git a/docs/changes/config/19616.feature.rst b/docs/changes/config/19616.feature.rst deleted file mode 100644 index 930caa4ead3b..000000000000 --- a/docs/changes/config/19616.feature.rst +++ /dev/null @@ -1,3 +0,0 @@ -Added a ``ensure_exists`` boolean option to cache and config path getters, allowing -callers to disable directory creation on discovery with ``ensure_exists=False``. -When False, callers should handle missing directory on their own. diff --git a/docs/changes/constants/18407.api.rst b/docs/changes/constants/18407.api.rst deleted file mode 100644 index 318efaa0ebdb..000000000000 --- a/docs/changes/constants/18407.api.rst +++ /dev/null @@ -1 +0,0 @@ -CODATA 2022 has replaced CODATA 2018 as default. ``astropyconst80`` is available for ``constants`` science state, combining CODATA 2022 with IAU 2015. diff --git a/docs/changes/convolution/18348.api.rst b/docs/changes/convolution/18348.api.rst deleted file mode 100644 index d2d6daf18d2e..000000000000 --- a/docs/changes/convolution/18348.api.rst +++ /dev/null @@ -1,2 +0,0 @@ -Fixes edge treatment in ``convolve(..., boundary='fill', fill_value=nan)`` to be consistent with -``convolve_fft``. diff --git a/docs/changes/convolution/18363.bugfix.rst b/docs/changes/convolution/18363.bugfix.rst deleted file mode 100644 index 2a2c9250a160..000000000000 --- a/docs/changes/convolution/18363.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -``convolve()`` and ``convolve_fft()`` now raise exceptions if the kernel is a -masked array with masked values. diff --git a/docs/changes/coordinates/18362.api.rst b/docs/changes/coordinates/18362.api.rst deleted file mode 100644 index c20955dc5314..000000000000 --- a/docs/changes/coordinates/18362.api.rst +++ /dev/null @@ -1,5 +0,0 @@ -``Galactocentric`` and ``LSR`` now use a ``CartesianRepresentation`` instead -of ``CartesianDifferential`` for their velocity attributes (``galcen_v_sun`` -and ``v_bary``, resp.). On input, this has little effect, since instances of -both remain accepted, but for converting the attribute to a ``Quantity``, one -now has to access the ``.xyz`` attribute instead of ``.d_xyz``. diff --git a/docs/changes/coordinates/18362.bugfix.rst b/docs/changes/coordinates/18362.bugfix.rst deleted file mode 100644 index 9c3bdeba1909..000000000000 --- a/docs/changes/coordinates/18362.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -``Galactocentric`` and ``LSR`` now raise an error if their velocities are -initialized with something that does not have velocity units. diff --git a/docs/changes/coordinates/18687.bugfix.rst b/docs/changes/coordinates/18687.bugfix.rst deleted file mode 100644 index 97ed51c5743d..000000000000 --- a/docs/changes/coordinates/18687.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -The ``refresh_cache`` parameter of ``EarthLocation.of_site()`` and -``EarthLocation.get_site_names()`` methods now works. diff --git a/docs/changes/coordinates/19001.bugfix.rst b/docs/changes/coordinates/19001.bugfix.rst deleted file mode 100644 index c0975104fc27..000000000000 --- a/docs/changes/coordinates/19001.bugfix.rst +++ /dev/null @@ -1,6 +0,0 @@ -Relativistic Doppler shifts are now applied to ``SpectralCoord`` in the correct -direction even if the ``astropy.units.spectral()`` equivalency is enabled. -Previously enabling the equivalency could cause wrong results with the -``SpectralCoord.to_rest()``, -``SpectralCoord.with_observer_stationary_relative_to()`` or -``SpectralCoord.with_radial_velocity_shift()`` methods. diff --git a/docs/changes/coordinates/19308.bugfix.rst b/docs/changes/coordinates/19308.bugfix.rst deleted file mode 100644 index ebb9e210d15f..000000000000 --- a/docs/changes/coordinates/19308.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -Fixed a broadcasting bug in ``astropy.uncertainty.distributions.uniform`` where -multi-dimensional inputs (``ndim >= 2``) would raise a ``ValueError``. diff --git a/docs/changes/coordinates/19330.bugfix.rst b/docs/changes/coordinates/19330.bugfix.rst deleted file mode 100644 index 60df745beb2b..000000000000 --- a/docs/changes/coordinates/19330.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -Send a User-Agent header to the OpenStreetMap API in ``EarthLocation.of_address`` -to fix access denied errors. diff --git a/docs/changes/coordinates/19403.bugfix.rst b/docs/changes/coordinates/19403.bugfix.rst deleted file mode 100644 index 31180d7ccf6f..000000000000 --- a/docs/changes/coordinates/19403.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed a missing f-string in a TypeError from ``BaseAffineTransform._apply_transform`` that caused ``{data.__class__}`` to appear literally in the error message. diff --git a/docs/changes/coordinates/20095.bugfix.rst b/docs/changes/coordinates/20095.bugfix.rst new file mode 100644 index 000000000000..7bc3eea59a41 --- /dev/null +++ b/docs/changes/coordinates/20095.bugfix.rst @@ -0,0 +1,3 @@ +Fixed ``BaseRepresentation.represent_as`` mutating a source representation when +converting attached differentials to another class, which could cause a repeated +``SkyCoord.apply_space_motion`` call after displaying the coordinate to fail. diff --git a/docs/changes/coordinates/20227.bugfix.rst b/docs/changes/coordinates/20227.bugfix.rst new file mode 100644 index 000000000000..2c2d080d99f3 --- /dev/null +++ b/docs/changes/coordinates/20227.bugfix.rst @@ -0,0 +1,6 @@ +Fixed two rounding errors in ``Angle.to_string``. The seconds were carried into +the next minute half a unit too early, so that, e.g., +``Angle("1d2m59.49999s").to_string(precision=0)`` gave ``1d03m00s`` instead of +``1d02m59s``. And with ``fields=1`` the seconds were rounded into the minutes +before the minutes were rounded into the degrees, rounding twice, so that, e.g., +``Angle("285d29m41.76s").to_string(fields=1)`` gave ``286d`` instead of ``285d``. diff --git a/docs/changes/cosmology/18800.api.rst b/docs/changes/cosmology/18800.api.rst deleted file mode 100644 index 725dc15a1817..000000000000 --- a/docs/changes/cosmology/18800.api.rst +++ /dev/null @@ -1,2 +0,0 @@ -Redshift arguments (``z``, ``z1``, ``z2``) in cosmology methods are now -positional-only, completing the deprecations started in v7.0 and v7.1. diff --git a/docs/changes/cosmology/18807.feature.rst b/docs/changes/cosmology/18807.feature.rst deleted file mode 100644 index 7ae13af33e28..000000000000 --- a/docs/changes/cosmology/18807.feature.rst +++ /dev/null @@ -1,4 +0,0 @@ -The ``angular_diameter_distance`` method now accepts two redshift arguments -to compute the angular diameter distance between objects at different redshifts. -The previous separate method ``angular_diameter_distance_z1z2`` is deprecated -and will be removed in a future version. diff --git a/docs/changes/cosmology/18809.feature.rst b/docs/changes/cosmology/18809.feature.rst deleted file mode 100644 index a7deeaa748d5..000000000000 --- a/docs/changes/cosmology/18809.feature.rst +++ /dev/null @@ -1 +0,0 @@ -The trait ``astropy.cosmology.traits.NeutrinoComponent`` has been added to work with objects that have attributes and methods related to neutrinos. diff --git a/docs/changes/cosmology/18856.feature.rst b/docs/changes/cosmology/18856.feature.rst deleted file mode 100644 index 0ec307850dd3..000000000000 --- a/docs/changes/cosmology/18856.feature.rst +++ /dev/null @@ -1,4 +0,0 @@ -Removed deprecated module-level shim files (``astropy.cosmology.connect``, -``astropy.cosmology.core``, ``astropy.cosmology.flrw``, ``astropy.cosmology.funcs``, and -``astropy.cosmology.parameter``), which were deprecated in v7.1. All cosmology classes -and functions should be imported directly from ``astropy.cosmology``. diff --git a/docs/changes/cosmology/18874.api.rst b/docs/changes/cosmology/18874.api.rst deleted file mode 100644 index 8aca8a89e8b9..000000000000 --- a/docs/changes/cosmology/18874.api.rst +++ /dev/null @@ -1,3 +0,0 @@ -The ``Ob0=None`` parameter value for cosmology classes is no longer supported. -This deprecation was started in v7.0. Use ``Ob0=0`` instead to indicate zero -baryonic matter density. diff --git a/docs/changes/cosmology/18952.feature.rst b/docs/changes/cosmology/18952.feature.rst deleted file mode 100644 index b1f13440299f..000000000000 --- a/docs/changes/cosmology/18952.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -Registered the ``read_mrt()`` and ``write_mrt()`` methods with the Cosmology class, -enabling the import and export of cosmological data to and from MRT files. diff --git a/docs/changes/io.ascii/18505.feature.rst b/docs/changes/io.ascii/18505.feature.rst deleted file mode 100644 index 641ff7f12b5f..000000000000 --- a/docs/changes/io.ascii/18505.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Read CDS table where the data is split into multiple files diff --git a/docs/changes/io.ascii/19125.feature.rst b/docs/changes/io.ascii/19125.feature.rst deleted file mode 100644 index f984b67265e6..000000000000 --- a/docs/changes/io.ascii/19125.feature.rst +++ /dev/null @@ -1,3 +0,0 @@ -Add a new ``io.ascii`` format ``mesa`` to read history and profile output files from -`MESA `_, a well-known code for stellar evolution -calculations. diff --git a/docs/changes/io.ascii/19313.bugfix.rst b/docs/changes/io.ascii/19313.bugfix.rst deleted file mode 100644 index 66ce4a8d422a..000000000000 --- a/docs/changes/io.ascii/19313.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -Modify ECSV reader to handle header "meta" entries that are not marked with the -"!!omap" tag, but are otherwise valid YAML types allowed by the ECSV specification. diff --git a/docs/changes/io.ascii/20333.bugfix.rst b/docs/changes/io.ascii/20333.bugfix.rst new file mode 100644 index 000000000000..07e17a169b1f --- /dev/null +++ b/docs/changes/io.ascii/20333.bugfix.rst @@ -0,0 +1,4 @@ +The ``latex`` and ``aastex`` readers no longer treat an ampersand inside a +braced ``{...}`` group or an escaped ``\&`` as a column separator, so tables +with a reference column such as ``\cite{2013A&A...558A..33A}`` now read +correctly. diff --git a/docs/changes/io.fits/19023.bugfix.rst b/docs/changes/io.fits/19023.bugfix.rst deleted file mode 100644 index bfca12894ad4..000000000000 --- a/docs/changes/io.fits/19023.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix ``getdata()``'s lower and upper keywords. diff --git a/docs/changes/io.fits/19025.bugfix.rst b/docs/changes/io.fits/19025.bugfix.rst deleted file mode 100644 index b04fb3b86933..000000000000 --- a/docs/changes/io.fits/19025.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -Fix use of TNULL with float columns in ASCII tables. Undefined values in float -columns are now replaced by NaNs (instead of 0). diff --git a/docs/changes/io.fits/19267.api.rst b/docs/changes/io.fits/19267.api.rst deleted file mode 100644 index 47d4a300d82e..000000000000 --- a/docs/changes/io.fits/19267.api.rst +++ /dev/null @@ -1,4 +0,0 @@ -``np.char.chararray`` being deprecated in Numpy 2.5, in a future version -``io.fits`` will return a normal array instead of a ``chararray`` for string -columns. As a consequence the special chararray methods are deprecated (e.g., -``.rstrip()`` or ``.decode()``). Use ``np.strings`` functions instead. diff --git a/docs/changes/io.fits/19294.bugfix.rst b/docs/changes/io.fits/19294.bugfix.rst deleted file mode 100644 index a78a89aa4ee8..000000000000 --- a/docs/changes/io.fits/19294.bugfix.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fix for ``fsspec`` configuration in ``fits.open`` for remote FITS files. ``fits.open`` -now accepts the keyword argument ``fsspec_filesystem``, which is used to configure, -e.g., the buffer block size. Remote FITS file objects are then opened with -``fsspec.filesystem.open``. diff --git a/docs/changes/io.fits/19362.bugfix.rst b/docs/changes/io.fits/19362.bugfix.rst deleted file mode 100644 index ce82b255df9e..000000000000 --- a/docs/changes/io.fits/19362.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix a bug which caused CompImageHDU to have both SIMPLE and XTENSION keywords when initialized from a PrimaryHDU header. diff --git a/docs/changes/io.fits/19363.bugfix.rst b/docs/changes/io.fits/19363.bugfix.rst deleted file mode 100644 index a0c61a1ebf9e..000000000000 --- a/docs/changes/io.fits/19363.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix support for reading FITS files using image tile compression with UNCOMPRESSED_DATA columns. diff --git a/docs/changes/io.fits/19367.bugfix.rst b/docs/changes/io.fits/19367.bugfix.rst deleted file mode 100644 index ed5faafc252b..000000000000 --- a/docs/changes/io.fits/19367.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed a bug that caused reading FITS files to not work properly on WASM when relying on the default memmap settings. diff --git a/docs/changes/io.fits/19374.feature.rst b/docs/changes/io.fits/19374.feature.rst deleted file mode 100644 index e75c4a1939fa..000000000000 --- a/docs/changes/io.fits/19374.feature.rst +++ /dev/null @@ -1,7 +0,0 @@ -Added a ``logical_as_bytes`` parameter to ``fits.open()`` that, when set to -``True``, causes FITS logical columns to be read as bytes (``S1``) instead of -``bool``, preserving NULL (undefined) values that would otherwise be silently -converted to ``False``. Columns read this way round-trip correctly when written -back to a FITS file, preserving ``b'T'``, ``b'F'``, and ``b'\x00'`` (NULL) -values. A warning is now emitted when NULL values are present and -``logical_as_bytes`` is not set. diff --git a/docs/changes/io.fits/19404.bugfix.rst b/docs/changes/io.fits/19404.bugfix.rst deleted file mode 100644 index a28512f6d491..000000000000 --- a/docs/changes/io.fits/19404.bugfix.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fixed silent data corruption in ``FITS_rec.__setitem__`` when using negative -slice indices. Assigning to slices like ``data[-2:] = new_rows`` previously -wrote to the wrong rows because negative indices were clamped to 0 instead of -being resolved relative to the array length. diff --git a/docs/changes/io.fits/19416.bugfix.rst b/docs/changes/io.fits/19416.bugfix.rst deleted file mode 100644 index 2278a62e0a79..000000000000 --- a/docs/changes/io.fits/19416.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed a bug that caused compressed FITS files to become corrupted when opened in update mode and modifying the header. diff --git a/docs/changes/io.fits/19438.bugfix.rst b/docs/changes/io.fits/19438.bugfix.rst deleted file mode 100644 index 1a35992df565..000000000000 --- a/docs/changes/io.fits/19438.bugfix.rst +++ /dev/null @@ -1,3 +0,0 @@ -Fix a bug that caused header verification for CompImageHDU to not work correctly and -in some cases produce corrupt files when the decompressed header was a primary HDU -header not an extension header. diff --git a/docs/changes/io.fits/19629.bugfix.rst b/docs/changes/io.fits/19629.bugfix.rst deleted file mode 100644 index 4e4cb01e3b0e..000000000000 --- a/docs/changes/io.fits/19629.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix variable-length array logical (``PL``/``QL``) columns being written as 1/0 bytes instead of FITS T/F and read back as raw int8 (84/70) instead of bool. Files written by astropy <= 7.2.0 (which used the legacy 0x00/0x01 encoding) are still readable: such files are detected on read and decoded correctly with an ``AstropyUserWarning``. diff --git a/docs/changes/io.fits/19638.api.rst b/docs/changes/io.fits/19638.api.rst deleted file mode 100644 index 0e16a52a612d..000000000000 --- a/docs/changes/io.fits/19638.api.rst +++ /dev/null @@ -1,2 +0,0 @@ -The ``strip_spaces`` option in ``Table.read`` to strip trailing whitespaces in -string columns is now True by default. Set it to False to keep the old behavior. diff --git a/docs/changes/io.fits/19659.feature.rst b/docs/changes/io.fits/19659.feature.rst deleted file mode 100644 index 0d6a13089478..000000000000 --- a/docs/changes/io.fits/19659.feature.rst +++ /dev/null @@ -1,9 +0,0 @@ -Extended the ``logical_as_bytes`` option of ``fits.open()`` to apply to -variable-length array (``PL``/``QL``) logical columns. When ``True``, -VLA logical columns are returned as ``S1`` byte arrays (one byte per -entry) so that ``b'\x00'`` (NULL) is distinguishable from ``b'F'`` -(False); ``S1`` row arrays also round-trip through writing. A warning -is emitted when NULL bytes are present in a VLA logical column read -with ``logical_as_bytes=False``, except for the ambiguous case of a -column whose heap is entirely ``\x00`` (which may instead be an -all-False column written by astropy <= 7.2.0). diff --git a/docs/changes/io.fits/19869.bugfix.rst b/docs/changes/io.fits/19869.bugfix.rst new file mode 100644 index 000000000000..585f8b62f270 --- /dev/null +++ b/docs/changes/io.fits/19869.bugfix.rst @@ -0,0 +1,5 @@ +Fixed ``fitsinfo`` and ``fitsheader`` crashing with a traceback when run on +an LZW-compressed ``.Z`` file while the optional ``uncompresspy`` dependency +is not installed. The scripts now broadly catch exceptions and report a +clean error message, matching the behaviour of the ``fitscheck`` and +``fitsdiff`` scripts. diff --git a/docs/changes/io.fits/20313.bugfix.rst b/docs/changes/io.fits/20313.bugfix.rst new file mode 100644 index 000000000000..31e95d238ae8 --- /dev/null +++ b/docs/changes/io.fits/20313.bugfix.rst @@ -0,0 +1,8 @@ +The ``ZQUANTIZ`` keyword is no longer written to the header of compressed +image HDUs when no quantization is applied (i.e., for integer data, or +floating point data compressed with ``quantize_method=NO_DITHER``). A few +FITS readers, including DS9 and ``fv``, currently treat the mere presence +of ``ZQUANTIZ`` as an indication that the data are quantized, regardless +of its value, and cannot read the files. Omitting the keyword when it is +not needed is fully standards-compliant, as a missing ``ZQUANTIZ`` is +already interpreted as ``NO_DITHER``. diff --git a/docs/changes/io.misc/19658.bugfix.rst b/docs/changes/io.misc/19658.bugfix.rst deleted file mode 100644 index 2aa6b893a662..000000000000 --- a/docs/changes/io.misc/19658.bugfix.rst +++ /dev/null @@ -1,3 +0,0 @@ -Fixed reading parquet files written by pandas 3.0+, where string columns are -encoded as ``large_string`` rather than ``string`` and previously round-tripped -to ``object`` dtype instead of the expected fixed-width Unicode dtype. diff --git a/docs/changes/io.misc/19909.bugfix.rst b/docs/changes/io.misc/19909.bugfix.rst new file mode 100644 index 000000000000..cd87f672d46d --- /dev/null +++ b/docs/changes/io.misc/19909.bugfix.rst @@ -0,0 +1,3 @@ +Fixed the ``format="ecsv"`` ECSV reader to correctly read an empty table that has +no columns. Previously this was raising an ``InconsistentTableError``. The legacy +``format="ascii.ecsv"`` ECSV reader was already handling this case correctly. diff --git a/docs/changes/io.registry/17571.api.rst b/docs/changes/io.registry/17571.api.rst deleted file mode 100644 index f9a43ffffcb6..000000000000 --- a/docs/changes/io.registry/17571.api.rst +++ /dev/null @@ -1,2 +0,0 @@ -The ``help`` methods from ``Table.read`` and ``Table.write`` now raise an -exception if called under Python's optimized mode (``-OO`` flag). diff --git a/docs/changes/io.votable/18454.perf.rst b/docs/changes/io.votable/18454.perf.rst deleted file mode 100644 index 42fe8bcffe0c..000000000000 --- a/docs/changes/io.votable/18454.perf.rst +++ /dev/null @@ -1,10 +0,0 @@ -Improve performance of Binary parsing by moving converters to Cython - -* **Numeric operations**: 47-57% faster -* **Mixed data types**: 37-45% faster -* **String operations**: 24-35% faster -* **Boolean fields**: 10-20% faster -* **Small overhead operations**: 29-43% faster - -Performance gains are consistent across dataset sizes from 200k to 1M rows. -The most substantial improvements are seen in numeric types with up to 50-60% reduction in processing time. diff --git a/docs/changes/io.votable/19499.feature.rst b/docs/changes/io.votable/19499.feature.rst deleted file mode 100644 index d46cc3234534..000000000000 --- a/docs/changes/io.votable/19499.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Added a ``write`` method to ``VOTableFile``. diff --git a/docs/changes/io.votable/19593.api.rst b/docs/changes/io.votable/19593.api.rst deleted file mode 100644 index 0206d4c4257e..000000000000 --- a/docs/changes/io.votable/19593.api.rst +++ /dev/null @@ -1,3 +0,0 @@ -DataOrigin updated with IVOA version 1.2: - -* Terms changed in ``astropy.io.votable.dataorigin`` (``ivoid`` and ``editor`` renamed to ``ivoid_data`` and ``journal``) diff --git a/docs/changes/io.votable/20302.bugfix.rst b/docs/changes/io.votable/20302.bugfix.rst new file mode 100644 index 000000000000..3e065132b2d8 --- /dev/null +++ b/docs/changes/io.votable/20302.bugfix.rst @@ -0,0 +1,3 @@ +Prevent overflow on 32-bit systems and WebAssembly when reading binary VOTables by +changing the ambiguous types of ``null_value`` to fixed-width equivalents (e.g., from +``long`` to ``int64_t``). diff --git a/docs/changes/modeling/19189.bugfix.rst b/docs/changes/modeling/19189.bugfix.rst deleted file mode 100644 index 2983132014bf..000000000000 --- a/docs/changes/modeling/19189.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Bugfix for ``Parameter.value`` accessor when the parameter value has not been set yet. diff --git a/docs/changes/nddata/18862.feature.rst b/docs/changes/nddata/18862.feature.rst deleted file mode 100644 index 53311a9e7d5e..000000000000 --- a/docs/changes/nddata/18862.feature.rst +++ /dev/null @@ -1,4 +0,0 @@ -Implemented support for the ``.flags`` attribute in -``CCDData.to_hdu()``, enabling the CCDData class to write and read -``.flags`` in FITS format. -Previously this was silent no-op even though it should have raised a ``NotImplementedError``. diff --git a/docs/changes/nddata/19389.bugfix.rst b/docs/changes/nddata/19389.bugfix.rst deleted file mode 100644 index 5a7cf47501c4..000000000000 --- a/docs/changes/nddata/19389.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed ``CCDData.read()`` logging a spurious info message when the unit passed by the caller matches the BUNIT value in the FITS header. diff --git a/docs/changes/nddata/20364.bugfix.rst b/docs/changes/nddata/20364.bugfix.rst new file mode 100644 index 000000000000..4b65d501fef6 --- /dev/null +++ b/docs/changes/nddata/20364.bugfix.rst @@ -0,0 +1 @@ +Fix ``block_replicate`` with ``conserve_sum=True`` inadvertently promoting ``float16``, ``float32``, and ``complex64`` input arrays to wider dtypes. diff --git a/docs/changes/samp/19231.bugfix.rst b/docs/changes/samp/19231.bugfix.rst deleted file mode 100644 index cb316403bb4d..000000000000 --- a/docs/changes/samp/19231.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed an XML External Entity (XXE) vulnerability in SAMP XML-RPC communication by disabling external entity resolution. diff --git a/docs/changes/stats/19372.bugfix.rst b/docs/changes/stats/19372.bugfix.rst deleted file mode 100644 index 05d5b3d4b6aa..000000000000 --- a/docs/changes/stats/19372.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed pickling of ``SigmaClip`` objects when Bottleneck is installed. ``_dtype_dispatch`` returned a local closure which cannot be pickled; replaced with a picklable ``_DtypeDispatch`` class. diff --git a/docs/changes/stats/19457.bugfix.rst b/docs/changes/stats/19457.bugfix.rst deleted file mode 100644 index d43520882c01..000000000000 --- a/docs/changes/stats/19457.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed missing pointer dereference in ``fast_sigma_clip.c`` that caused ``mad_buffer`` to be allocated regardless of the ``use_mad_std`` value. diff --git a/docs/changes/stats/19498.perf.rst b/docs/changes/stats/19498.perf.rst deleted file mode 100644 index 34437fa205ff..000000000000 --- a/docs/changes/stats/19498.perf.rst +++ /dev/null @@ -1,3 +0,0 @@ -Vectorized the ``var-width`` mode in ``RipleysKEstimator`` by precomputing the -full distance matrix, replacing a triple nested Python loop. This yields -speedups of several thousand times for typical input sizes. diff --git a/docs/changes/stats/20008.bugfix.rst b/docs/changes/stats/20008.bugfix.rst new file mode 100644 index 000000000000..af019f2fda51 --- /dev/null +++ b/docs/changes/stats/20008.bugfix.rst @@ -0,0 +1 @@ +Fixed a bug where ``astropy.stats.histogram()`` raised a ``ValueError`` when both ``range`` and ``weights`` were provided with a scalar number of bins. For scalar bin counts, ``calculate_bin_edges()`` now delegates range filtering directly to ``numpy.histogram_bin_edges()``, avoiding an array shape mismatch between data and weights while reserving manual range filtering for Astropy's string-based bin width estimators. diff --git a/docs/changes/stats/20393.bugfix.rst b/docs/changes/stats/20393.bugfix.rst new file mode 100644 index 000000000000..cb514bb4b94c --- /dev/null +++ b/docs/changes/stats/20393.bugfix.rst @@ -0,0 +1 @@ +Fixed an out-of-bounds memory read in the C implementation of ``sigma_clip`` (used when ``axis`` is given with ``cenfunc``/``stdfunc`` set to their string values) that occurred when a clipping iteration rejected all remaining values. This could cause a crash or return incorrect (unmasked) results; the fast path now returns the same result as the pure-python implementation in this case. diff --git a/docs/changes/table/18703.feature.rst b/docs/changes/table/18703.feature.rst deleted file mode 100644 index 8763d0ce1b31..000000000000 --- a/docs/changes/table/18703.feature.rst +++ /dev/null @@ -1,4 +0,0 @@ -Support writing table indices to FITS, HDF5, and ECSV formats and subsequently reading -them back to round-trip the original indexed table. This stores the index data as -temporary columns in the output, similar to the mechanism for serializing mixin columns -like ``SkyCoord``. diff --git a/docs/changes/table/19123.feature.rst b/docs/changes/table/19123.feature.rst deleted file mode 100644 index cfde5f504b42..000000000000 --- a/docs/changes/table/19123.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Added ``conf.multidim_threshold`` configuration option to control display of multidimensional table columns. Setting this to a value like 3 will display full content for small arrays (e.g., ``[1 2 3]`` for 3-vectors), while larger arrays revert to the abbreviated ``first .. last`` format. diff --git a/docs/changes/table/19173.feature.rst b/docs/changes/table/19173.feature.rst deleted file mode 100644 index 38d1c8964750..000000000000 --- a/docs/changes/table/19173.feature.rst +++ /dev/null @@ -1,4 +0,0 @@ -Enable conversion of tables with multidimensional columns to pandas DataFrames: - -* Previously, ``to_pandas()`` and ``to_df()`` would raise a ``ValueError`` for columns containing lists or arrays (for example, ``[[1, 2], [3, 4]]``). -* Now these columns are automatically converted to 1D object arrays compatible with pandas. diff --git a/docs/changes/table/19198.api.rst b/docs/changes/table/19198.api.rst deleted file mode 100644 index 03354aa8d672..000000000000 --- a/docs/changes/table/19198.api.rst +++ /dev/null @@ -1,5 +0,0 @@ -The private ``astropy.table.np_utils`` module has been removed. It did not -contain any code that was still used except the definition of -``TableMergeError``, which should be imported from ``astropy.table``. All -other routines were long ago moved to either ``astropy.table.operations`` or -``astropy.utils.metadata``. diff --git a/docs/changes/table/19199.bugfix.rst b/docs/changes/table/19199.bugfix.rst deleted file mode 100644 index 140582a5f856..000000000000 --- a/docs/changes/table/19199.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -Tables can now be joined and stacked also if they contain columns with -user-defined data types such as ``QuadPrecDtype``. diff --git a/docs/changes/table/19358.bugfix.rst b/docs/changes/table/19358.bugfix.rst deleted file mode 100644 index f837ee97b524..000000000000 --- a/docs/changes/table/19358.bugfix.rst +++ /dev/null @@ -1,3 +0,0 @@ -Fixed ``AssertionError`` when passing ``numpy.bool_`` as the -``index`` argument to ``Table.to_pandas()`` or ``Table.to_dataframe()``. -The correct ``ValueError`` is now raised instead. diff --git a/docs/changes/table/19450.bugfix.rst b/docs/changes/table/19450.bugfix.rst deleted file mode 100644 index 5f3e137dea8f..000000000000 --- a/docs/changes/table/19450.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -Fixed table index getting corrupted when a row assignment raises an exception -mid-update. The index is now properly restored to its original state on failure. diff --git a/docs/changes/table/19466.bugfix.rst b/docs/changes/table/19466.bugfix.rst deleted file mode 100644 index 10c79a8de077..000000000000 --- a/docs/changes/table/19466.bugfix.rst +++ /dev/null @@ -1,2 +0,0 @@ -Fixes a problem where deepcopying a MaskedColumn did not correctly create the ``info`` -attribute. This resulted in an inability to print the column after the deepcopy. diff --git a/docs/changes/table/19542.api.rst b/docs/changes/table/19542.api.rst deleted file mode 100644 index 871f2bf47231..000000000000 --- a/docs/changes/table/19542.api.rst +++ /dev/null @@ -1,5 +0,0 @@ -Setting the ``Column.dtype`` attribute is now deprecated since mutating -an array is unsafe if an array is shared, especially by multiple -threads. As an alternative, you can create a view with a new dtype -via ``column.view(dtype=new_dtype)``. This follows a similar -deprecation in numpy 2.5.0. diff --git a/docs/changes/table/20031.bugfix.rst b/docs/changes/table/20031.bugfix.rst new file mode 100644 index 000000000000..c80efa7fb013 --- /dev/null +++ b/docs/changes/table/20031.bugfix.rst @@ -0,0 +1,3 @@ +Fixed issue with table groups for zero-length tables in ``astropy.table``. Previously +operations like ``len(tbl.group_by("a").groups)`` or ``len(tbl.groups)`` would fail or +give inconsistent results if the table ``tbl`` had no rows. diff --git a/docs/changes/table/20088.bugfix.rst b/docs/changes/table/20088.bugfix.rst new file mode 100644 index 000000000000..b3790f986450 --- /dev/null +++ b/docs/changes/table/20088.bugfix.rst @@ -0,0 +1,5 @@ +Fixed an issue when deep-copying a ``Table`` where the copied columns lost their link to +the new parent table. This led to problems in ``rename_columns`` and ``info.name`` +assignment. Other potential problems included table-aware unit conversion/replacement in +QTable, grouped-column linkage to table group metadata, and synchronized pprint +include/exclude name bookkeeping on rename or delete. diff --git a/docs/changes/table/20097.bugfix.rst b/docs/changes/table/20097.bugfix.rst new file mode 100644 index 000000000000..61ffadd7fe46 --- /dev/null +++ b/docs/changes/table/20097.bugfix.rst @@ -0,0 +1,3 @@ +Fixed an issue where ``vstack`` was returning the input table itself when given +a single table. It now returns a copy of that table, consistent with the documented +behavior of always returning a new table. diff --git a/docs/changes/table/20240.bugfix.rst b/docs/changes/table/20240.bugfix.rst new file mode 100644 index 000000000000..dbbb673e6769 --- /dev/null +++ b/docs/changes/table/20240.bugfix.rst @@ -0,0 +1,6 @@ +Fixed ``Table.to_pandas()`` and ``Table.to_df()`` silently corrupting masked +integer columns whose values are too large to be represented exactly as a +float64, e.g. Gaia source ids. The nullable integer column is now built +directly from the integer data and the mask instead of via a float64 +intermediate. Only the pandas backend was affected; polars and pyarrow already +round-tripped these values exactly. diff --git a/docs/changes/table/20265.bugfix.rst b/docs/changes/table/20265.bugfix.rst new file mode 100644 index 000000000000..c50f7de0b917 --- /dev/null +++ b/docs/changes/table/20265.bugfix.rst @@ -0,0 +1,6 @@ +Fixed a bug where a numpy function used on a ``MaskedColumn`` produced output +with a different dtype than the input (e.g., ``np.strings.find``), which would +later lead to a ``TypeError`` from a stale ``fill_value`` dtype. Now, when +the fill_value is no longer valid for the output dtype, fall back to the +default fill_value for that dtype instead of propagating the stale value. +This can now raise a castings warnings following standard numpy casting rules. diff --git a/docs/changes/tests/17883.api.rst b/docs/changes/tests/17883.api.rst deleted file mode 100644 index dc7c5c09a423..000000000000 --- a/docs/changes/tests/17883.api.rst +++ /dev/null @@ -1,4 +0,0 @@ -API changes towards a future removal of astropy test runner: - -* Removed ``astropy.tests.command`` module that was deprecated in v6.0. -* Officially deprecated ``astropy.test``, ``astropy.tests.runner.TestRunnerBase``, and ``astropy.tests.runner.TestRunner`` (previously pending deprecation). This will also affect downstream ``packagename.test`` generated using ``TestRunner``. The deprecated functionality will be removed in a future release after a deprecation period as per Astropy deprecation policy. diff --git a/docs/changes/time/19368.bugfix.rst b/docs/changes/time/19368.bugfix.rst deleted file mode 100644 index 79a5070fe64c..000000000000 --- a/docs/changes/time/19368.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed missing ``goto fail`` in ``create_parser`` in ``parse_times.c`` after setting a ``ValueError`` for invalid parameter array size, preventing execution from continuing with an exception already set. diff --git a/docs/changes/time/20178.bugfix.rst b/docs/changes/time/20178.bugfix.rst new file mode 100644 index 000000000000..1b7b84aae710 --- /dev/null +++ b/docs/changes/time/20178.bugfix.rst @@ -0,0 +1,3 @@ +Fixed ``Time.__setitem__`` silently dropping the mask when a masked ``Time`` was +assigned into an unmasked one. As a result, ``vstack`` no longer loses the mask +on ``Time`` mixin columns. diff --git a/docs/changes/time/20232.bugfix.rst b/docs/changes/time/20232.bugfix.rst new file mode 100644 index 000000000000..6bc6f3eb9fde --- /dev/null +++ b/docs/changes/time/20232.bugfix.rst @@ -0,0 +1,4 @@ +Fixed a bug where ``Time.insert`` (and therefore ``Table.add_row`` for a ``Time`` +column) silently dropped the mask, exposing values that should have stayed +masked. This affected both the mask of the original object and the mask of the +inserted values. diff --git a/docs/changes/timeseries/17842.feature.rst b/docs/changes/timeseries/17842.feature.rst deleted file mode 100644 index cf68ecacab78..000000000000 --- a/docs/changes/timeseries/17842.feature.rst +++ /dev/null @@ -1,4 +0,0 @@ -Adds support for Low Rank Approximation (LRA) to ``trig_sum``. -This is enabled by passing the new argument ``algorithm='lra'``, -which is now the default option for ``fast`` and -``fastchi2`` implementations. diff --git a/docs/changes/units/19055.bugfix.rst b/docs/changes/units/19055.bugfix.rst deleted file mode 100644 index c658508f0e62..000000000000 --- a/docs/changes/units/19055.bugfix.rst +++ /dev/null @@ -1,3 +0,0 @@ -Fixed a bug in the ``np.average`` function when weights with units and a -different shape to the input array were passed and the optionally-returned sum -of the weights was requested. The sum of the weights now has correct units. diff --git a/docs/changes/units/19104.feature.rst b/docs/changes/units/19104.feature.rst deleted file mode 100644 index f026edd36342..000000000000 --- a/docs/changes/units/19104.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -Add support for ``atol`` and ``rtol`` parameters from ``np.matrix_rank`` -when called on a ``Quantity`` object. diff --git a/docs/changes/units/19214.feature.rst b/docs/changes/units/19214.feature.rst deleted file mode 100644 index 470bf0a70dbc..000000000000 --- a/docs/changes/units/19214.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Allow 1d vector-like strings, with or without units, to be converted into ``Quantity``. diff --git a/docs/changes/units/19360.bugfix.rst b/docs/changes/units/19360.bugfix.rst deleted file mode 100644 index 5516e6a7ffeb..000000000000 --- a/docs/changes/units/19360.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed incorrect unit returned by ``numpy.diff`` when applied to logarithmic quantities (e.g., magnitudes). diff --git a/docs/changes/utils/19142.bugfix.rst b/docs/changes/utils/19142.bugfix.rst deleted file mode 100644 index ddbd2191c3cc..000000000000 --- a/docs/changes/utils/19142.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Pickling ``Masked`` subclasses with an initialized ``info`` attribute no longer fails. diff --git a/docs/changes/utils/19351.bugfix.rst b/docs/changes/utils/19351.bugfix.rst deleted file mode 100644 index 1211519191af..000000000000 --- a/docs/changes/utils/19351.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -``ShapedLikeNDArray.take()`` now raises ``NotImplementedError`` when ``out`` is passed, instead of returning the exception object. diff --git a/docs/changes/utils/19534.bugfix.rst b/docs/changes/utils/19534.bugfix.rst deleted file mode 100644 index 6b4bf8d5a3b0..000000000000 --- a/docs/changes/utils/19534.bugfix.rst +++ /dev/null @@ -1,3 +0,0 @@ -Ensure that ``utils.masked.get_data_and_mask`` works properly with containers, -returning ``None`` for the mask if ``masked`` is not set (instead of returning -an all-False array). diff --git a/docs/changes/utils/19594.bugfix.rst b/docs/changes/utils/19594.bugfix.rst deleted file mode 100644 index f3c85ec51eb5..000000000000 --- a/docs/changes/utils/19594.bugfix.rst +++ /dev/null @@ -1,4 +0,0 @@ -The dummy file object used by ``astropy.utils.misc.silence`` to replace -``sys.stdout``/``sys.stderr`` now implements ``flush()`` and ``isatty()``, -so importing libraries that probe the stream (e.g. IPython 9.13 at import -time) no longer raises ``AttributeError`` under ``silence``. diff --git a/docs/changes/utils/19631.api.rst b/docs/changes/utils/19631.api.rst deleted file mode 100644 index 3d673871eb30..000000000000 --- a/docs/changes/utils/19631.api.rst +++ /dev/null @@ -1,3 +0,0 @@ -Cache directories are not created on discovery anymore. Meaning some functions -that only retrieve a file or directory by name and without populating it won't -guarantee that the path returned already exists. diff --git a/docs/changes/utils/20064.bugfix.rst b/docs/changes/utils/20064.bugfix.rst new file mode 100644 index 000000000000..251aa3a852fd --- /dev/null +++ b/docs/changes/utils/20064.bugfix.rst @@ -0,0 +1,2 @@ +Fixed an issue where, in a multi-threaded context, astropy's logger could race with +imports, leading to an exception being raised. diff --git a/docs/changes/utils/20169.bugfix.rst b/docs/changes/utils/20169.bugfix.rst new file mode 100644 index 000000000000..62f382eb444f --- /dev/null +++ b/docs/changes/utils/20169.bugfix.rst @@ -0,0 +1 @@ +Flipped ``astropy.utils.data.conf.dataurl`` and ``astropy.utils.data.conf.dataurl_mirror`` to reduce URL redirect, in hope to reduce service denial. diff --git a/docs/changes/visualization/18994.api.rst b/docs/changes/visualization/18994.api.rst deleted file mode 100644 index 33bd975faf93..000000000000 --- a/docs/changes/visualization/18994.api.rst +++ /dev/null @@ -1,4 +0,0 @@ -Removed the deprecated ``min_cut`` and ``max_cut`` keywords from the -``simple_norm`` function and the ``fits2bitmap`` command-line script. -Use the ``vmin`` and ``vmax`` keywords, respectively, to specify the cut -levels. diff --git a/docs/changes/visualization/19539.feature.rst b/docs/changes/visualization/19539.feature.rst deleted file mode 100644 index 8d3da7dea32d..000000000000 --- a/docs/changes/visualization/19539.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -Expose ``MplQuantityConverter`` class in ``astropy.visualization`` and -except numpy arrays in its ``convert`` and ``default_units`` methods. diff --git a/docs/changes/visualization/19623.feature.rst b/docs/changes/visualization/19623.feature.rst deleted file mode 100644 index 012bc88239e6..000000000000 --- a/docs/changes/visualization/19623.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -Added the ``imshow_simple_norm`` function to provide a more compact interface -to the ``SimpleNorm``/``simple_norm`` interface for quick visualization. diff --git a/docs/changes/visualization/20221.bugfix.rst b/docs/changes/visualization/20221.bugfix.rst new file mode 100644 index 000000000000..045090840ce8 --- /dev/null +++ b/docs/changes/visualization/20221.bugfix.rst @@ -0,0 +1,30 @@ +Fixed a number of bugs in WCSAxes: + +* Setting the number of ticks to zero no longer crashes drawing, minor + ticks, or the mouseover coordinate display. +* Contour-type grids no longer crash for a longitude coordinate with fewer + than two ticks or for a coordinate sliced out of the plot, and no longer + draw a spurious gridline along the longitude wrap for a single tick. +* Drawing a gridline sampled with fewer than three points no longer crashes. +* Transforming empty coordinate arrays no longer crashes for 1-d WCS and + now returns correctly shaped arrays. +* Simplifying tick labels no longer crashes when one label is a prefix of + an adjacent one. +* Tick values for longitude coordinates in non-degree units are no longer + corrupted when a tick intersects a spine more than once. +* ``%``-style tick formats now use the format unit instead of the + coordinate's native unit. +* Invalid tick format strings now raise a ``ValueError`` instead of being + silently accepted with reduced precision. +* ``get_coords_overlay`` no longer ignores a supplied ``coord_meta`` when + passed a WCS. +* The default conversion of right ascension format units to hourangle no + longer depends on unit object identity. +* ``custom_ucd_coord_meta_mapping`` no longer applies earlier keys when a + later key conflicts, and now raises for keys specified more than once. +* The cursor coordinate display no longer breaks after ``reset_wcs`` and + now preserves the pixel display mode. +* Automatically-placed coordinates are now hidden instead of crashing when + no spine assignment is consistent with fixed tick positions. +* Formatting a coordinate for the mouseover display no longer crashes for + a degenerate (zero-size) coordinate range. diff --git a/docs/changes/wcs/18743.feature.rst b/docs/changes/wcs/18743.feature.rst deleted file mode 100644 index 193e26fd6284..000000000000 --- a/docs/changes/wcs/18743.feature.rst +++ /dev/null @@ -1 +0,0 @@ -``world_to_pixel`` and ``pixel_to_world`` now can take ``Masked`` input. diff --git a/docs/changes/wcs/18902.api.rst b/docs/changes/wcs/18902.api.rst deleted file mode 100644 index 4094e0fac538..000000000000 --- a/docs/changes/wcs/18902.api.rst +++ /dev/null @@ -1,5 +0,0 @@ -``SpectralCoord`` conversions through the APE 14 high-level WCS API no -longer emit a warning when neither the WCS nor the input ``SpectralCoord`` -has an observer defined. Warnings are still emitted when only one of the -two has observer information (and the conversion therefore proceeds -without a velocity frame change). diff --git a/docs/changes/wcs/19064.api.rst b/docs/changes/wcs/19064.api.rst deleted file mode 100644 index 719238e8177b..000000000000 --- a/docs/changes/wcs/19064.api.rst +++ /dev/null @@ -1,6 +0,0 @@ -``wcs.high_level_objects_to_values`` and ``wcs.values_to_high_level_objects`` -no longer require their ``low_level_wcs`` argument to be a full -``astropy.wcs.wcsapi.BaseLowLevelWCS`` instance. Any object exposing -``world_axis_object_classes`` and ``world_axis_object_components`` attributes -is accepted, and ``serialized_classes`` is now optional (treated as ``False`` -if absent). diff --git a/docs/changes/wcs/19506.bugfix.rst b/docs/changes/wcs/19506.bugfix.rst deleted file mode 100644 index 9c33379c049b..000000000000 --- a/docs/changes/wcs/19506.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fixed a bug where degree units were hardcoded into the FITS WCS APE 14 ``world_to_pixel`` method. diff --git a/docs/changes/wcs/19591.bugfix.rst b/docs/changes/wcs/19591.bugfix.rst deleted file mode 100644 index 9ebd28094d1f..000000000000 --- a/docs/changes/wcs/19591.bugfix.rst +++ /dev/null @@ -1,9 +0,0 @@ -Lazily-populated caches on a ``WCS`` (such as the internal -``world_axis_object_components``/``world_axis_object_classes`` cache) are no -longer included in the pickled state. They are regenerated on demand after -unpickling, which keeps pickling robust even when a cache entry holds a -non-picklable value. - -Fixed a bug where the ``preserve_units`` option passed to the ``WCS`` -constructor was silently reset to ``False`` when a ``WCS`` object was pickled -and unpickled. diff --git a/docs/changes/wcs/20418.bugfix.rst b/docs/changes/wcs/20418.bugfix.rst new file mode 100644 index 000000000000..1c907b422df5 --- /dev/null +++ b/docs/changes/wcs/20418.bugfix.rst @@ -0,0 +1,4 @@ +Fixed a bug that could cause a segmentation fault when using a WCS object with +``preserve_units=True`` and where units has already been defined and the WCS +had been ``set()`` but the scaling factors were found to be 1, and when cdelt or +crval were subsequently set. diff --git a/docs/changes/wcs/20422.bugfix.rst b/docs/changes/wcs/20422.bugfix.rst new file mode 100644 index 000000000000..1adb670889d3 --- /dev/null +++ b/docs/changes/wcs/20422.bugfix.rst @@ -0,0 +1 @@ +Fix ``WCS.to_header()`` for cases where ``preserve_units=True``, units were set programmatically, and ``WCS.set()`` was not called. diff --git a/docs/changes/wcs/20423.bugfix.rst b/docs/changes/wcs/20423.bugfix.rst new file mode 100644 index 000000000000..280a216b6bb4 --- /dev/null +++ b/docs/changes/wcs/20423.bugfix.rst @@ -0,0 +1,3 @@ +Fixed a race condition in which concurrent failing transforms on the same +``WCS`` object could allocate the internal error buffer twice, leaking one +allocation. The buffer is now allocated once when the object is created. diff --git a/docs/changes/wcs/20425.bugfix.rst b/docs/changes/wcs/20425.bugfix.rst new file mode 100644 index 000000000000..38feede8d8e2 --- /dev/null +++ b/docs/changes/wcs/20425.bugfix.rst @@ -0,0 +1,3 @@ +Fixed a race condition in which deepcopying a ``WCS`` object while another +thread used it could raise ``RuntimeError: dictionary changed size during +iteration``. diff --git a/docs/conf.py b/docs/conf.py index 7a999ec455c0..59b8d350f995 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -70,11 +70,10 @@ logger.error(msg) sys.exit(1) -from sphinx_astropy.conf.v2 import * # noqa: E402, F403 -from sphinx_astropy.conf.v2 import ( # noqa: E402 +from sphinx_astropy.conf.v3 import * # noqa: E402, F403 +from sphinx_astropy.conf.v3 import ( # noqa: E402 exclude_patterns, extensions, - html_theme_options, intersphinx_mapping, numpydoc_xref_aliases, numpydoc_xref_astropy_aliases, @@ -231,33 +230,18 @@ # -- Options for HTML output --------------------------------------------------- -html_theme_options.update( - { - "analytics": { - "google_analytics_id": "G-R0510VK4B6", - }, - "github_url": "https://github.com/astropy/astropy", - "external_links": [ - {"name": "Learn", "url": "https://learn.astropy.org/"}, - {"name": "Packages", "url": "https://www.astropy.org/affiliated/"}, - ], - "use_edit_page_button": True, - "logo": { - "image_light": "_static/astropy_banner_96.png", - "image_dark": "_static/astropy_banner_96_dark.png", - }, - # https://github.com/pydata/pydata-sphinx-theme/issues/1492 - "navigation_with_keys": False, - "announcement": "https://www.astropy.org/annoucement_banner.html", - "header_links_before_dropdown": 6, - } -) +html_theme_options = { + "analytics": { + "google_analytics_id": "G-R0510VK4B6", + }, + "github_url": "https://github.com/astropy/astropy", + "use_edit_page_button": True, +} # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". html_title = f"{project} v{release}" -html_favicon = "_static/astropy_logo.ico" html_static_path = ["_static"] html_css_files = ["astropy.css"] html_copy_source = False @@ -385,6 +369,11 @@ def _custom_edit_url( dtype, target = line.split(None, 1) nitpick_ignore.append((dtype, target.strip())) +# NumPy frequently relocates its private typing namespace between releases +# (e.g. numpy._typing.ArrayLike -> numpy._typing._array_like.ArrayLike), so +# match the whole private namespace rather than tracking individual paths. +nitpick_ignore_regex = [("py:obj", r"numpy\._typing\..*")] + suppress_warnings = [ "config.cache", # our rebuild is okay ] @@ -399,6 +388,7 @@ def _custom_edit_url( "http://data.astropy.org", "https://astropy-dei.orgmycology.com/", # 403 Client Error: Forbidden "https://doi.org/", # CI blocked by service provider + "https://codata.org/", # CI blocked by service provider "https://zenodo.org/", # 403 Client Error: Forbidden "https://ui.adsabs.harvard.edu", # CI blocked by service provider "https://hst-docs.stsci.edu", # CI blocked by service provider @@ -554,7 +544,6 @@ def rstjinja(app, docname, source): "miniconda": "https://docs.conda.io/en/latest/miniconda.html", # pytest "pytest": "https://pytest.org/en/latest/index.html", - "pytest-astropy": "https://github.com/astropy/pytest-astropy", "pytest-doctestplus": "https://github.com/astropy/pytest-doctestplus", "pytest-remotedata": "https://github.com/astropy/pytest-remotedata", # fsspec diff --git a/docs/config/index.rst b/docs/config/index.rst index 98053c6edca2..ea5b0ce76764 100644 --- a/docs/config/index.rst +++ b/docs/config/index.rst @@ -72,7 +72,7 @@ configuration parameters. For instance, to get the default URL for >>> from astropy.utils.data import conf >>> conf.dataurl - 'http://data.astropy.org/' + 'http://www.astropy.org/astropy-data/' Changing Values at Runtime -------------------------- @@ -121,7 +121,7 @@ takes care of resetting the value you changed when you are done using it:: You can also modify the values at runtime directly:: >>> conf.dataurl - 'http://data.astropy.org/' + 'http://www.astropy.org/astropy-data/' >>> conf.dataurl = 'http://astropydata.mywebsite.com' >>> conf.dataurl 'http://astropydata.mywebsite.com' @@ -194,7 +194,7 @@ value in the configuration file:: >>> conf.reset('dataurl') >>> conf.dataurl - 'http://data.astropy.org/' + 'http://www.astropy.org/astropy-data/' .. EXAMPLE END diff --git a/docs/conftest.py b/docs/conftest.py index f64f8ea0d3f0..dd98ea2dbcf8 100644 --- a/docs/conftest.py +++ b/docs/conftest.py @@ -19,14 +19,14 @@ @pytest.fixture(scope="session", autouse=True) def _session_level_cache_dir(tmp_path_factory): - tmp_path = tmp_path_factory.mktemp("cache_") - os.environ["ASTROPY_CACHE_DIR"] = str(tmp_path) + os.environ["ASTROPY_CACHE_DIR"] = str(tmp_path_factory.mktemp("astropy_cache_")) + os.environ["XDG_CACHE_HOME"] = str(tmp_path_factory.mktemp("xdg_cache_")) @pytest.fixture(scope="session", autouse=True) def _session_level_config_dir(tmp_path_factory): - tmp_path = tmp_path_factory.mktemp("config_") - os.environ["ASTROPY_CONFIG_DIR"] = str(tmp_path) + os.environ["ASTROPY_CONFIG_DIR"] = str(tmp_path_factory.mktemp("astropy_config_")) + os.environ["XDG_CONFIG_HOME"] = str(tmp_path_factory.mktemp("xdg_config_")) @pytest.fixture(autouse=True) diff --git a/docs/coordinates/example_gallery_index.rst b/docs/coordinates/example_gallery_index.rst index 92d11984d85a..115e29749892 100644 --- a/docs/coordinates/example_gallery_index.rst +++ b/docs/coordinates/example_gallery_index.rst @@ -1,16 +1,16 @@ -.. _astropy-coordinates-example-gallery: - -Example Gallery -*************** - -This gallery of examples shows a variety of relatively small snippets or -examples of tasks that can be done with the ``astropy.coordinates`` sub-package. - -.. toctree:: - :maxdepth: 1 - - example_gallery_plot_galactocentric_frame - example_gallery_plot_mars_coordinate_frame - example_gallery_plot_obs_planning - example_gallery_plot_sgr_coordinate_frame - example_gallery_rv_to_gsr +.. _astropy-coordinates-example-gallery: + +Example Gallery +*************** + +This gallery of examples shows a variety of relatively small snippets or +examples of tasks that can be done with the ``astropy.coordinates`` sub-package. + +.. toctree:: + :maxdepth: 1 + + example_gallery_plot_galactocentric_frame + example_gallery_plot_mars_coordinate_frame + example_gallery_plot_obs_planning + example_gallery_plot_sgr_coordinate_frame + example_gallery_rv_to_gsr diff --git a/docs/coordinates/example_gallery_plot_galactocentric_frame.rst b/docs/coordinates/example_gallery_plot_galactocentric_frame.rst index 9090c311002a..00e2a4555a95 100644 --- a/docs/coordinates/example_gallery_plot_galactocentric_frame.rst +++ b/docs/coordinates/example_gallery_plot_galactocentric_frame.rst @@ -1,174 +1,174 @@ -.. _sphx_glr_generated_examples_coordinates_plot_galactocentric-frame.py: - -Transforming positions and velocities to and from a Galactocentric frame -======================================================================== - -.. - EXAMPLE START - Transforming positions and velocities to and from a Galactocentric frames - -This example shows a few examples of how to use and customize the -`~astropy.coordinates.Galactocentric` frame to transform Heliocentric sky -positions, distance, proper motions, and radial velocities to a Galactocentric, -Cartesian frame, and the same in reverse. - -The main configurable parameters of the `~astropy.coordinates.Galactocentric` -frame control the position and velocity of the solar system barycenter within -the Galaxy. These are specified by setting the ICRS coordinates of the -Galactic center, the distance to the Galactic center (the sun-galactic center -line is always assumed to be the x-axis of the Galactocentric frame), and the -Cartesian 3-velocity of the sun in the Galactocentric frame. We will first -demonstrate how to customize these values, then show how to set the solar motion -instead by inputting the proper motion of Sgr A*. - -Note that, for brevity, we may refer to the solar system barycenter as just "the -sun" in the examples below. - -Let's first define a barycentric coordinate and velocity in the ICRS frame. -We will use the data for the star HD 39881 from the -`Simbad `_ database: - - ->>> import astropy.coordinates as coord ->>> from astropy import units as u ->>> c1 = coord.SkyCoord( -... ra=89.014303 * u.degree, -... dec=13.924912 * u.degree, -... distance=(37.59 * u.mas).to(u.pc, u.parallax()), -... pm_ra_cosdec=372.72 * (u.mas / u.yr), -... pm_dec=-483.69 * (u.mas / u.yr), -... radial_velocity=0.37 * (u.km / u.s), -... frame="icrs", -... ) - -This is a high proper-motion star; suppose we'd like to transform its position -and velocity to a Galactocentric frame to see if it has a large 3D velocity -as well. To use the Astropy default solar position and motion parameters, we -can do the following: - ->>> gc1 = c1.transform_to(coord.Galactocentric) - -From here, we can access the components of the resulting -`~astropy.coordinates.Galactocentric` instance to see the 3D Cartesian -velocity components: - ->>> print(gc1.v_x, gc1.v_y, gc1.v_z) # doctest: +FLOAT_CMP -30.254684717897074 km / s 171.29916086104885 km / s 18.19390627095307 km / s - -The default parameters for the `~astropy.coordinates.Galactocentric` frame -are detailed in the linked documentation, but we can modify the most commonly -changed values using the keywords ``galcen_distance``, ``galcen_v_sun``, and -``z_sun`` which set the sun-Galactic center distance, the 3D velocity vector -of the sun, and the height of the sun above the Galactic midplane, -respectively. The velocity of the sun can be specified as an -`~astropy.units.Quantity` object with velocity units and is interpreted as a -Cartesian velocity, as in the example below. Note that, as with the positions, -the Galactocentric frame is a right-handed system (i.e., the Sun is at negative -x values) so ``v_x`` is opposite of the Galactocentric radial velocity: - ->>> v_sun = [11.1, 244, 7.25] * (u.km / u.s) # [vx, vy, vz] ->>> gc_frame = coord.Galactocentric( -... galcen_distance=8 * u.kpc, galcen_v_sun=v_sun, z_sun=0 * u.pc -... ) - -We can then transform to this frame instead, with our custom parameters: - ->>> gc2 = c1.transform_to(gc_frame) ->>> print(gc2.v_x, gc2.v_y, gc2.v_z) # doctest: +FLOAT_CMP -28.427958360720748 km / s 169.69916086104888 km / s 17.70831652451455 km / s - -It is sometimes useful to specify the solar motion using the -`proper motion of Sgr A* `_ -instead of Cartesian velocity components. With an assumed distance, we can convert -proper motion components to Cartesian velocity components using `astropy.units`: - ->>> galcen_distance = 8 * u.kpc ->>> pm_gal_sgrA = [-6.379, -0.202] * (u.mas / u.yr) # from Reid & Brunthaler 2004 ->>> vy, vz = -(galcen_distance * pm_gal_sgrA).to(u.km / u.s, u.dimensionless_angles()) - -We still have to assume a line-of-sight velocity for the Galactic center, -which we will again take to be 11 km/s: - ->>> vx = 11.1 * (u.km / u.s) ->>> v_sun2 = u.Quantity([vx, vy, vz]) # List of Quantity -> a single Quantity ->>> gc_frame2 = coord.Galactocentric( -... galcen_distance=galcen_distance, galcen_v_sun=v_sun2, z_sun=0 * u.pc -... ) ->>> gc3 = c1.transform_to(gc_frame2) ->>> print(gc3.v_x, gc3.v_y, gc3.v_z) # doctest: +FLOAT_CMP -28.427958360720748 km / s 167.61484955608267 km / s 18.118916793584443 km / s - -The transformations also work in the opposite direction. This can be useful -for transforming simulated or theoretical data to observable quantities. As -an example, we will generate 4 theoretical circular orbits at different -Galactocentric radii with the same circular velocity, and transform them to -Heliocentric coordinates: - -.. plot:: - :include-source: - - >>> import matplotlib.pyplot as plt - >>> import numpy as np - >>> import astropy.coordinates as coord - >>> from astropy import units as u - >>> ring_distances = np.arange(10, 26, 5) * u.kpc - >>> circ_velocity = 220 * (u.km / u.s) - >>> phi_grid = np.linspace(90, 270, 512) * u.degree # grid of azimuths - >>> ring_rep = coord.CylindricalRepresentation( - ... rho=ring_distances[:, np.newaxis], - ... phi=phi_grid[np.newaxis], - ... z=np.zeros_like(ring_distances)[:, np.newaxis], - ... ) - >>> angular_velocity = (-circ_velocity / ring_distances).to( - ... u.mas / u.yr, u.dimensionless_angles() - ... ) - >>> ring_dif = coord.CylindricalDifferential( - ... d_rho=np.zeros(phi_grid.shape)[np.newaxis] * (u.km / u.s), - ... d_phi=angular_velocity[:, np.newaxis], - ... d_z=np.zeros(phi_grid.shape)[np.newaxis] * (u.km / u.s), - ... ) - >>> ring_rep = ring_rep.with_differentials(ring_dif) - >>> gc_rings = coord.SkyCoord(ring_rep, frame=coord.Galactocentric) - - First, let's visualize the geometry in Galactocentric coordinates. Here are - the positions and velocities of the rings; note that in the velocity plot, - the velocities of the 4 rings are identical and thus overlaid under the same - curve: - - >>> fig, axes = plt.subplots(1, 2, figsize=(12, 6)) - >>> axes[0].plot(gc_rings.x.T, gc_rings.y.T, marker="None", linewidth=3) # doctest: +IGNORE_OUTPUT - >>> axes[0].text(-8.0, 0, r"$\odot$", fontsize=20) # doctest: +IGNORE_OUTPUT - >>> axes[0].set_xlim(-30, 30) # doctest: +IGNORE_OUTPUT - >>> axes[0].set_ylim(-30, 30) # doctest: +IGNORE_OUTPUT - >>> axes[0].set_xlabel("$x$ [kpc]") # doctest: +IGNORE_OUTPUT - >>> axes[0].set_ylabel("$y$ [kpc]") # doctest: +IGNORE_OUTPUT - >>> axes[0].set_title("Positions") # doctest: +IGNORE_OUTPUT - >>> axes[1].plot(gc_rings.v_x.T, gc_rings.v_y.T, marker="None", linewidth=3) # doctest: +IGNORE_OUTPUT - >>> axes[1].set_xlim(-250, 250) # doctest: +IGNORE_OUTPUT - >>> axes[1].set_ylim(-250, 250) # doctest: +IGNORE_OUTPUT - >>> axes[1].set_xlabel(f"$v_x$ [{(u.km / u.s).to_string('latex_inline')}]") # doctest: +IGNORE_OUTPUT - >>> axes[1].set_ylabel(f"$v_y$ [{(u.km / u.s).to_string('latex_inline')}]") # doctest: +IGNORE_OUTPUT - >>> axes[1].set_title("Velocities") # doctest: +IGNORE_OUTPUT - >>> fig.tight_layout() - - Now we can transform to Galactic coordinates and visualize the rings in - observable coordinates: - - >>> gal_rings = gc_rings.transform_to(coord.Galactic) - >>> fig, ax = plt.subplots(1, 1, figsize=(8, 6)) - >>> for i in range(len(ring_distances)): - ... ax.plot( - ... gal_rings[i].l.degree, - ... gal_rings[i].pm_l_cosb.value, - ... label=str(ring_distances[i]), - ... marker="None", - ... linewidth=3, - ... ) # doctest: +IGNORE_OUTPUT - >>> ax.set_xlim(360, 0) # doctest: +IGNORE_OUTPUT - >>> ax.set_xlabel("$l$ [deg]") # doctest: +IGNORE_OUTPUT - >>> ax.set_ylabel(rf'$\mu_l \, \cos b$ [{(u.mas/u.yr).to_string("latex_inline")}]') # doctest: +IGNORE_OUTPUT - >>> ax.legend() # doctest: +IGNORE_OUTPUT - >>> plt.draw() - -.. - EXAMPLE END +.. _sphx_glr_generated_examples_coordinates_plot_galactocentric-frame.py: + +Transforming positions and velocities to and from a Galactocentric frame +======================================================================== + +.. + EXAMPLE START + Transforming positions and velocities to and from a Galactocentric frames + +This example shows a few examples of how to use and customize the +`~astropy.coordinates.Galactocentric` frame to transform Heliocentric sky +positions, distance, proper motions, and radial velocities to a Galactocentric, +Cartesian frame, and the same in reverse. + +The main configurable parameters of the `~astropy.coordinates.Galactocentric` +frame control the position and velocity of the solar system barycenter within +the Galaxy. These are specified by setting the ICRS coordinates of the +Galactic center, the distance to the Galactic center (the sun-galactic center +line is always assumed to be the x-axis of the Galactocentric frame), and the +Cartesian 3-velocity of the sun in the Galactocentric frame. We will first +demonstrate how to customize these values, then show how to set the solar motion +instead by inputting the proper motion of Sgr A*. + +Note that, for brevity, we may refer to the solar system barycenter as just "the +sun" in the examples below. + +Let's first define a barycentric coordinate and velocity in the ICRS frame. +We will use the data for the star HD 39881 from the +`Simbad `_ database: + + +>>> import astropy.coordinates as coord +>>> from astropy import units as u +>>> c1 = coord.SkyCoord( +... ra=89.014303 * u.degree, +... dec=13.924912 * u.degree, +... distance=(37.59 * u.mas).to(u.pc, u.parallax()), +... pm_ra_cosdec=372.72 * (u.mas / u.yr), +... pm_dec=-483.69 * (u.mas / u.yr), +... radial_velocity=0.37 * (u.km / u.s), +... frame="icrs", +... ) + +This is a high proper-motion star; suppose we'd like to transform its position +and velocity to a Galactocentric frame to see if it has a large 3D velocity +as well. To use the Astropy default solar position and motion parameters, we +can do the following: + +>>> gc1 = c1.transform_to(coord.Galactocentric) + +From here, we can access the components of the resulting +`~astropy.coordinates.Galactocentric` instance to see the 3D Cartesian +velocity components: + +>>> print(gc1.v_x, gc1.v_y, gc1.v_z) # doctest: +FLOAT_CMP +30.254684717897074 km / s 171.29916086104885 km / s 18.19390627095307 km / s + +The default parameters for the `~astropy.coordinates.Galactocentric` frame +are detailed in the linked documentation, but we can modify the most commonly +changed values using the keywords ``galcen_distance``, ``galcen_v_sun``, and +``z_sun`` which set the sun-Galactic center distance, the 3D velocity vector +of the sun, and the height of the sun above the Galactic midplane, +respectively. The velocity of the sun can be specified as an +`~astropy.units.Quantity` object with velocity units and is interpreted as a +Cartesian velocity, as in the example below. Note that, as with the positions, +the Galactocentric frame is a right-handed system (i.e., the Sun is at negative +x values) so ``v_x`` is opposite of the Galactocentric radial velocity: + +>>> v_sun = [11.1, 244, 7.25] * (u.km / u.s) # [vx, vy, vz] +>>> gc_frame = coord.Galactocentric( +... galcen_distance=8 * u.kpc, galcen_v_sun=v_sun, z_sun=0 * u.pc +... ) + +We can then transform to this frame instead, with our custom parameters: + +>>> gc2 = c1.transform_to(gc_frame) +>>> print(gc2.v_x, gc2.v_y, gc2.v_z) # doctest: +FLOAT_CMP +28.427958360720748 km / s 169.69916086104888 km / s 17.70831652451455 km / s + +It is sometimes useful to specify the solar motion using the +`proper motion of Sgr A* `_ +instead of Cartesian velocity components. With an assumed distance, we can convert +proper motion components to Cartesian velocity components using `astropy.units`: + +>>> galcen_distance = 8 * u.kpc +>>> pm_gal_sgrA = [-6.379, -0.202] * (u.mas / u.yr) # from Reid & Brunthaler 2004 +>>> vy, vz = -(galcen_distance * pm_gal_sgrA).to(u.km / u.s, u.dimensionless_angles()) + +We still have to assume a line-of-sight velocity for the Galactic center, +which we will again take to be 11 km/s: + +>>> vx = 11.1 * (u.km / u.s) +>>> v_sun2 = u.Quantity([vx, vy, vz]) # List of Quantity -> a single Quantity +>>> gc_frame2 = coord.Galactocentric( +... galcen_distance=galcen_distance, galcen_v_sun=v_sun2, z_sun=0 * u.pc +... ) +>>> gc3 = c1.transform_to(gc_frame2) +>>> print(gc3.v_x, gc3.v_y, gc3.v_z) # doctest: +FLOAT_CMP +28.427958360720748 km / s 167.61484955608267 km / s 18.118916793584443 km / s + +The transformations also work in the opposite direction. This can be useful +for transforming simulated or theoretical data to observable quantities. As +an example, we will generate 4 theoretical circular orbits at different +Galactocentric radii with the same circular velocity, and transform them to +Heliocentric coordinates: + +.. plot:: + :include-source: + + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> import astropy.coordinates as coord + >>> from astropy import units as u + >>> ring_distances = np.arange(10, 26, 5) * u.kpc + >>> circ_velocity = 220 * (u.km / u.s) + >>> phi_grid = np.linspace(90, 270, 512) * u.degree # grid of azimuths + >>> ring_rep = coord.CylindricalRepresentation( + ... rho=ring_distances[:, np.newaxis], + ... phi=phi_grid[np.newaxis], + ... z=np.zeros_like(ring_distances)[:, np.newaxis], + ... ) + >>> angular_velocity = (-circ_velocity / ring_distances).to( + ... u.mas / u.yr, u.dimensionless_angles() + ... ) + >>> ring_dif = coord.CylindricalDifferential( + ... d_rho=np.zeros(phi_grid.shape)[np.newaxis] * (u.km / u.s), + ... d_phi=angular_velocity[:, np.newaxis], + ... d_z=np.zeros(phi_grid.shape)[np.newaxis] * (u.km / u.s), + ... ) + >>> ring_rep = ring_rep.with_differentials(ring_dif) + >>> gc_rings = coord.SkyCoord(ring_rep, frame=coord.Galactocentric) + + First, let's visualize the geometry in Galactocentric coordinates. Here are + the positions and velocities of the rings; note that in the velocity plot, + the velocities of the 4 rings are identical and thus overlaid under the same + curve: + + >>> fig, axes = plt.subplots(1, 2, figsize=(12, 6)) + >>> axes[0].plot(gc_rings.x.T, gc_rings.y.T, marker="None", linewidth=3) # doctest: +IGNORE_OUTPUT + >>> axes[0].text(-8.0, 0, r"$\odot$", fontsize=20) # doctest: +IGNORE_OUTPUT + >>> axes[0].set_xlim(-30, 30) # doctest: +IGNORE_OUTPUT + >>> axes[0].set_ylim(-30, 30) # doctest: +IGNORE_OUTPUT + >>> axes[0].set_xlabel("$x$ [kpc]") # doctest: +IGNORE_OUTPUT + >>> axes[0].set_ylabel("$y$ [kpc]") # doctest: +IGNORE_OUTPUT + >>> axes[0].set_title("Positions") # doctest: +IGNORE_OUTPUT + >>> axes[1].plot(gc_rings.v_x.T, gc_rings.v_y.T, marker="None", linewidth=3) # doctest: +IGNORE_OUTPUT + >>> axes[1].set_xlim(-250, 250) # doctest: +IGNORE_OUTPUT + >>> axes[1].set_ylim(-250, 250) # doctest: +IGNORE_OUTPUT + >>> axes[1].set_xlabel(f"$v_x$ [{(u.km / u.s).to_string('latex_inline')}]") # doctest: +IGNORE_OUTPUT + >>> axes[1].set_ylabel(f"$v_y$ [{(u.km / u.s).to_string('latex_inline')}]") # doctest: +IGNORE_OUTPUT + >>> axes[1].set_title("Velocities") # doctest: +IGNORE_OUTPUT + >>> fig.tight_layout() + + Now we can transform to Galactic coordinates and visualize the rings in + observable coordinates: + + >>> gal_rings = gc_rings.transform_to(coord.Galactic) + >>> fig, ax = plt.subplots(1, 1, figsize=(8, 6)) + >>> for i in range(len(ring_distances)): + ... ax.plot( + ... gal_rings[i].l.degree, + ... gal_rings[i].pm_l_cosb.value, + ... label=str(ring_distances[i]), + ... marker="None", + ... linewidth=3, + ... ) # doctest: +IGNORE_OUTPUT + >>> ax.set_xlim(360, 0) # doctest: +IGNORE_OUTPUT + >>> ax.set_xlabel("$l$ [deg]") # doctest: +IGNORE_OUTPUT + >>> ax.set_ylabel(rf'$\mu_l \, \cos b$ [{(u.mas/u.yr).to_string("latex_inline")}]') # doctest: +IGNORE_OUTPUT + >>> ax.legend() # doctest: +IGNORE_OUTPUT + >>> plt.draw() + +.. + EXAMPLE END diff --git a/docs/coordinates/example_gallery_plot_mars_coordinate_frame.rst b/docs/coordinates/example_gallery_plot_mars_coordinate_frame.rst index 987bb6aa3379..7eee3fab201b 100644 --- a/docs/coordinates/example_gallery_plot_mars_coordinate_frame.rst +++ b/docs/coordinates/example_gallery_plot_mars_coordinate_frame.rst @@ -1,115 +1,115 @@ -.. _sphx_glr_generated_examples_coordinates_plot_mars-coordinate-frame.py: - -Create a new coordinate frame class for Mars -============================================ - -.. - EXAMPLE START - Create a new coordinate frame class for Mars - -This example describes how to subclass and define a custom coordinate frame for a -planetary body which can be described by a geodetic or bodycentric representation, -as discussed in :ref:`astropy:astropy-coordinates-design` and -:ref:`astropy-coordinates-create-geodetic`. - -Note that we use the frame here only to store coordinates. To use it to determine, e.g., -where to point a telescope on Earth to observe Olympus Mons, one would need to add the -frame to the transfer graph, which is beyond the scope of this example. - -To do this, first we need to define a subclass of a -`~astropy.coordinates.BaseGeodeticRepresentation` and -`~astropy.coordinates.BaseBodycentricRepresentation`, then a subclass of -`~astropy.coordinates.BaseCoordinateFrame` using the previous defined -representations. - -.. plot:: - :include-source: - - >>> import matplotlib.pyplot as plt - >>> import numpy as np - >>> from astropy import units as u - >>> from astropy.coordinates.baseframe import BaseCoordinateFrame - >>> from astropy.coordinates.representation import CartesianRepresentation - >>> from astropy.coordinates.representation.geodetic import ( - ... BaseBodycentricRepresentation, - ... BaseGeodeticRepresentation, - ... ) - >>> from astropy.visualization import quantity_support - - The first step is to create a new class, and make it a subclass of - `~astropy.coordinates.BaseGeodeticRepresentation`. - Geodetic latitudes are used and longitudes span from 0 to 360 degrees east positive - It represent a best fit of the Mars spheroid to the martian geoid (areoid): - - >>> class MarsBestFitAeroid(BaseGeodeticRepresentation): - ... """A Spheroidal representation of Mars that minimized deviations with respect to the - ... areoid following - ... Ardalan A. A, R. Karimi, and E. W. Grafarend (2010) - ... https://doi.org/10.1007/s11038-009-9342-7 - ... """ - ... _equatorial_radius = 3395.4280 * u.km - ... _flattening = 0.5227617843759314 * u.percent - - Now let's define a new geodetic representation obtained from MarsBestFitAeroid but - described by planetocentric latitudes: - - >>> class MarsBestFitOcentricAeroid(BaseBodycentricRepresentation): - ... """A Spheroidal planetocentric representation of Mars that minimized deviations with - ... respect to the areoid following - ... Ardalan A. A, R. Karimi, and E. W. Grafarend (2010) - ... https://doi.org/10.1007/s11038-009-9342-7 - ... """ - ... _equatorial_radius = 3395.4280 * u.km - ... _flattening = 0.5227617843759314 * u.percent - - As a comparison we define a new spherical frame representation, we could - have based it on `~astropy.coordinates.BaseBodycentricRepresentation` too: - - >>> class MarsSphere(BaseGeodeticRepresentation): - ... """A Spherical representation of Mars.""" - ... _equatorial_radius = 3395.4280 * u.km - ... _flattening = 0.0 * u.percent - - The new planetary body-fixed reference system will be described using the - previous defined representations: - - >>> class MarsCoordinateFrame(BaseCoordinateFrame): - ... """A reference system for Mars.""" - ... name = "Mars" - - Now we plot the differences between each component of the cartesian - representation with respect to the spherical model, assuming the point on the - surface of the body (``height = 0``): - - >>> mars_sphere = MarsCoordinateFrame( - ... lon=np.linspace(0, 360, 128) * u.deg, - ... lat=np.linspace(-90, 90, 128) * u.deg, - ... representation_type=MarsSphere, - ... ) - >>> mars = MarsCoordinateFrame( - ... lon=np.linspace(0, 360, 128) * u.deg, - ... lat=np.linspace(-90, 90, 128) * u.deg, - ... representation_type=MarsBestFitAeroid, - ... ) - >>> mars_ocentric = MarsCoordinateFrame( - ... lon=np.linspace(0, 360, 128) * u.deg, - ... lat=np.linspace(-90, 90, 128) * u.deg, - ... representation_type=MarsBestFitOcentricAeroid, - ... ) - >>> xyz_sphere = mars_sphere.represent_as(CartesianRepresentation) - >>> xyz = mars.represent_as(CartesianRepresentation) - >>> xyz_ocentric = mars_ocentric.represent_as(CartesianRepresentation) - >>> with quantity_support(): - ... fig, ax = plt.subplots(2, subplot_kw={"projection": "3d"}) - ... ax[0].scatter(*((xyz - xyz_sphere).xyz << u.km)) # doctest: +IGNORE_OUTPUT - ... ax[0].tick_params(labelsize=8) # doctest: +IGNORE_OUTPUT - ... ax[0].set(xlabel="x [km]", ylabel="y [km]", zlabel="z [km]") # doctest: +IGNORE_OUTPUT - ... ax[0].set_title("Mars-odetic spheroid difference from sphere") # doctest: +IGNORE_OUTPUT - ... ax[1].scatter(*((xyz_ocentric - xyz_sphere).xyz << u.km)) # doctest: +IGNORE_OUTPUT - ... ax[1].tick_params(labelsize=8) # doctest: +IGNORE_OUTPUT - ... ax[1].set(xlabel="x [km]", ylabel="y [km]", zlabel="z [km]") # doctest: +IGNORE_OUTPUT - ... ax[1].set_title("Mars-ocentric spheroid difference from sphere") # doctest: +IGNORE_OUTPUT - ... plt.draw() - -.. - EXAMPLE END +.. _sphx_glr_generated_examples_coordinates_plot_mars-coordinate-frame.py: + +Create a new coordinate frame class for Mars +============================================ + +.. + EXAMPLE START + Create a new coordinate frame class for Mars + +This example describes how to subclass and define a custom coordinate frame for a +planetary body which can be described by a geodetic or bodycentric representation, +as discussed in :ref:`astropy:astropy-coordinates-design` and +:ref:`astropy-coordinates-create-geodetic`. + +Note that we use the frame here only to store coordinates. To use it to determine, e.g., +where to point a telescope on Earth to observe Olympus Mons, one would need to add the +frame to the transfer graph, which is beyond the scope of this example. + +To do this, first we need to define a subclass of a +`~astropy.coordinates.BaseGeodeticRepresentation` and +`~astropy.coordinates.BaseBodycentricRepresentation`, then a subclass of +`~astropy.coordinates.BaseCoordinateFrame` using the previous defined +representations. + +.. plot:: + :include-source: + + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> from astropy import units as u + >>> from astropy.coordinates.baseframe import BaseCoordinateFrame + >>> from astropy.coordinates.representation import CartesianRepresentation + >>> from astropy.coordinates.representation.geodetic import ( + ... BaseBodycentricRepresentation, + ... BaseGeodeticRepresentation, + ... ) + >>> from astropy.visualization import quantity_support + + The first step is to create a new class, and make it a subclass of + `~astropy.coordinates.BaseGeodeticRepresentation`. + Geodetic latitudes are used and longitudes span from 0 to 360 degrees east positive + It represent a best fit of the Mars spheroid to the martian geoid (areoid): + + >>> class MarsBestFitAeroid(BaseGeodeticRepresentation): + ... """A Spheroidal representation of Mars that minimized deviations with respect to the + ... areoid following + ... Ardalan A. A, R. Karimi, and E. W. Grafarend (2010) + ... https://doi.org/10.1007/s11038-009-9342-7 + ... """ + ... _equatorial_radius = 3395.4280 * u.km + ... _flattening = 0.5227617843759314 * u.percent + + Now let's define a new geodetic representation obtained from MarsBestFitAeroid but + described by planetocentric latitudes: + + >>> class MarsBestFitOcentricAeroid(BaseBodycentricRepresentation): + ... """A Spheroidal planetocentric representation of Mars that minimized deviations with + ... respect to the areoid following + ... Ardalan A. A, R. Karimi, and E. W. Grafarend (2010) + ... https://doi.org/10.1007/s11038-009-9342-7 + ... """ + ... _equatorial_radius = 3395.4280 * u.km + ... _flattening = 0.5227617843759314 * u.percent + + As a comparison we define a new spherical frame representation, we could + have based it on `~astropy.coordinates.BaseBodycentricRepresentation` too: + + >>> class MarsSphere(BaseGeodeticRepresentation): + ... """A Spherical representation of Mars.""" + ... _equatorial_radius = 3395.4280 * u.km + ... _flattening = 0.0 * u.percent + + The new planetary body-fixed reference system will be described using the + previous defined representations: + + >>> class MarsCoordinateFrame(BaseCoordinateFrame): + ... """A reference system for Mars.""" + ... name = "Mars" + + Now we plot the differences between each component of the cartesian + representation with respect to the spherical model, assuming the point on the + surface of the body (``height = 0``): + + >>> mars_sphere = MarsCoordinateFrame( + ... lon=np.linspace(0, 360, 128) * u.deg, + ... lat=np.linspace(-90, 90, 128) * u.deg, + ... representation_type=MarsSphere, + ... ) + >>> mars = MarsCoordinateFrame( + ... lon=np.linspace(0, 360, 128) * u.deg, + ... lat=np.linspace(-90, 90, 128) * u.deg, + ... representation_type=MarsBestFitAeroid, + ... ) + >>> mars_ocentric = MarsCoordinateFrame( + ... lon=np.linspace(0, 360, 128) * u.deg, + ... lat=np.linspace(-90, 90, 128) * u.deg, + ... representation_type=MarsBestFitOcentricAeroid, + ... ) + >>> xyz_sphere = mars_sphere.represent_as(CartesianRepresentation) + >>> xyz = mars.represent_as(CartesianRepresentation) + >>> xyz_ocentric = mars_ocentric.represent_as(CartesianRepresentation) + >>> with quantity_support(): + ... fig, ax = plt.subplots(2, subplot_kw={"projection": "3d"}) + ... ax[0].scatter(*((xyz - xyz_sphere).xyz << u.km)) # doctest: +IGNORE_OUTPUT + ... ax[0].tick_params(labelsize=8) # doctest: +IGNORE_OUTPUT + ... ax[0].set(xlabel="x [km]", ylabel="y [km]", zlabel="z [km]") # doctest: +IGNORE_OUTPUT + ... ax[0].set_title("Mars-odetic spheroid difference from sphere") # doctest: +IGNORE_OUTPUT + ... ax[1].scatter(*((xyz_ocentric - xyz_sphere).xyz << u.km)) # doctest: +IGNORE_OUTPUT + ... ax[1].tick_params(labelsize=8) # doctest: +IGNORE_OUTPUT + ... ax[1].set(xlabel="x [km]", ylabel="y [km]", zlabel="z [km]") # doctest: +IGNORE_OUTPUT + ... ax[1].set_title("Mars-ocentric spheroid difference from sphere") # doctest: +IGNORE_OUTPUT + ... plt.draw() + +.. + EXAMPLE END diff --git a/docs/coordinates/example_gallery_plot_obs_planning.rst b/docs/coordinates/example_gallery_plot_obs_planning.rst index fd361fb81cc1..b5c7a6e9550b 100644 --- a/docs/coordinates/example_gallery_plot_obs_planning.rst +++ b/docs/coordinates/example_gallery_plot_obs_planning.rst @@ -1,154 +1,154 @@ -.. _sphx_glr_generated_examples_coordinates_plot_obs-planning.py: - -Determining and plotting the altitude/azimuth of a celestial object -=================================================================== - -.. - EXAMPLE START - Determining and plotting the altitude/azimuth of a celestial object - -This example demonstrates coordinate transformations and the creation of -visibility curves to assist with observing run planning. - -In this example, we make a `~astropy.coordinates.SkyCoord` instance for M33. -The altitude-azimuth coordinates are then found using -`astropy.coordinates.EarthLocation` and `astropy.time.Time` objects. - -This example is meant to demonstrate the capabilities of the -`astropy.coordinates` package. For more convenient and/or complex observation -planning, consider the `astroplan `_ -package. - -Let's suppose you are planning to visit picturesque Bear Mountain State Park -in New York, USA. You are bringing your telescope with you (of course), and -someone told you M33 is a great target to observe there. You happen to know -you are free at 11:00 PM local time, and you want to know if it will be up. -Astropy can answer that. - -.. plot:: - :include-source: - - >>> import matplotlib.pyplot as plt - >>> import numpy as np - >>> from astropy import units as u - >>> from astropy.coordinates import AltAz, EarthLocation, SkyCoord, get_body, get_sun - >>> from astropy.time import Time - >>> from astropy.visualization import quantity_support - - :meth:`astropy.coordinates.SkyCoord.from_name` uses Simbad to resolve object - names and retrieve coordinates. - - Get the coordinates of M33: - - >>> # m33 = SkyCoord.from_name("M33") - >>> m33 = SkyCoord(23.46206906, 30.66017511, unit="deg") - - Use `astropy.coordinates.EarthLocation` to provide the location of Bear - Mountain and set the time to 11pm Eastern Daylight Time (EDT) on 2012 July 12: - - >>> bear_mountain = EarthLocation(lat=41.3 * u.deg, lon=-74 * u.deg, height=390 * u.m) - >>> utcoffset = -4 * u.hour # EDT - >>> time = Time("2012-7-12 23:00:00") - utcoffset - - :meth:`astropy.coordinates.EarthLocation.get_site_names` can be used to get - locations of major observatories. - - Use `astropy.coordinates` to find the Alt, Az coordinates of M33 at as - observed from Bear Mountain at 11pm on 2012 July 12: - - >>> m33altaz = m33.transform_to(AltAz(obstime=time, location=bear_mountain)) - >>> print(f"M33's Altitude = {m33altaz.alt:.2}") - M33's Altitude = 0.13 deg - - This is helpful since it turns out M33 is barely above the horizon at this - time. It is more informative to find M33's airmass over the course of - the night. - - Find the Alt, Az coordinates of M33 at 100 times evenly spaced between 10 PM - and 7 AM EDT: - - >>> midnight = Time("2012-7-13 00:00:00") - utcoffset - >>> delta_midnight = np.linspace(-2, 10, 100) * u.hour - >>> frame_July13night = AltAz(obstime=midnight + delta_midnight, location=bear_mountain) - >>> m33altazs_July13night = m33.transform_to(frame_July13night) - - Convert Alt, Az to airmass with `~astropy.coordinates.AltAz.secz` attribute: - - >>> m33airmasss_July13night = m33altazs_July13night.secz - - Plot the airmass as a function of time: - - >>> with quantity_support(): - ... fig, ax = plt.subplots(1, 1, figsize=(12, 6)) - ... ax.plot(delta_midnight, m33airmasss_July13night) # doctest: +IGNORE_OUTPUT - ... ax.set_xlim(-2, 10) # doctest: +IGNORE_OUTPUT - ... ax.set_ylim(1, 4) # doctest: +IGNORE_OUTPUT - ... ax.set_xlabel("Hours from EDT Midnight") # doctest: +IGNORE_OUTPUT - ... ax.set_ylabel("Airmass [Sec(z)]") # doctest: +IGNORE_OUTPUT - ... plt.draw() - - Use :func:`~astropy.coordinates.get_sun` to find the location of the Sun at 1000 - evenly spaced times between noon on July 12 and noon on July 13: - - >>> delta_midnight = np.linspace(-12, 12, 1000) * u.hour - >>> times_July12_to_13 = midnight + delta_midnight - >>> frame_July12_to_13 = AltAz(obstime=times_July12_to_13, location=bear_mountain) - >>> sunaltazs_July12_to_13 = get_sun(times_July12_to_13).transform_to(frame_July12_to_13) - - Do the same with :func:`~astropy.coordinates.get_body` to find when the moon is - up. Be aware that this will need to download a 10 MB file from the internet - to get a precise location of the moon. - - >>> moon_July12_to_13 = get_body("moon", times_July12_to_13) - >>> moonaltazs_July12_to_13 = moon_July12_to_13.transform_to(frame_July12_to_13) - - Find the Alt, Az coordinates of M33 at those same times: - - >>> m33altazs_July12_to_13 = m33.transform_to(frame_July12_to_13) - - Make a figure illustrating nighttime and the altitudes of M33 and - the Sun over that time: - - >>> with quantity_support(): - ... fig, ax = plt.subplots(1, 1, figsize=(12, 6)) - ... ax.plot(delta_midnight, sunaltazs_July12_to_13.alt, color="r", label="Sun") # doctest: +IGNORE_OUTPUT - ... ax.plot( - ... delta_midnight, moonaltazs_July12_to_13.alt, color=[0.75] * 3, ls="--", label="Moon" - ... ) # doctest: +IGNORE_OUTPUT - ... mappable = ax.scatter( - ... delta_midnight, - ... m33altazs_July12_to_13.alt, - ... c=m33altazs_July12_to_13.az.value, - ... label="M33", - ... lw=0, - ... s=8, - ... cmap="viridis", - ... ) - ... ax.fill_between( - ... delta_midnight, - ... 0 * u.deg, - ... 90 * u.deg, - ... sunaltazs_July12_to_13.alt < (-0 * u.deg), - ... color="0.5", - ... zorder=0, - ... ) # doctest: +IGNORE_OUTPUT - ... ax.fill_between( - ... delta_midnight, - ... 0 * u.deg, - ... 90 * u.deg, - ... sunaltazs_July12_to_13.alt < (-18 * u.deg), - ... color="k", - ... zorder=0, - ... ) # doctest: +IGNORE_OUTPUT - ... fig.colorbar(mappable).set_label("Azimuth [deg]") # doctest: +IGNORE_OUTPUT - ... ax.legend(loc="upper left") # doctest: +IGNORE_OUTPUT - ... ax.set_xlim(-12 * u.hour, 12 * u.hour) # doctest: +IGNORE_OUTPUT - ... ax.set_xticks((np.arange(13) * 2 - 12) * u.hour) # doctest: +IGNORE_OUTPUT - ... ax.set_ylim(0 * u.deg, 90 * u.deg) # doctest: +IGNORE_OUTPUT - ... ax.set_xlabel("Hours from EDT Midnight") # doctest: +IGNORE_OUTPUT - ... ax.set_ylabel("Altitude [deg]") # doctest: +IGNORE_OUTPUT - ... ax.grid(visible=True) # doctest: +IGNORE_OUTPUT - ... plt.draw() - -.. - EXAMPLE END +.. _sphx_glr_generated_examples_coordinates_plot_obs-planning.py: + +Determining and plotting the altitude/azimuth of a celestial object +=================================================================== + +.. + EXAMPLE START + Determining and plotting the altitude/azimuth of a celestial object + +This example demonstrates coordinate transformations and the creation of +visibility curves to assist with observing run planning. + +In this example, we make a `~astropy.coordinates.SkyCoord` instance for M33. +The altitude-azimuth coordinates are then found using +`astropy.coordinates.EarthLocation` and `astropy.time.Time` objects. + +This example is meant to demonstrate the capabilities of the +`astropy.coordinates` package. For more convenient and/or complex observation +planning, consider the `astroplan `_ +package. + +Let's suppose you are planning to visit picturesque Bear Mountain State Park +in New York, USA. You are bringing your telescope with you (of course), and +someone told you M33 is a great target to observe there. You happen to know +you are free at 11:00 PM local time, and you want to know if it will be up. +Astropy can answer that. + +.. plot:: + :include-source: + + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> from astropy import units as u + >>> from astropy.coordinates import AltAz, EarthLocation, SkyCoord, get_body, get_sun + >>> from astropy.time import Time + >>> from astropy.visualization import quantity_support + + :meth:`astropy.coordinates.SkyCoord.from_name` uses Simbad to resolve object + names and retrieve coordinates. + + Get the coordinates of M33: + + >>> # m33 = SkyCoord.from_name("M33") + >>> m33 = SkyCoord(23.46206906, 30.66017511, unit="deg") + + Use `astropy.coordinates.EarthLocation` to provide the location of Bear + Mountain and set the time to 11pm Eastern Daylight Time (EDT) on 2012 July 12: + + >>> bear_mountain = EarthLocation(lat=41.3 * u.deg, lon=-74 * u.deg, height=390 * u.m) + >>> utcoffset = -4 * u.hour # EDT + >>> time = Time("2012-7-12 23:00:00") - utcoffset + + :meth:`astropy.coordinates.EarthLocation.get_site_names` can be used to get + locations of major observatories. + + Use `astropy.coordinates` to find the Alt, Az coordinates of M33 at as + observed from Bear Mountain at 11pm on 2012 July 12: + + >>> m33altaz = m33.transform_to(AltAz(obstime=time, location=bear_mountain)) + >>> print(f"M33's Altitude = {m33altaz.alt:.2}") + M33's Altitude = 0.13 deg + + This is helpful since it turns out M33 is barely above the horizon at this + time. It is more informative to find M33's airmass over the course of + the night. + + Find the Alt, Az coordinates of M33 at 100 times evenly spaced between 10 PM + and 7 AM EDT: + + >>> midnight = Time("2012-7-13 00:00:00") - utcoffset + >>> delta_midnight = np.linspace(-2, 10, 100) * u.hour + >>> frame_July13night = AltAz(obstime=midnight + delta_midnight, location=bear_mountain) + >>> m33altazs_July13night = m33.transform_to(frame_July13night) + + Convert Alt, Az to airmass with `~astropy.coordinates.AltAz.secz` attribute: + + >>> m33airmasss_July13night = m33altazs_July13night.secz + + Plot the airmass as a function of time: + + >>> with quantity_support(): + ... fig, ax = plt.subplots(1, 1, figsize=(12, 6)) + ... ax.plot(delta_midnight, m33airmasss_July13night) # doctest: +IGNORE_OUTPUT + ... ax.set_xlim(-2, 10) # doctest: +IGNORE_OUTPUT + ... ax.set_ylim(1, 4) # doctest: +IGNORE_OUTPUT + ... ax.set_xlabel("Hours from EDT Midnight") # doctest: +IGNORE_OUTPUT + ... ax.set_ylabel("Airmass [Sec(z)]") # doctest: +IGNORE_OUTPUT + ... plt.draw() + + Use :func:`~astropy.coordinates.get_sun` to find the location of the Sun at 1000 + evenly spaced times between noon on July 12 and noon on July 13: + + >>> delta_midnight = np.linspace(-12, 12, 1000) * u.hour + >>> times_July12_to_13 = midnight + delta_midnight + >>> frame_July12_to_13 = AltAz(obstime=times_July12_to_13, location=bear_mountain) + >>> sunaltazs_July12_to_13 = get_sun(times_July12_to_13).transform_to(frame_July12_to_13) + + Do the same with :func:`~astropy.coordinates.get_body` to find when the moon is + up. Be aware that this will need to download a 10 MB file from the internet + to get a precise location of the moon. + + >>> moon_July12_to_13 = get_body("moon", times_July12_to_13) + >>> moonaltazs_July12_to_13 = moon_July12_to_13.transform_to(frame_July12_to_13) + + Find the Alt, Az coordinates of M33 at those same times: + + >>> m33altazs_July12_to_13 = m33.transform_to(frame_July12_to_13) + + Make a figure illustrating nighttime and the altitudes of M33 and + the Sun over that time: + + >>> with quantity_support(): + ... fig, ax = plt.subplots(1, 1, figsize=(12, 6)) + ... ax.plot(delta_midnight, sunaltazs_July12_to_13.alt, color="r", label="Sun") # doctest: +IGNORE_OUTPUT + ... ax.plot( + ... delta_midnight, moonaltazs_July12_to_13.alt, color=[0.75] * 3, ls="--", label="Moon" + ... ) # doctest: +IGNORE_OUTPUT + ... mappable = ax.scatter( + ... delta_midnight, + ... m33altazs_July12_to_13.alt, + ... c=m33altazs_July12_to_13.az.value, + ... label="M33", + ... lw=0, + ... s=8, + ... cmap="viridis", + ... ) + ... ax.fill_between( + ... delta_midnight, + ... 0 * u.deg, + ... 90 * u.deg, + ... sunaltazs_July12_to_13.alt < (-0 * u.deg), + ... color="0.5", + ... zorder=0, + ... ) # doctest: +IGNORE_OUTPUT + ... ax.fill_between( + ... delta_midnight, + ... 0 * u.deg, + ... 90 * u.deg, + ... sunaltazs_July12_to_13.alt < (-18 * u.deg), + ... color="k", + ... zorder=0, + ... ) # doctest: +IGNORE_OUTPUT + ... fig.colorbar(mappable).set_label("Azimuth [deg]") # doctest: +IGNORE_OUTPUT + ... ax.legend(loc="upper left") # doctest: +IGNORE_OUTPUT + ... ax.set_xlim(-12 * u.hour, 12 * u.hour) # doctest: +IGNORE_OUTPUT + ... ax.set_xticks((np.arange(13) * 2 - 12) * u.hour) # doctest: +IGNORE_OUTPUT + ... ax.set_ylim(0 * u.deg, 90 * u.deg) # doctest: +IGNORE_OUTPUT + ... ax.set_xlabel("Hours from EDT Midnight") # doctest: +IGNORE_OUTPUT + ... ax.set_ylabel("Altitude [deg]") # doctest: +IGNORE_OUTPUT + ... ax.grid(visible=True) # doctest: +IGNORE_OUTPUT + ... plt.draw() + +.. + EXAMPLE END diff --git a/docs/coordinates/example_gallery_plot_sgr_coordinate_frame.rst b/docs/coordinates/example_gallery_plot_sgr_coordinate_frame.rst index 5f752001ba84..935ad8819db6 100644 --- a/docs/coordinates/example_gallery_plot_sgr_coordinate_frame.rst +++ b/docs/coordinates/example_gallery_plot_sgr_coordinate_frame.rst @@ -1,237 +1,237 @@ -.. _sphx_glr_generated_examples_coordinates_plot_sgr-coordinate-frame.py: - -Create a new coordinate class (for the Sagittarius stream) -========================================================== - -.. - EXAMPLE START - Create a new coordinate class (for the Sagittarius stream) - -This document describes in detail how to subclass and define a custom spherical -coordinate frame, as discussed in :ref:`astropy:astropy-coordinates-design` and -the docstring for `~astropy.coordinates.BaseCoordinateFrame`. In this example, -we will define a coordinate system defined by the plane of orbit of the -Sagittarius Dwarf Galaxy (hereafter Sgr; as defined in Majewski et al. 2003). -The Sgr coordinate system is often referred to in terms of two angular -coordinates, :math:`\Lambda,B`. - -To do this, we need to define a subclass of -`~astropy.coordinates.BaseCoordinateFrame` that knows the names and units of the -coordinate system angles in each of the supported representations. In this case -we support `~astropy.coordinates.SphericalRepresentation` with "Lambda" and -"Beta". Then we have to define the transformation from this coordinate system to -some other built-in system. Here we will use Galactic coordinates, represented -by the `~astropy.coordinates.Galactic` class. - -.. seealso:: - - The `gala package `_ - Defines a number of Astropy coordinate frames for - stellar stream coordinate systems. - - Majewski et al. 2003 - "A Two Micron All Sky Survey View of the Sagittarius - Dwarf Galaxy. I. Morphology of the Sagittarius Core and Tidal Arms", - https://arxiv.org/abs/astro-ph/0304198 - - Law & Majewski 2010 - "The Sagittarius Dwarf Galaxy: A Model for Evolution in a - Triaxial Milky Way Halo", https://arxiv.org/abs/1003.1132 - - David Law's Sgr info page - https://www.stsci.edu/~dlaw/Sgr/ - -.. plot:: - :include-source: - - >>> import matplotlib.pyplot as plt - >>> import numpy as np - >>> import astropy.coordinates as coord - >>> from astropy import units as u - >>> from astropy.coordinates import frame_transform_graph, rotation_matrix - - The first step is to create a new class, which we'll call - ``Sagittarius`` and make it a subclass of - `~astropy.coordinates.BaseCoordinateFrame`: - - >>> class Sagittarius(coord.BaseCoordinateFrame): - ... """A Heliocentric spherical coordinate system defined by the orbit - ... of the Sagittarius dwarf galaxy, as described in - ... https://ui.adsabs.harvard.edu/abs/2003ApJ...599.1082M - ... and further explained in - ... https://www.stsci.edu/~dlaw/Sgr/. - ... - ... Parameters - ... ---------- - ... representation : `~astropy.coordinates.BaseRepresentation` or None - ... A representation object or None to have no data (or use the other keywords) - ... Lambda : `~astropy.coordinates.Angle`, optional, must be keyword - ... The longitude-like angle corresponding to Sagittarius' orbit. - ... Beta : `~astropy.coordinates.Angle`, optional, must be keyword - ... The latitude-like angle corresponding to Sagittarius' orbit. - ... distance : `~astropy.units.Quantity`, optional, must be keyword - ... The Distance for this object along the line-of-sight. - ... pm_Lambda_cosBeta : `~astropy.units.Quantity`, optional, must be keyword - ... The proper motion along the stream in ``Lambda`` (including the - ... ``cos(Beta)`` factor) for this object (``pm_Beta`` must also be given). - ... pm_Beta : `~astropy.units.Quantity`, optional, must be keyword - ... The proper motion in Declination for this object (``pm_ra_cosdec`` must - ... also be given). - ... radial_velocity : `~astropy.units.Quantity`, optional, keyword-only - ... The radial velocity of this object. - ... """ - ... default_representation = coord.SphericalRepresentation - ... default_differential = coord.SphericalCosLatDifferential - ... frame_specific_representation_info = { - ... coord.SphericalRepresentation: [ - ... coord.RepresentationMapping("lon", "Lambda"), - ... coord.RepresentationMapping("lat", "Beta"), - ... coord.RepresentationMapping("distance", "distance"), - ... ] - ... } - - Breaking this down line-by-line, we define the class as a subclass of - `~astropy.coordinates.BaseCoordinateFrame`. Then we include a descriptive - docstring. The final lines are class-level attributes that specify the - default representation for the data, default differential for the velocity - information, and mappings from the attribute names used by representation - objects to the names that are to be used by the ``Sagittarius`` frame. In this - case we override the names in the spherical representations but do not do - anything with other representations like cartesian or cylindrical. - - Next we have to define the transformation from this coordinate system to some - other built-in coordinate system; we will use Galactic coordinates. We can do - this by defining functions that return transformation matrices, or by simply - defining a function that accepts a coordinate and returns a new coordinate in - the new system. Because the transformation to the Sagittarius coordinate - stem is just a spherical rotation from Galactic coordinates, we will - define a function that returns this matrix. We will start by constructing the - transformation matrix using pre-determined Euler angles and the - ``rotation_matrix`` helper function: - - >>> SGR_PHI = (180 + 3.75) * u.degree # Euler angles (from Law & Majewski 2010) - >>> SGR_THETA = (90 - 13.46) * u.degree - >>> SGR_PSI = (180 + 14.111534) * u.degree - - Generate the rotation matrix using the x-convention (see Goldstein): - - >>> SGR_MATRIX = ( - ... np.diag([1.0, 1.0, -1.0]) - ... @ rotation_matrix(SGR_PSI, "z") - ... @ rotation_matrix(SGR_THETA, "x") - ... @ rotation_matrix(SGR_PHI, "z") - ... ) - - Since we already constructed the transformation (rotation) matrix above, and - the inverse of a rotation matrix is just its transpose, the required - transformation functions are very simple: - - >>> @frame_transform_graph.transform( - ... coord.StaticMatrixTransform, coord.Galactic, Sagittarius - ... ) - ... def galactic_to_sgr(): - ... """Compute the Galactic spherical to heliocentric Sgr transformation matrix.""" - ... return SGR_MATRIX - - The decorator ``@frame_transform_graph.transform(coord.StaticMatrixTransform, coord.Galactic, Sagittarius)`` - registers this function on the - ``frame_transform_graph`` as a coordinate transformation. Inside the function, - we return the previously defined rotation matrix. - - We then register the inverse transformation by using the transpose of the - rotation matrix (which is faster to compute than the inverse): - - >>> @frame_transform_graph.transform( - ... coord.StaticMatrixTransform, Sagittarius, coord.Galactic - ... ) - ... def sgr_to_galactic(): - ... """Compute the heliocentric Sgr to spherical Galactic transformation matrix.""" - ... return SGR_MATRIX.swapaxes(-2, -1) - - Now that we have registered these transformations between ``Sagittarius`` and - `~astropy.coordinates.Galactic`, we can transform between *any* coordinate - system and ``Sagittarius`` (as long as the other system has a path to - transform to `~astropy.coordinates.Galactic`). For example, to transform from - ICRS coordinates to ``Sagittarius``, we would do: - - >>> icrs = coord.SkyCoord(280.161732 * u.degree, 11.91934 * u.degree, frame="icrs") - >>> sgr = icrs.transform_to(Sagittarius) - >>> print(sgr) - - - Or, to transform from the ``Sagittarius`` frame to ICRS coordinates (in this - case, a line along the ``Sagittarius`` x-y plane): - - >>> sgr = coord.SkyCoord( - ... Lambda=np.linspace(0, 2 * np.pi, 128) * u.radian, - ... Beta=np.zeros(128) * u.radian, - ... frame="sagittarius", - ... ) - >>> icrs = sgr.transform_to(coord.ICRS) - >>> print(icrs) # doctest: +ELLIPSIS - - - As an example, we will now plot the points in both coordinate systems: - - >>> fig, axes = plt.subplots(2, 1, figsize=(8, 10), subplot_kw={"projection": "aitoff"}) - >>> axes[0].set_title("Sagittarius") # doctest: +IGNORE_OUTPUT - >>> axes[0].plot( - ... sgr.Lambda.wrap_at(180 * u.deg).radian, - ... sgr.Beta.radian, - ... linestyle="none", - ... marker=".", - ... ) # doctest: +IGNORE_OUTPUT - >>> axes[0].grid(visible=True) # doctest: +IGNORE_OUTPUT - >>> axes[1].set_title("ICRS") # doctest: +IGNORE_OUTPUT - >>> axes[1].plot( - ... icrs.ra.wrap_at(180 * u.deg).radian, icrs.dec.radian, linestyle="none", marker="." - ... ) # doctest: +IGNORE_OUTPUT - >>> axes[1].grid(visible=True) # doctest: +IGNORE_OUTPUT - - This particular transformation is just a spherical rotation, which is a - special case of an Affine transformation with no vector offset. The - transformation of velocity components is therefore natively supported as - well: - - >>> sgr = coord.SkyCoord( - ... Lambda=np.linspace(0, 2 * np.pi, 128) * u.radian, - ... Beta=np.zeros(128) * u.radian, - ... pm_Lambda_cosBeta=np.random.uniform(-5, 5, 128) * (u.mas / u.yr), - ... pm_Beta=np.zeros(128) * (u.mas / u.yr), - ... frame="sagittarius", - ... ) - >>> icrs = sgr.transform_to(coord.ICRS) - >>> print(icrs) # doctest: +ELLIPSIS - - >>> fig, axes = plt.subplots(3, 1, figsize=(8, 10), sharex=True) - >>> axes[0].set_title("Sagittarius") # doctest: +IGNORE_OUTPUT - >>> axes[0].plot( - ... sgr.Lambda.degree, sgr.pm_Lambda_cosBeta.value, linestyle="none", marker="." - ... ) # doctest: +IGNORE_OUTPUT - >>> axes[0].set_xlabel(r"$\Lambda$ [deg]") # doctest: +IGNORE_OUTPUT - >>> axes[0].set_ylabel( - ... rf"$\mu_\Lambda \, \cos B$ [{sgr.pm_Lambda_cosBeta.unit.to_string('latex_inline')}]" - ... ) # doctest: +IGNORE_OUTPUT - >>> axes[0].grid(visible=True) # doctest: +IGNORE_OUTPUT - >>> axes[1].set_title("ICRS") # doctest: +IGNORE_OUTPUT - >>> axes[1].plot(icrs.ra.degree, icrs.pm_ra_cosdec.value, linestyle="none", marker=".") # doctest: +IGNORE_OUTPUT - >>> axes[1].set_ylabel( - ... rf"$\mu_\alpha \, \cos\delta$ [{icrs.pm_ra_cosdec.unit.to_string('latex_inline')}]" - ... ) # doctest: +IGNORE_OUTPUT - >>> axes[1].grid(visible=True) # doctest: +IGNORE_OUTPUT - >>> axes[2].set_title("ICRS") # doctest: +IGNORE_OUTPUT - >>> axes[2].plot(icrs.ra.degree, icrs.pm_dec.value, linestyle="none", marker=".") # doctest: +IGNORE_OUTPUT - >>> axes[2].set_xlabel("RA [deg]") # doctest: +IGNORE_OUTPUT - >>> axes[2].set_ylabel(rf"$\mu_\delta$ [{icrs.pm_dec.unit.to_string('latex_inline')}]") # doctest: +IGNORE_OUTPUT - >>> axes[2].grid(visible=True) # doctest: +IGNORE_OUTPUT - >>> plt.draw() - -.. - EXAMPLE END +.. _sphx_glr_generated_examples_coordinates_plot_sgr-coordinate-frame.py: + +Create a new coordinate class (for the Sagittarius stream) +========================================================== + +.. + EXAMPLE START + Create a new coordinate class (for the Sagittarius stream) + +This document describes in detail how to subclass and define a custom spherical +coordinate frame, as discussed in :ref:`astropy:astropy-coordinates-design` and +the docstring for `~astropy.coordinates.BaseCoordinateFrame`. In this example, +we will define a coordinate system defined by the plane of orbit of the +Sagittarius Dwarf Galaxy (hereafter Sgr; as defined in Majewski et al. 2003). +The Sgr coordinate system is often referred to in terms of two angular +coordinates, :math:`\Lambda,B`. + +To do this, we need to define a subclass of +`~astropy.coordinates.BaseCoordinateFrame` that knows the names and units of the +coordinate system angles in each of the supported representations. In this case +we support `~astropy.coordinates.SphericalRepresentation` with "Lambda" and +"Beta". Then we have to define the transformation from this coordinate system to +some other built-in system. Here we will use Galactic coordinates, represented +by the `~astropy.coordinates.Galactic` class. + +.. seealso:: + + The `gala package `_ + Defines a number of Astropy coordinate frames for + stellar stream coordinate systems. + + Majewski et al. 2003 + "A Two Micron All Sky Survey View of the Sagittarius + Dwarf Galaxy. I. Morphology of the Sagittarius Core and Tidal Arms", + https://arxiv.org/abs/astro-ph/0304198 + + Law & Majewski 2010 + "The Sagittarius Dwarf Galaxy: A Model for Evolution in a + Triaxial Milky Way Halo", https://arxiv.org/abs/1003.1132 + + David Law's Sgr info page + https://www.stsci.edu/~dlaw/Sgr/ + +.. plot:: + :include-source: + + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> import astropy.coordinates as coord + >>> from astropy import units as u + >>> from astropy.coordinates import frame_transform_graph, rotation_matrix + + The first step is to create a new class, which we'll call + ``Sagittarius`` and make it a subclass of + `~astropy.coordinates.BaseCoordinateFrame`: + + >>> class Sagittarius(coord.BaseCoordinateFrame): + ... """A Heliocentric spherical coordinate system defined by the orbit + ... of the Sagittarius dwarf galaxy, as described in + ... https://ui.adsabs.harvard.edu/abs/2003ApJ...599.1082M + ... and further explained in + ... https://www.stsci.edu/~dlaw/Sgr/. + ... + ... Parameters + ... ---------- + ... representation : `~astropy.coordinates.BaseRepresentation` or None + ... A representation object or None to have no data (or use the other keywords) + ... Lambda : `~astropy.coordinates.Angle`, optional, must be keyword + ... The longitude-like angle corresponding to Sagittarius' orbit. + ... Beta : `~astropy.coordinates.Angle`, optional, must be keyword + ... The latitude-like angle corresponding to Sagittarius' orbit. + ... distance : `~astropy.units.Quantity`, optional, must be keyword + ... The Distance for this object along the line-of-sight. + ... pm_Lambda_cosBeta : `~astropy.units.Quantity`, optional, must be keyword + ... The proper motion along the stream in ``Lambda`` (including the + ... ``cos(Beta)`` factor) for this object (``pm_Beta`` must also be given). + ... pm_Beta : `~astropy.units.Quantity`, optional, must be keyword + ... The proper motion in Declination for this object (``pm_ra_cosdec`` must + ... also be given). + ... radial_velocity : `~astropy.units.Quantity`, optional, keyword-only + ... The radial velocity of this object. + ... """ + ... default_representation = coord.SphericalRepresentation + ... default_differential = coord.SphericalCosLatDifferential + ... frame_specific_representation_info = { + ... coord.SphericalRepresentation: [ + ... coord.RepresentationMapping("lon", "Lambda"), + ... coord.RepresentationMapping("lat", "Beta"), + ... coord.RepresentationMapping("distance", "distance"), + ... ] + ... } + + Breaking this down line-by-line, we define the class as a subclass of + `~astropy.coordinates.BaseCoordinateFrame`. Then we include a descriptive + docstring. The final lines are class-level attributes that specify the + default representation for the data, default differential for the velocity + information, and mappings from the attribute names used by representation + objects to the names that are to be used by the ``Sagittarius`` frame. In this + case we override the names in the spherical representations but do not do + anything with other representations like cartesian or cylindrical. + + Next we have to define the transformation from this coordinate system to some + other built-in coordinate system; we will use Galactic coordinates. We can do + this by defining functions that return transformation matrices, or by simply + defining a function that accepts a coordinate and returns a new coordinate in + the new system. Because the transformation to the Sagittarius coordinate + stem is just a spherical rotation from Galactic coordinates, we will + define a function that returns this matrix. We will start by constructing the + transformation matrix using pre-determined Euler angles and the + ``rotation_matrix`` helper function: + + >>> SGR_PHI = (180 + 3.75) * u.degree # Euler angles (from Law & Majewski 2010) + >>> SGR_THETA = (90 - 13.46) * u.degree + >>> SGR_PSI = (180 + 14.111534) * u.degree + + Generate the rotation matrix using the x-convention (see Goldstein): + + >>> SGR_MATRIX = ( + ... np.diag([1.0, 1.0, -1.0]) + ... @ rotation_matrix(SGR_PSI, "z") + ... @ rotation_matrix(SGR_THETA, "x") + ... @ rotation_matrix(SGR_PHI, "z") + ... ) + + Since we already constructed the transformation (rotation) matrix above, and + the inverse of a rotation matrix is just its transpose, the required + transformation functions are very simple: + + >>> @frame_transform_graph.transform( + ... coord.StaticMatrixTransform, coord.Galactic, Sagittarius + ... ) + ... def galactic_to_sgr(): + ... """Compute the Galactic spherical to heliocentric Sgr transformation matrix.""" + ... return SGR_MATRIX + + The decorator ``@frame_transform_graph.transform(coord.StaticMatrixTransform, coord.Galactic, Sagittarius)`` + registers this function on the + ``frame_transform_graph`` as a coordinate transformation. Inside the function, + we return the previously defined rotation matrix. + + We then register the inverse transformation by using the transpose of the + rotation matrix (which is faster to compute than the inverse): + + >>> @frame_transform_graph.transform( + ... coord.StaticMatrixTransform, Sagittarius, coord.Galactic + ... ) + ... def sgr_to_galactic(): + ... """Compute the heliocentric Sgr to spherical Galactic transformation matrix.""" + ... return SGR_MATRIX.swapaxes(-2, -1) + + Now that we have registered these transformations between ``Sagittarius`` and + `~astropy.coordinates.Galactic`, we can transform between *any* coordinate + system and ``Sagittarius`` (as long as the other system has a path to + transform to `~astropy.coordinates.Galactic`). For example, to transform from + ICRS coordinates to ``Sagittarius``, we would do: + + >>> icrs = coord.SkyCoord(280.161732 * u.degree, 11.91934 * u.degree, frame="icrs") + >>> sgr = icrs.transform_to(Sagittarius) + >>> print(sgr) + + + Or, to transform from the ``Sagittarius`` frame to ICRS coordinates (in this + case, a line along the ``Sagittarius`` x-y plane): + + >>> sgr = coord.SkyCoord( + ... Lambda=np.linspace(0, 2 * np.pi, 128) * u.radian, + ... Beta=np.zeros(128) * u.radian, + ... frame="sagittarius", + ... ) + >>> icrs = sgr.transform_to(coord.ICRS) + >>> print(icrs) # doctest: +ELLIPSIS + + + As an example, we will now plot the points in both coordinate systems: + + >>> fig, axes = plt.subplots(2, 1, figsize=(8, 10), subplot_kw={"projection": "aitoff"}) + >>> axes[0].set_title("Sagittarius") # doctest: +IGNORE_OUTPUT + >>> axes[0].plot( + ... sgr.Lambda.wrap_at(180 * u.deg).radian, + ... sgr.Beta.radian, + ... linestyle="none", + ... marker=".", + ... ) # doctest: +IGNORE_OUTPUT + >>> axes[0].grid(visible=True) # doctest: +IGNORE_OUTPUT + >>> axes[1].set_title("ICRS") # doctest: +IGNORE_OUTPUT + >>> axes[1].plot( + ... icrs.ra.wrap_at(180 * u.deg).radian, icrs.dec.radian, linestyle="none", marker="." + ... ) # doctest: +IGNORE_OUTPUT + >>> axes[1].grid(visible=True) # doctest: +IGNORE_OUTPUT + + This particular transformation is just a spherical rotation, which is a + special case of an Affine transformation with no vector offset. The + transformation of velocity components is therefore natively supported as + well: + + >>> sgr = coord.SkyCoord( + ... Lambda=np.linspace(0, 2 * np.pi, 128) * u.radian, + ... Beta=np.zeros(128) * u.radian, + ... pm_Lambda_cosBeta=np.random.uniform(-5, 5, 128) * (u.mas / u.yr), + ... pm_Beta=np.zeros(128) * (u.mas / u.yr), + ... frame="sagittarius", + ... ) + >>> icrs = sgr.transform_to(coord.ICRS) + >>> print(icrs) # doctest: +ELLIPSIS + + >>> fig, axes = plt.subplots(3, 1, figsize=(8, 10), sharex=True) + >>> axes[0].set_title("Sagittarius") # doctest: +IGNORE_OUTPUT + >>> axes[0].plot( + ... sgr.Lambda.degree, sgr.pm_Lambda_cosBeta.value, linestyle="none", marker="." + ... ) # doctest: +IGNORE_OUTPUT + >>> axes[0].set_xlabel(r"$\Lambda$ [deg]") # doctest: +IGNORE_OUTPUT + >>> axes[0].set_ylabel( + ... rf"$\mu_\Lambda \, \cos B$ [{sgr.pm_Lambda_cosBeta.unit.to_string('latex_inline')}]" + ... ) # doctest: +IGNORE_OUTPUT + >>> axes[0].grid(visible=True) # doctest: +IGNORE_OUTPUT + >>> axes[1].set_title("ICRS") # doctest: +IGNORE_OUTPUT + >>> axes[1].plot(icrs.ra.degree, icrs.pm_ra_cosdec.value, linestyle="none", marker=".") # doctest: +IGNORE_OUTPUT + >>> axes[1].set_ylabel( + ... rf"$\mu_\alpha \, \cos\delta$ [{icrs.pm_ra_cosdec.unit.to_string('latex_inline')}]" + ... ) # doctest: +IGNORE_OUTPUT + >>> axes[1].grid(visible=True) # doctest: +IGNORE_OUTPUT + >>> axes[2].set_title("ICRS") # doctest: +IGNORE_OUTPUT + >>> axes[2].plot(icrs.ra.degree, icrs.pm_dec.value, linestyle="none", marker=".") # doctest: +IGNORE_OUTPUT + >>> axes[2].set_xlabel("RA [deg]") # doctest: +IGNORE_OUTPUT + >>> axes[2].set_ylabel(rf"$\mu_\delta$ [{icrs.pm_dec.unit.to_string('latex_inline')}]") # doctest: +IGNORE_OUTPUT + >>> axes[2].grid(visible=True) # doctest: +IGNORE_OUTPUT + >>> plt.draw() + +.. + EXAMPLE END diff --git a/docs/coordinates/example_gallery_rv_to_gsr.rst b/docs/coordinates/example_gallery_rv_to_gsr.rst index 1669cb874d65..35d38c5063bf 100644 --- a/docs/coordinates/example_gallery_rv_to_gsr.rst +++ b/docs/coordinates/example_gallery_rv_to_gsr.rst @@ -1,103 +1,103 @@ -.. _sphx_glr_generated_examples_coordinates_rv-to-gsr.py: - -Convert a radial velocity to the Galactic Standard of Rest (GSR) -================================================================ - -.. - EXAMPLE START - Convert a radial velocity to the Galactic Standard of Rest (GSR) - -Radial or line-of-sight velocities of sources are often reported in a -Heliocentric or Solar-system barycentric reference frame. A common -transformation incorporates the projection of the Sun's motion along the -line-of-sight to the target, hence transforming it to a Galactic rest frame -instead (sometimes referred to as the Galactic Standard of Rest, GSR). This -transformation depends on the assumptions about the orientation of the Galactic -frame relative to the bary- or Heliocentric frame. It also depends on the -assumed solar velocity vector. Here we will demonstrate how to perform this -transformation using a sky position and barycentric radial-velocity. - -Use the latest convention for the Galactocentric coordinates: - ->>> import astropy.coordinates as coord ->>> coord.galactocentric_frame_defaults.set("latest") # doctest: +IGNORE_OUTPUT - -For this example, let's work with the coordinates and barycentric radial -velocity of the star HD 155967, as obtained from -`Simbad `_: - ->>> from astropy import units as u ->>> icrs = coord.SkyCoord( -... ra=258.58356362 * u.deg, -... dec=14.55255619 * u.deg, -... radial_velocity=-16.1 * u.km / u.s, -... frame="icrs", -... ) - -Next, we need to decide on the velocity of the Sun in the assumed GSR frame. -We will use the same velocity vector as used in the -`~astropy.coordinates.Galactocentric` frame, and convert it to a -`~astropy.coordinates.CartesianRepresentation` object using the -``.to_cartesian()`` method of the -`~astropy.coordinates.CartesianDifferential` object ``galcen_v_sun``: - ->>> v_sun = coord.Galactocentric().galcen_v_sun.to_cartesian() - -We now need to get a unit vector in the assumed Galactic frame from the sky -position in the ICRS frame above. We will use this unit vector to project the -solar velocity onto the line-of-sight: - ->>> gal = icrs.transform_to(coord.Galactic) ->>> cart_data = gal.data.to_cartesian() ->>> unit_vector = cart_data / cart_data.norm() - -Now we project the solar velocity using this unit vector: - ->>> v_proj = v_sun.dot(unit_vector) - -Finally, we add the projection of the solar velocity to the radial velocity -to get a GSR radial velocity: - ->>> rv_gsr = icrs.radial_velocity + v_proj ->>> print(rv_gsr) # doctest: +FLOAT_CMP -123.30460087379765 km / s - -We could wrap this in a function so we can control the solar velocity and -reuse the above code: - ->>> def rv_to_gsr(c, v_sun=None): -... """Transform a barycentric radial velocity to the Galactic Standard of Rest -... (GSR). -... -... Parameters -... ---------- -... c : `~astropy.coordinates.BaseCoordinateFrame` subclass instance -... The radial velocity, associated with a sky coordinates, to be -... transformed. -... v_sun : `~astropy.units.Quantity`, optional -... The 3D velocity of the solar system barycenter in the GSR frame. -... Defaults to the same solar motion as in the -... `~astropy.coordinates.Galactocentric` frame. -... -... Returns -... ------- -... v_gsr : `~astropy.units.Quantity` -... The input radial velocity transformed to a GSR frame. -... """ -... if v_sun is None: -... v_sun = coord.Galactocentric().galcen_v_sun.to_cartesian() -... -... gal = c.transform_to(coord.Galactic) -... cart_data = gal.data.to_cartesian() -... unit_vector = cart_data / cart_data.norm() -... -... v_proj = v_sun.dot(unit_vector) -... -... return c.radial_velocity + v_proj - ->>> rv_gsr = rv_to_gsr(icrs) ->>> print(rv_gsr) # doctest: +FLOAT_CMP -123.30460087379765 km / s - -.. - EXAMPLE END +.. _sphx_glr_generated_examples_coordinates_rv-to-gsr.py: + +Convert a radial velocity to the Galactic Standard of Rest (GSR) +================================================================ + +.. + EXAMPLE START + Convert a radial velocity to the Galactic Standard of Rest (GSR) + +Radial or line-of-sight velocities of sources are often reported in a +Heliocentric or Solar-system barycentric reference frame. A common +transformation incorporates the projection of the Sun's motion along the +line-of-sight to the target, hence transforming it to a Galactic rest frame +instead (sometimes referred to as the Galactic Standard of Rest, GSR). This +transformation depends on the assumptions about the orientation of the Galactic +frame relative to the bary- or Heliocentric frame. It also depends on the +assumed solar velocity vector. Here we will demonstrate how to perform this +transformation using a sky position and barycentric radial-velocity. + +Use the latest convention for the Galactocentric coordinates: + +>>> import astropy.coordinates as coord +>>> coord.galactocentric_frame_defaults.set("latest") # doctest: +IGNORE_OUTPUT + +For this example, let's work with the coordinates and barycentric radial +velocity of the star HD 155967, as obtained from +`Simbad `_: + +>>> from astropy import units as u +>>> icrs = coord.SkyCoord( +... ra=258.58356362 * u.deg, +... dec=14.55255619 * u.deg, +... radial_velocity=-16.1 * u.km / u.s, +... frame="icrs", +... ) + +Next, we need to decide on the velocity of the Sun in the assumed GSR frame. +We will use the same velocity vector as used in the +`~astropy.coordinates.Galactocentric` frame, and convert it to a +`~astropy.coordinates.CartesianRepresentation` object using the +``.to_cartesian()`` method of the +`~astropy.coordinates.CartesianDifferential` object ``galcen_v_sun``: + +>>> v_sun = coord.Galactocentric().galcen_v_sun.to_cartesian() + +We now need to get a unit vector in the assumed Galactic frame from the sky +position in the ICRS frame above. We will use this unit vector to project the +solar velocity onto the line-of-sight: + +>>> gal = icrs.transform_to(coord.Galactic) +>>> cart_data = gal.data.to_cartesian() +>>> unit_vector = cart_data / cart_data.norm() + +Now we project the solar velocity using this unit vector: + +>>> v_proj = v_sun.dot(unit_vector) + +Finally, we add the projection of the solar velocity to the radial velocity +to get a GSR radial velocity: + +>>> rv_gsr = icrs.radial_velocity + v_proj +>>> print(rv_gsr) # doctest: +FLOAT_CMP +123.30460087379765 km / s + +We could wrap this in a function so we can control the solar velocity and +reuse the above code: + +>>> def rv_to_gsr(c, v_sun=None): +... """Transform a barycentric radial velocity to the Galactic Standard of Rest +... (GSR). +... +... Parameters +... ---------- +... c : `~astropy.coordinates.BaseCoordinateFrame` subclass instance +... The radial velocity, associated with a sky coordinates, to be +... transformed. +... v_sun : `~astropy.units.Quantity`, optional +... The 3D velocity of the solar system barycenter in the GSR frame. +... Defaults to the same solar motion as in the +... `~astropy.coordinates.Galactocentric` frame. +... +... Returns +... ------- +... v_gsr : `~astropy.units.Quantity` +... The input radial velocity transformed to a GSR frame. +... """ +... if v_sun is None: +... v_sun = coord.Galactocentric().galcen_v_sun.to_cartesian() +... +... gal = c.transform_to(coord.Galactic) +... cart_data = gal.data.to_cartesian() +... unit_vector = cart_data / cart_data.norm() +... +... v_proj = v_sun.dot(unit_vector) +... +... return c.radial_velocity + v_proj + +>>> rv_gsr = rv_to_gsr(icrs) +>>> print(rv_gsr) # doctest: +FLOAT_CMP +123.30460087379765 km / s + +.. + EXAMPLE END diff --git a/docs/coordinates/index.rst b/docs/coordinates/index.rst index 9d9c14be18eb..0cff700888cf 100644 --- a/docs/coordinates/index.rst +++ b/docs/coordinates/index.rst @@ -579,7 +579,7 @@ coordinate systems implemented here include: The definitive implementation of IAU-defined algorithms. The "SOFA Tools for Earth Attitude" document is particularly valuable for understanding the latest IAU standards in detail. -* `IERS Conventions (2010) `_ +* `IERS Conventions (2010) `_ An exhaustive reference covering the ITRS, the IAU2000 celestial coordinates framework, and other related details of modern coordinate conventions. * Meeus, J. "Astronomical Algorithms" diff --git a/docs/credits.rst b/docs/credits.rst index e069748c4d19..ac8b7800ac7b 100644 --- a/docs/credits.rst +++ b/docs/credits.rst @@ -33,11 +33,13 @@ Core Package Contributors * Alex Hagen * Alex Rudy * Alexander Bakanov +* Alexander Hu * Alexandre Beelen * Alexandre R\. Bomfim Junior * Alfio Puglisi * Alpha-Ursae-Minoris * Amit Kumar +* Ana Clara Galvão * Ana Posses * Anany Shrey Jain * Anchit Jain @@ -165,6 +167,7 @@ Core Package Contributors * Edward Betts * Edward Slavich * Eero Vaher +* Eesh Saxena * Eli Bressert * Elijah Bernstein-Cooper * Elise Chavez @@ -231,6 +234,7 @@ Core Package Contributors * Humna Awan * iamsoto * Igor Lemos +* Ikbar Faiz * ikkamens * Inada Naoki * J\. Berg @@ -286,6 +290,7 @@ Core Package Contributors * Joseph Schlitz * Jost Migenda * JP Maia +* Juan Escudero Pedrosa * Juan Luis Cano Rodríguez * Juanjo Bazán * Julian Harbeck @@ -384,6 +389,7 @@ Core Package Contributors * Médéric Boquien * Megan Sosey * Melissa Weber Mendonça +* Mengjia Shang * Michael Belfrage * Michael Brewer * Michael Droettboom @@ -436,6 +442,7 @@ Core Package Contributors * Nora Luetzgendorf * odidev * Ole Streicher +* Om S Habib * omahs * Orion Poplawski * orionlee @@ -494,6 +501,7 @@ Core Package Contributors * Rohit Kapoor * Rohit Patil * Roland Weber +* Romain Thomas * Roman Tolesnikov * Roy Smart * Rui Xue @@ -535,6 +543,7 @@ Core Package Contributors * Shivansh Mishra * Shreeharsh Shinde * Shresth Verma +* Shreya Vernekar * Shreyas Bapat * Sigurd Næss * Simon Alinder @@ -562,6 +571,7 @@ Core Package Contributors * Sushobhana Patra * Suyog Garg * Swapnil Sharma +* Syn Pu * T\. Carl Beery * T\. E\. Pickering * Tanuj Rastogi @@ -591,6 +601,7 @@ Core Package Contributors * Varun Kasyap Pentamaraju * Varun Nikam * Vatsala Swaroop +* veyron * Víctor Terrón * Víctor Zabalza * Victoria Dye @@ -600,6 +611,7 @@ Core Package Contributors * Vital Fernández * Volodymyr Savchenko * VSN Reddy Janga +* Wang Rui * Wilfred Tyler Gee * William Jamieson * Wolfgang Kerzendorf @@ -635,7 +647,7 @@ please send an email to the coordinators, or `open a pull request for this page `_ in the `astropy repository `_) -For how to acknowledge Astropy, please see `the Acknowledging or Citing Astropy page `_. +For how to acknowledge Astropy, please see :ref:`astropy-org-acknowledge`. .. These substitutions are duplicated here (from conf.py) so this rst file can be used in the astropy.org site without triggering warnings. .. |Astropy mailing list| replace:: `Astropy mailing list `__ diff --git a/docs/development/codeguide.rst b/docs/development/codeguide.rst index 8bfff3f1d357..ba7be89eadd8 100644 --- a/docs/development/codeguide.rst +++ b/docs/development/codeguide.rst @@ -434,5 +434,4 @@ the hierarchy. .. _Scipy: https://www.scipy.org/ .. _matplotlib: https://matplotlib.org/ .. _Cython: https://cython.org/ -.. _PyPI: https://pypi.org/project .. _ruff: https://docs.astral.sh/ruff/ diff --git a/docs/development/maintainers/releasing.rst b/docs/development/maintainers/releasing.rst index 23fdbe0dfb3f..2f484190c7b8 100644 --- a/docs/development/maintainers/releasing.rst +++ b/docs/development/maintainers/releasing.rst @@ -166,51 +166,33 @@ Updating the What's new and contributors Make sure to update the "What's new" section with the stats on the number of issues, PRs, and contributors. Since the What's New for the feature release is now only present in the release -branch, you should switch to it to, e.g.:: +branch, you should switch to it first, e.g.:: $ git checkout v6.0.x -To find the statistics and contributors, use the `generate_releaserst.xsh`_ -script. This requires `xonsh `_ and `docopt -`_ which you can install with:: - - python -m pip install xonsh docopt requests - -You should then run the script in the root of the astropy repository as follows:: - - xonsh generate_releaserst.xsh 5.3 v6.0.0.dev \ - --project-name=astropy \ - --pretty-project-name=astropy \ - --pat= - -The first argument should be the last major version (before any bug fix releases -and ignoring the .0 part of the version number, while the second argument should -be the ``.dev`` tag that was just after the branching of the last major version. -Finally, you will need a GitHub personal access token with default permissions -(no scopes selected). - -The output will look similar to:: - - This release of astropy contains 2573 commits in 163 merged pull requests - closing 104 issues from 98 people, 50 of which are first-time contributors - to astropy. - - * 2573 commits have been added since 5.3 - * 104 issues have been closed since 5.3 - * 163 pull requests have been merged since 5.3 - * 98 people have contributed since 5.3 - * 50 of which are new contributors - - The people who have contributed to the code for this release are: - - - Name 1 * - - Name 2 * - - Name 3 +The ``scripts/update-latest-whatsnew-stats.py`` script fills in the statistics +and the contributor list automatically. Run it from the root of the repository; +with no arguments it updates the latest What's New page present on the branch, +which on the release branch is the one being released:: + + $ python scripts/update-latest-whatsnew-stats.py --pat= + +You will need a `GitHub personal access token +`__ +with default permissions (no scopes selected). The script works out the previous release from the preceding What's +New page (so for ``6.0.rst`` it compares against ``v5.3.0``), reads the local git +history, and queries GitHub for the issue and pull request counts. It writes the +results straight into the page, between the ``.. release-summary-start`` / +``.. release-summary-end`` and ``.. release-contributors-start`` / +``.. release-contributors-end`` marker comments that are part of the What's New +template, so there is nothing to copy and paste and it is safe to re-run. You can +also pass an explicit page (e.g. ``docs/whatsnew/6.0.rst``) to target a different +release. At this point, you will likely need to update the Astropy ``.mailmap`` file, which maps contributor emails to names, as there are often contributors who are not careful about using the same e-mail address for every commit, meaning -that they appear multiple times in the contributor list above, sometimes with +that they appear multiple times in the contributor list, sometimes with different spelling, and sometimes you may also just see their GitHub username with no full name. @@ -223,10 +205,9 @@ Edit the ``.mailmap`` file to add entries for new email addresses for already known contributors (matched to the appropriate canonical name/email address). You can also try and investigate users with no name to see if you can determine their full name from other sources - if you do, add a new entry for them in -the ``.mailmap`` file. Once you have done this, you can re-run the -``generate_releaserst.xsh`` script (you will likely need to iterate a few times). -Once you are happy with the output, copy it into the 'What's new' page for -the current release and commit this. E.g., :: +the ``.mailmap`` file. Once you have done this, re-run the script (you will +likely need to iterate a few times). Once you are happy with the result, commit +it:: $ git add docs/whatsnew/6.0.rst $ git commit -m "Added contributor statistics and names" @@ -235,17 +216,19 @@ Push the release branch back to GitHub, e.g.:: $ git push upstream v6.0.x -Switch to a new branch that tracks the ``main`` branch and update the -``docs/credits.rst`` file to include any new contributors from the above step, -and commit this and the ``.mailmap`` changes:: +If you updated the ``.mailmap`` file above, also regenerate the list of +contributors in ``docs/credits.rst`` so it picks up the corrected names. This is +done by the ``scripts/update-credits.py`` script, which rebuilds the list from +the (mailmap-aware) git history. Switch to a new branch that tracks the ``main`` +branch, run the script, and commit both files:: $ git checkout -b v6.0.0-mailmap-credits upstream/main - $ git add .mailmap - $ git add docs/credits.rst + $ python scripts/update-credits.py + $ git add .mailmap docs/credits.rst $ git commit -m "Updated list of contributors and .mailmap file" -Open a pull request to merge this into ``main`` and mark it as requiring backporting to -the release branch. +Open a pull request to merge this into ``main`` and mark it as requiring +backporting to the release branch. .. _release-procedure-check-ci: @@ -434,9 +417,9 @@ Post-Release procedures https://github.com/astropy/astropy.github.com. #. Cherry-pick the commit rendering the changelog and deleting the fragments and - open a PR to the astropy *main* branch. Also make sure you cherry-pick the - commit updating the ``.mailmap`` and ``docs/credits.rst`` files to the *main* - branch in a separate PR. + open a PR to the astropy *main* branch. If you updated the ``.mailmap`` and + ``docs/credits.rst`` files during the release, make sure the separate PR with + those changes is also opened against the *main* branch. #. Turn off any branch protection you might have enabled in :ref:`release-procedure-restrict-branch`. @@ -661,4 +644,3 @@ it's harder for commits that need to be backported from getting lost. .. _astropy-tools repository: https://github.com/astropy/astropy-tools .. _Anaconda: https://conda.io/docs/ .. _twine: https://packaging.python.org/key_projects/#twine -.. _generate_releaserst.xsh: https://raw.githubusercontent.com/sunpy/sunpy/main/tools/generate_releaserst.xsh diff --git a/docs/development/maintainers/testhelpers.rst b/docs/development/maintainers/testhelpers.rst index 686e3e5a025b..69b6e1b8d7c2 100644 --- a/docs/development/maintainers/testhelpers.rst +++ b/docs/development/maintainers/testhelpers.rst @@ -11,10 +11,9 @@ overview of running or writing the tests. Details ======= -The dependencies used by the Astropy test suite are provided by a separate -package called |pytest-astropy|. This package provides the ``pytest`` -dependency itself, in addition to several ``pytest`` plugins that are used by -Astropy, and will also be of general use to other packages. +The dependencies used by the Astropy test suite are optional and encapsulated +with the ``test`` extra. This extra provides the ``pytest`` dependency itself, +in addition to several ``pytest`` plugins that are used by Astropy. Since the testing dependencies are not actually required to install or use Astropy, in the ``pyproject.toml`` file they are not included under the diff --git a/docs/development/quickstart.rst b/docs/development/quickstart.rst index 15ee6f2dab51..25bad1fcb388 100644 --- a/docs/development/quickstart.rst +++ b/docs/development/quickstart.rst @@ -11,7 +11,7 @@ Creating a development environment To make and test code changes and build the documentation locally you will need to create a development environment. If you run into problems at any stage do not hesitate -to `ask for help `_. +to :ref:`ask for help `. Set up GitHub and Git --------------------- @@ -191,7 +191,7 @@ Creating and submitting a pull request You can contribute bug fixes, new features, and documentation updates by submitting a GitHub pull request (PR). This section will guide you through the process. We encourage -you to `ask for help `_ if you get stuck. The Astropy +you to :ref:`ask for help ` if you get stuck. The Astropy community is welcoming and friendly and will help you! If you are new to the Astropy Project and interested to submit a large patch diff --git a/docs/development/testguide.rst b/docs/development/testguide.rst index 10162438c2f0..128a15871816 100644 --- a/docs/development/testguide.rst +++ b/docs/development/testguide.rst @@ -138,7 +138,8 @@ Test coverage reports Coverage reports can be generated using the `pytest-cov `_ plugin (which is installed -automatically when installing pytest-astropy) by using e.g.:: +automatically when installing astropy with ``test`` extra dependencies) +by using e.g.:: pytest --cov astropy --cov-report html @@ -494,7 +495,7 @@ Imagine if random testing gave you minimal, non-flaky failing examples, and a clean way to describe even the most complicated data - that's property-based testing! -``pytest-astropy`` includes a dependency on `Hypothesis +astropy's ``test`` extra includes a dependency on `Hypothesis `_, so installation is easy - you can just read the docs or `work through the tutorial `_ diff --git a/docs/index.rst b/docs/index.rst index 9f1e966ed0cc..d0f230cd9e26 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,17 @@ -.. Astropy documentation index file, created by - sphinx-quickstart on Tue Jul 26 02:59:34 2011. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. +.. Hide the left hand sidebar on the home page because it's empty + +.. raw:: html + + :tocdepth: 3 @@ -16,22 +26,22 @@ astropy: A Community Python Library for Astronomy **Useful links**: :ref:`Installation ` | `Issues & Ideas `__ | -`Get Help `__ | -`Contribute `__ | -`About `__ +:ref:`astropy-org-help` | +:ref:`astropy-org-contribute` | +:ref:`astropy-org-about` The ``astropy`` package contains key functionality and common tools needed for performing astronomy and astrophysics with Python. It is at the core of the -`Astropy Project `_, which aims to enable -the community to develop a robust ecosystem of `affiliated packages`_ +:ref:`Astropy Project `, which aims to enable +the community to develop a robust ecosystem of :ref:`astropy-org-affiliated` covering a broad range of needs for astronomical research, data processing, and data analysis. -.. Important:: If you use Astropy for work presented in a publication or talk - please help the project via proper `citation or acknowledgement - `_. This also applies to use of - software or `affiliated packages`_ that depend on the astropy - core package. +.. Important:: If you use Astropy for work presented in a publication + or talk please help the project via proper + :ref:`astropy-org-acknowledge`. This also applies to use of + software or :ref:`astropy-org-affiliated` that depend on the astropy core + package. .. toctree:: :maxdepth: 1 @@ -46,59 +56,65 @@ processing, and data analysis. :gutter: 2 .. grid-item-card:: Getting Started - :img-top: _static/index-images/getting_started.svg :link: index_getting_started :link-type: doc :text-align: center + :material-outlined:`directions_run;8em;sd-text-secondary` + New to Astropy? Check out the getting started guides. They contain an introduction to astropy's main concepts and links to additional tutorials. .. grid-item-card:: User Guide - :img-top: _static/index-images/user_guide.svg :link: index_user_docs :link-type: doc :text-align: center + :material-outlined:`menu_book;8em;sd-text-secondary` + The user guide provides in-depth information on the key concepts of astropy with useful background information and explanation. .. grid-item-card:: Learn Astropy - :img-top: _static/index-images/learn.svg :link: https://learn.astropy.org :link-type: url :text-align: center + :material-outlined:`psychology;8em;sd-text-secondary` + Learn how to use Python for astronomy through tutorials and guides that cover Astropy and other packages in the astronomy Python ecosystem. .. grid-item-card:: Astropy Packages - :img-top: _static/index-images/packages.svg - :link: https://www.astropy.org/affiliated/ - :link-type: url + :link: astropy-org-affiliated + :link-type: ref :text-align: center + :material-outlined:`inventory_2;8em;sd-text-secondary` + The Astropy Project ecosystem includes numerous `Coordinated `_ and `Affiliated `_ packages. Coordinated packages are maintained by the Project. .. grid-item-card:: Contributor's Guide - :img-top: _static/index-images/contributor.svg :link: index_dev :link-type: doc :text-align: center + :material-outlined:`person_add;8em;sd-text-secondary` + Saw a typo in the documentation? Want to improve existing functionalities? The contributing guidelines will show you how to improve astropy. .. grid-item-card:: Project Details - :img-top: _static/index-images/api.svg :link: index_project_details :link-type: doc :text-align: center + :material-outlined:`more_horiz;8em;sd-text-secondary` + What's new in the latest release, changelog, and other project details. .. image:: https://github.com/astropy/repo_stats/blob/cache/cache/astropy_user_stats_light.png?raw=true diff --git a/docs/index_dev.rst b/docs/index_dev.rst index 257f2ae94c3e..0f0a728c4f0f 100644 --- a/docs/index_dev.rst +++ b/docs/index_dev.rst @@ -11,7 +11,7 @@ guidelines. For newcomers the process may initially seem overwhelming, but with a little patience and practice you will see that it is not so complex. The key is to follow the steps -outlined here and `ask for help `_ if you get stuck. +outlined here and :ref:`ask for help ` if you get stuck. The Astropy community is welcoming and friendly and will help you! {% if is_development %} @@ -22,8 +22,7 @@ cover provide a deeper dive and a reference for both developers and maintainers. .. Important:: There are useful ways to contribute to Astropy without diving into the developer workflow which is described here. For an - an overview see the `Contribute to Astropy `_ - page. + an overview see the :ref:`astropy-org-contribute` page. Contributing quickstart diff --git a/docs/index_getting_started.rst b/docs/index_getting_started.rst index d25938d6ab72..5c1e59436bb4 100644 --- a/docs/index_getting_started.rst +++ b/docs/index_getting_started.rst @@ -12,6 +12,3 @@ Getting Started install importing_astropy Tutorials - Get Help - Contribute and Report Problems - About the Astropy Project diff --git a/docs/install.rst b/docs/install.rst index 1b83f7becad6..570def473bd3 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -213,8 +213,6 @@ installed. The following packages can optionally be used when testing: -- |pytest-astropy|: See :ref:`sourcebuildtest` - - `pytest-xdist `_: Used for distributed testing. diff --git a/docs/io/fits/usage/unfamiliar.rst b/docs/io/fits/usage/unfamiliar.rst index b20dda1dcc9c..b4da5f01ec2c 100644 --- a/docs/io/fits/usage/unfamiliar.rst +++ b/docs/io/fits/usage/unfamiliar.rst @@ -597,3 +597,106 @@ describes the possible options for constructing a :class:`CompImageHDU` object. .. EXAMPLE END + + +Supported Integer Data Types +---------------------------- + +Not every compression algorithm can be used with every integer data type. The +table below summarizes which combinations work, including the cases where +``astropy`` accepts the input only when the values lie within a more +restricted range. + +.. list-table:: + :header-rows: 1 + :stub-columns: 1 + + * - Compression + - ``int16`` + - ``int32`` + - ``int64`` + - ``uint8`` + - ``uint16`` + - ``uint32`` + - ``uint64`` + * - ``GZIP_1`` + - ✅ + - ✅ + - ⚠️ [1]_ + - ✅ + - ✅ + - ✅ + - ⚠️ [1]_ + * - ``GZIP_2`` + - ✅ + - ✅ + - ⚠️ [1]_ + - ✅ + - ✅ + - ✅ + - ⚠️ [1]_ + * - ``RICE_1`` + - ✅ + - ✅ + - 🟡 [2]_ + - ✅ + - ✅ + - ✅ + - 🟡 [2]_ + * - ``HCOMPRESS_1`` + - ✅ + - ✅ + - 🟡 [2]_ + - ✅ + - ✅ + - ✅ + - 🟡 [2]_ + * - ``PLIO_1`` + - 🟡 [3]_ + - 🟡 [3]_ + - 🟡 [2]_ [3]_ + - ✅ + - ❌ [4]_ + - ❌ [4]_ + - ❌ [4]_ + * - ``NOCOMPRESS`` + - ✅ + - ✅ + - ✅ + - ✅ + - ✅ + - ✅ + - ✅ + +Legend: + +* ✅ Full support: any value within the type's range round-trips losslessly. +* 🟡 Partial support: works only when input values satisfy the numeric + restriction in the corresponding footnote; a ``ValueError`` is raised + otherwise. +* ⚠️ Caveat: round-trips correctly within ``astropy``, but the resulting + file may not be readable by other FITS libraries (see footnote). +* ❌ Not supported: writing the data raises a ``ValueError``. + +.. [1] ``astropy`` writes a standards-compliant file, but ``cfitsio`` and + tools built on top of it (including ``funpack``, ``fitsio``, and DS9) do + not currently support reading 64-bit integer images compressed with + ``GZIP_1`` or ``GZIP_2``. The file round-trips correctly when read by + ``astropy`` itself. + +.. [2] 64-bit integer input is converted to a 32-bit type on write. The + conversion succeeds only if every input value fits in the corresponding + 32-bit range: ``[-2**31, 2**31 - 1]`` for signed and ``[0, 2**32 - 1]`` + for unsigned. Otherwise a ``ValueError`` is raised. When the conversion + succeeds an ``AstropyUserWarning`` is emitted to signal the precision + change. + +.. [3] ``PLIO_1`` is designed for pixel masks and supports only non-negative + integer values up to ``2**24 - 1`` (``16777215``). Negative values or + values above this limit cause a ``ValueError`` at write time. For + ``int64`` input both this restriction and the 32-bit conversion in + footnote [2]_ apply. + +.. [4] ``PLIO_1`` cannot store unsigned 16-, 32-, or 64-bit integers. Use + ``RICE_1``, ``HCOMPRESS_1``, ``GZIP_1``, or ``GZIP_2`` for unsigned data + that does not fit in ``uint8``. diff --git a/docs/io/misc.rst b/docs/io/misc.rst index 587aa3ea2f3a..803fd18eb18c 100644 --- a/docs/io/misc.rst +++ b/docs/io/misc.rst @@ -1,6 +1,6 @@ -************************************************************** -ECVS, HDF5, Parquet, PyArrow CSV, YAML (`astropy.io.misc`) -************************************************************** +********************************************************** +ECSV, HDF5, Parquet, PyArrow CSV, YAML (`astropy.io.misc`) +********************************************************** The `astropy.io.misc` module contains miscellaneous input/output routines that do not fit elsewhere, and are often used by other ``astropy`` sub-packages. For diff --git a/docs/io/unified_table.rst b/docs/io/unified_table.rst index 9840191c7cf1..73ba04bd4472 100644 --- a/docs/io/unified_table.rst +++ b/docs/io/unified_table.rst @@ -57,15 +57,12 @@ the file format, for instance ``'ascii.daophot'``: Reading a Table directly from the Internet It is possible to load tables directly from the Internet using URLs. For -example, download tables from Vizier catalogues in CDS format -(``'ascii.cds'``): +example, download an example Spitzer catalog (a :ref:`VOTable `): .. doctest-remote-data:: >>> from astropy.table import Table - >>> t = Table.read("ftp://cdsarc.unistra.fr/pub/cats/VII/253/snrs.dat", - ... readme="ftp://cdsarc.unistra.fr/pub/cats/VII/253/ReadMe", - ... format="ascii.cds") + >>> t = Table.read("http://www.astropy.org/astropy-data/photometry/spitzer_example_catalog.xml", format="votable") For certain file formats the format can be automatically detected, for example, from the filename extension:: diff --git a/docs/modeling/parameters.rst b/docs/modeling/parameters.rst index bb4f1c21bded..4fbf8ca95dc8 100644 --- a/docs/modeling/parameters.rst +++ b/docs/modeling/parameters.rst @@ -39,6 +39,16 @@ cases, however, array-valued parameters have no meaning specific to the model, and are simply combined with input arrays during model evaluation according to the standard `Numpy broadcasting rules`_. + +.. note:: + + The value of a `~astropy.modeling.Parameter` which has not been set and has + no default value will be returned as ``nan`` and tracked internally as a ``None`` + until the value for that ``Parameter`` has been set. For the case of a + ``Parameter`` with a custom ``getter`` / ``setter`` pair, the value will be + returned as whatever the output of the ``getter`` is when applied to a ``nan`` + value. + Parameter constraints ===================== diff --git a/docs/nitpick-exceptions b/docs/nitpick-exceptions index 2658d709051d..57391f9dae7a 100644 --- a/docs/nitpick-exceptions +++ b/docs/nitpick-exceptions @@ -12,6 +12,7 @@ py:class astropy.modeling.polynomial.PolynomialBase py:class astropy.io.fits.hdu.base.ExtensionHDU py:class astropy.io.fits.util.NotifierMixin py:class astropy.io.fits.hdu.compressed._codecs.Codec +py:class numcodecs.abc.Codec # astropy.io.misc.yaml py:class yaml.dumper.SafeDumper @@ -45,6 +46,7 @@ py:obj Bbox py:obj Transform py:obj Figure py:obj AbstractPathEffect +py:obj MultiNorm py:obj N py:obj masked @@ -59,7 +61,7 @@ py:obj n py:obj v py:obj ndarray py:obj args -py:obj numpy._typing.ArrayLike +# numpy._typing.* private namespace is handled by nitpick_ignore_regex in conf.py # other classes and functions that cannot be linked to py:class xmlrpc.client.Error diff --git a/docs/samp/index.rst b/docs/samp/index.rst index d943e98d3d8b..5e9d919f8724 100644 --- a/docs/samp/index.rst +++ b/docs/samp/index.rst @@ -6,6 +6,10 @@ SAMP (Simple Application Messaging Protocol) (`astropy.samp`) ************************************************************* +.. warning:: + ``astropy.samp`` was deprecated in version 8.0 and will be removed in a future version; + please use ``pyvo.samp`` instead. See more docs at: https://pyvo.readthedocs.io/en/stable/samp/index.html + `astropy.samp` is a Python implementation of the SAMP messaging system. Simple Application Messaging Protocol (SAMP) is an inter-process communication diff --git a/docs/table/access_table.rst b/docs/table/access_table.rst index 0b25ff7720de..af84b6c88eea 100644 --- a/docs/table/access_table.rst +++ b/docs/table/access_table.rst @@ -837,7 +837,7 @@ Structured array columns .. EXAMPLE START: Creating a formatted Astropy Table with a Structured Column -For columns which are structured arrays, the format string must be a a string +For columns which are structured arrays, the format string must be a string that uses `"new style" format strings `_ with parameter substitutions corresponding to the field names in the structured diff --git a/docs/timeseries/pandas.rst b/docs/timeseries/pandas.rst index 8dfada548d51..97871888df26 100644 --- a/docs/timeseries/pandas.rst +++ b/docs/timeseries/pandas.rst @@ -36,7 +36,7 @@ Consider a concise example starting from a :class:`~pandas.DataFrame`: >>> df = pandas.DataFrame() >>> df['a'] = [1, 2, 3] >>> times = np.array(['2015-07-04', '2015-07-05', '2015-07-06'], dtype=np.datetime64) - >>> df.set_index(pandas.DatetimeIndex(times), inplace=True) + >>> df = df.set_index(pandas.DatetimeIndex(times)) >>> df a 2015-07-04 1 diff --git a/docs/units/logarithmic_units.rst b/docs/units/logarithmic_units.rst index 963efc156f8a..12c21892eb11 100644 --- a/docs/units/logarithmic_units.rst +++ b/docs/units/logarithmic_units.rst @@ -259,8 +259,8 @@ to work:: This is implemented by having a list of supported ufuncs in ``units/function/core.py`` and by explicitly disabling some array methods in :class:`~astropy.units.FunctionQuantity`. If you believe a - function or method is incorrectly treated, please `let us know - `_. + function or method is incorrectly treated, please + :ref:`let us know `. Dimensionless Logarithmic Quantities ==================================== diff --git a/docs/utils/iers.rst b/docs/utils/iers.rst index 25bdac475074..d60f0eb57f11 100644 --- a/docs/utils/iers.rst +++ b/docs/utils/iers.rst @@ -10,7 +10,7 @@ Introduction The `~astropy.utils.iers` package provides access to the tables provided by the `International Earth Rotation and Reference Systems `_ (IERS) service, in particular the `Earth Orientation data -`_ +`_ allowing interpolation of published UT1-UTC and polar motion values for given times. The UT1-UTC values are used in :ref:`astropy-time` to provide UT1 values, and the polar motions are used in `astropy.coordinates` to determine Earth diff --git a/docs/visualization/rgb.rst b/docs/visualization/rgb.rst index 38f5efa0c442..abbcf1505219 100644 --- a/docs/visualization/rgb.rst +++ b/docs/visualization/rgb.rst @@ -86,7 +86,7 @@ of the galaxies show up. Compare with Fig. 1 of `Lupton et al. (2004)`_ or the `SDSS Skyserver image`_. .. plot:: - :context: + :context: close-figs :include-source: :align: center @@ -175,7 +175,7 @@ can be beneficial. In this case, the a stretch instance of :class:`~astropy.visualization.LogStretch` is directly passed: .. plot:: - :context: + :context: close-figs :include-source: :align: center @@ -200,7 +200,7 @@ By specifying per-filter maximum values, it is possible to emphasize certain objects, such as the very reddest sources: .. plot:: - :context: + :context: close-figs :include-source: :align: center @@ -217,7 +217,7 @@ certain objects, such as the very reddest sources: Other stretches, such as square root, can also be used: .. plot:: - :context: + :context: close-figs :include-source: :align: center diff --git a/docs/wcs/relax.rst b/docs/wcs/relax.rst index 3294de2ea986..97ca3213e4cb 100644 --- a/docs/wcs/relax.rst +++ b/docs/wcs/relax.rst @@ -6,7 +6,7 @@ FITS WCS keywords. Note that the default value of ``relax`` is `True` for reading (to accept all non standard keywords), and `False` for writing (to write out only standard keywords), in accordance with `Postel's prescription -`_: +`_: “Be liberal in what you accept, and conservative in what you send.” diff --git a/docs/whatsnew/8.0.rst b/docs/whatsnew/8.0.rst index 6a63807adcd0..ad906ba1e580 100644 --- a/docs/whatsnew/8.0.rst +++ b/docs/whatsnew/8.0.rst @@ -20,14 +20,26 @@ In particular, this release includes: * :ref:`whatsnew-8.0-vla-logical` * :ref:`whatsnew-8.0-timeseries-lra` * :ref:`whatsnew-8.0-logical-as-bytes` +* :ref:`whatsnew-8.0-table-display` +* :ref:`whatsnew-8.0-fits-strings` +* :ref:`whatsnew-8.0-visualization` +* :ref:`whatsnew-8.0-quantity-strings` +* :ref:`whatsnew-8.0-deprecations` +* :ref:`whatsnew-8.0-dependencies` In addition to these major changes, Astropy v8.0 includes a large number of smaller improvements and bug fixes, which are described in the :ref:`changelog`. By the numbers: -* X issues have been closed since v7.2 -* X pull requests have been merged since v7.2 -* X distinct people have contributed code +.. release-summary-start + +* 1014 commits have been added since v7.2 +* 179 issues have been closed since v7.2 +* 364 pull requests have been merged since v7.2 +* 70 people have contributed since v7.2 +* 37 of which are new contributors + +.. release-summary-end Full change log =============== @@ -157,6 +169,22 @@ is included as a column in the table rather than in metadata (similar to HTML and LaTeX formats). Multi-dimensional columns (such as neutrino masses) are automatically JSON-encoded when writing and decoded when reading. +Angular diameter distance between two redshifts +----------------------------------------------- + +The :meth:`~astropy.cosmology.FLRW.angular_diameter_distance` method now accepts +either one or two redshift arguments. When two redshifts are supplied, it +returns the angular diameter distance from an object at ``z1`` to an object at +``z2``, replacing the use of the now-deprecated +``angular_diameter_distance_z1z2`` method: + +.. code-block:: python + + from astropy.cosmology import Planck18 + + Planck18.angular_diameter_distance(0.5) # observer to z=0.5 + Planck18.angular_diameter_distance(0.5, 2.0) # z=0.5 to z=2.0 + .. _whatsnew-8.0-codata2022: @@ -302,3 +330,187 @@ Bytes read this way also round-trip through writing: a column read as A `~astropy.utils.exceptions.AstropyUserWarning` is now also issued whenever a logical column being read with ``logical_as_bytes=False`` (the default) contains NULL bytes, alerting users to lost information. + +.. _whatsnew-8.0-table-display: + +Table display and pandas conversion +=================================== + +Two improvements make tables with multidimensional columns easier to work with. + +A new ``conf.multidim_threshold`` configuration option controls how +multidimensional columns are displayed. Setting it to a small value (for +example, ``3``) shows full content for short arrays such as 3-vectors +(``[1 2 3]``), while larger arrays still fall back to the abbreviated +``first .. last`` representation. + +In addition, :meth:`~astropy.table.Table.to_pandas` now accepts +tables with multidimensional columns: previously this would raise a +``ValueError``, but they are now converted to 1D object arrays compatible +with pandas. + + +.. _whatsnew-8.0-fits-strings: + +FITS string column changes +========================== + +A few changes affect how string columns are read from FITS binary tables. + +When reading FITS files via :meth:`~astropy.table.Table.read`, the +``strip_spaces`` option now defaults to ``True``, so trailing whitespace in +fixed-width string columns is stripped automatically. Pass +``strip_spaces=False`` to recover the previous behavior. + +In preparation for the deprecation of ``np.char.chararray`` in NumPy 2.5, +``astropy.io.fits`` will in a future release return ordinary NumPy arrays +rather than ``chararray`` instances for string columns. Code that relies on +``chararray``-specific methods (such as ``.rstrip()`` or ``.decode()``) +should migrate to the equivalent functions in ``np.strings``. + +.. _whatsnew-8.0-visualization: + +New imshow_simple_norm convenience function +=========================================== + +A new :func:`~astropy.visualization.imshow_simple_norm` function provides a +compact one-call alternative to building a ``SimpleNorm``/``simple_norm`` and +passing it to ``imshow``, which is convenient for quick image previews: + +.. code-block:: python + + from astropy.visualization import imshow_simple_norm + + imshow_simple_norm(image, stretch="asinh", percent=99.5) + +.. _whatsnew-8.0-quantity-strings: + +Parsing vector strings into Quantity +==================================== + +It is now possible to construct a :class:`~astropy.units.Quantity` from a +1D vector-like string, with or without units: + +.. code-block:: python + + from astropy import units as u + + u.Quantity("[1, 2, 3] m") + u.Quantity("1, 2, 3", unit="m") + +.. _whatsnew-8.0-deprecations: + +Deprecation of the built-in test runner +======================================= + +The ``astropy.test()`` entry point and the +``~astropy.tests.runner.TestRunner`` / +``~astropy.tests.runner.TestRunnerBase`` classes are now formally +deprecated and will be removed in a future release. Downstream packages that +expose a ``packagename.test()`` function generated via ``TestRunner`` are +affected and should migrate to invoking ``pytest`` directly. The previously +deprecated ``astropy.tests.command`` module has been removed in this release. + +Deprecation of astropy.samp +=========================== + +The ``astropy.samp`` module has been moved downstream into the PyVO package and is available since +the PyVO v1.9 release. Therefore it is now deprecated in astropy itself. + +.. _whatsnew-8.0-dependencies: + +NumPy 2.0 is now required +========================= + +The minimum supported NumPy version is now **2.0**. Code and downstream +packages that still rely on the NumPy 1.x C API or the Python-level +behaviours that changed in NumPy 2.0 will need to be updated. + +For users that cannot yet move to NumPy 2.0, the **7.2.x** series of releases +will continue to receive support for six months following the 8.0 release. +That branch remains compatible with NumPy 1. + +Contributors to the 8.0 release +=============================== + +.. release-contributors-start + +The people who have contributed to the code for this release are: + +.. hlist:: + :columns: 4 + + - Adam Ginsburg + - Aditya Lohuni * + - Albert Y. Shih + - Ana Clara Galvão * + - Arthur Sardella * + - Ben Green * + - Bill Wolf * + - Bodhi Silberling * + - Brett Morris + - Brigitta Sipőcz + - Bruno Sanchez * + - Charalampos Stratakis * + - Clara Brasseur + - Clément Robert + - Curtis McCully + - Cyrus * + - Dhruv Yadav * + - Duncan Macleod + - Eero Vaher + - Erik Tollerud + - Evan Chen * + - Fazeel Usmani * + - Hans Moritz Günther + - Ikbar Faiz * + - J. Berg * + - Jett Higgins * + - Joren Hammudoglu * + - Julian Harbeck * + - Kacper Rutkowski * + - Kang Wang + - Kartavay Verma * + - Kyle Oman + - Larry Bradley + - M Bussonnier * + - Macdara Ó Murchú * + - Marten van Kerkwijk + - Maximilian Linhoff + - Mohsin Mehmood + - Nabil Freij + - Nadia Dencheva + - Naksh Yadav * + - Nathaniel Starkman + - Om S Habib * + - P. L. Lim + - Pierre Sassoulas * + - Preshanth Jagannathan + - R. Virinchi * + - Raphael Erik Hviding + - Reem Hamraz * + - RinZ27 * + - Simon Conseil + - Stelios Voutsinas + - Stuart Mumford + - Surya K. * + - Syn Pu * + - Thomas Dutkiewicz * + - Thomas Robitaille + - Tiago Gomes + - Tim Jenness + - Tom Aldcroft + - Varun Kasyap Pentamaraju * + - Varun Nikam + - Vishwas * + - Wang Rui * + - William Jamieson + - Yaocheng Chen * + - gilles.landais * + - github-actions + - xbreak * + - Περικλής Παντελαίος * + +Where a * indicates that this release contains their first contribution to astropy. + +.. release-contributors-end diff --git a/pyproject.toml b/pyproject.toml index e62b59652336..244d59de39f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ keywords = [ "ascii", ] dependencies = [ - "astropy-iers-data>=0.2026.4.27.1.3.2", + "astropy-iers-data>=0.2026.8.31.0.57.9", "numpy>=2.0", "packaging>=25.0", "pyerfa>=2.0.1.3", # for >=2.0.1.7, adjust structured_units.rst and doctest-requires @@ -103,9 +103,13 @@ test = [ "coverage>=7.2.0", "hypothesis>=6.84.0", "pytest>=8.0.1", + "pytest-astropy-header>=0.2.2", + "pytest-cov>=2.3.1", "pytest-doctestplus>=1.4.0", - "pytest-astropy-header>=0.2.1", - "pytest-astropy>=0.11.0", + "pytest-filter-subpackage>=0.2.0", + "pytest-remotedata>=0.4.1", + "pytest-run-parallel>=0.10.0", + "pytest-skip-slow==1.1.0", "pytest-xdist>=3.6.0", "threadpoolctl>=3.0.0", ] @@ -136,12 +140,12 @@ typing = [ docs = [ "astropy[recommended]", # installs the [recommended] dependencies "sphinx>=8.2.0,<9", # keep in sync with docs/conf.py - "sphinx-astropy[confv2]>=1.9.1", + "sphinx-astropy[confv3]>=1.11", "pytest>=8.0.0", "sphinx-changelog>=1.2.0", "sphinx_design>=0.6.1", "Jinja2>=3.1.3", - "sphinxcontrib-globalsubs >= 0.1.1", + "sphinxcontrib-globalsubs>=0.1.1", "matplotlib!=3.9.0", # https://github.com/matplotlib/matplotlib/issues/28234 "dask[dataframe]>=2024.8.0", # keep in sync with dependency-groups.dataframe "fsspec[http,s3]>=2023.4.0", # keep in sync with dependency-groups.dataframe @@ -224,8 +228,6 @@ namespaces = true "astropy.tests.figures" = ["*.json"] "astropy.wcs" = ["include/*/*.h"] "astropy.wcs.tests" = ["extension/*.c"] -# Delete with deprecated astropy.utils.misc.walk_skip_hidden() -"astropy.utils.tests" = ["data/.hidden_file.txt"] [tool.setuptools_scm] version_file = "astropy/_version.py" @@ -262,6 +264,10 @@ filterwarnings = [ "ignore:'_UnionGenericAlias' is deprecated and slated for removal in Python 3.17:DeprecationWarning", # not PYTHON_LT_3_14 # Weird warning from the vectorized do_format in Angle.to_string. low numpy/python? "ignore:invalid value encountered in do_format:RuntimeWarning", + # Remove this when we delete samp subpackage + "ignore:astropy\\.samp was deprecated:astropy.utils.exceptions.AstropyDeprecationWarning", + # https://github.com/astropy/astropy/issues/20164 + "ignore:.*matrix.*is being replaced:DeprecationWarning", # https://github.com/astropy/astropy/issues/19511 "ignore:Implicitly cleaning up .rst`` file with the release summary. + +Usage: + python scripts/update-latest-whatsnew-stats.py --pat=$GITHUB_PAT + python scripts/update-latest-whatsnew-stats.py docs/whatsnew/8.0.rst --pat=$GITHUB_PAT + +With no path, the latest ``docs/whatsnew/..rst`` page present in +the checkout is used, so the script also works as-is on a release branch (where +that page is the one being released). A path may be passed explicitly to target +a different page. + +The target version is taken from the file name, and the previous release is +taken from the whatsnew page immediately before it in the same directory (so +for ``8.0.rst`` the previous page is ``7.2.rst`` and the comparison tag is +``v7.2.0``). + +The script reads the local git history, queries GitHub for the merged-PR and +closed-issue counts in the corresponding date window, and splices the stats +bullets + contributor list into the target file between sentinel comments: + + .. release-summary-start + + (auto-generated stats land here) + + .. release-summary-end + + ... + + .. release-contributors-start + + (auto-generated contributor list lands here) + + .. release-contributors-end + +The marker lines start with `..` so they render as rst comments and stay +invisible in the built HTML. Idempotent across re-runs. + +A GitHub token is read from ``--pat`` or the ``GH_TOKEN`` / ``GITHUB_TOKEN`` +environment variable. +""" + +import argparse +import datetime as dt +import json +import os +import re +import subprocess +import sys +import textwrap +import urllib.request +from collections.abc import Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, TypeVar + +REPO = "astropy/astropy" +WHATSNEW_DIR = Path("docs", "whatsnew") +GH_GRAPHQL = "https://api.github.com/graphql" +VERSION_RE = re.compile(r"^\d+\.\d+$") +RELEASE_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$") + +T = TypeVar("T") + + +@dataclass(slots=True, frozen=True) +class Error: + message: str + + +@dataclass(slots=True, frozen=True, order=True) +class WhatsNewPage: + version: tuple[int, ...] + path: Path = field(compare=False) + + +def git(*args: str) -> str: + return subprocess.check_output(("git", *args), text=True).strip() + + +def commit_date(ref: str) -> dt.datetime: + """Committer date of ``ref`` as a datetime, peeling annotated tags.""" + return dt.datetime.fromisoformat( + git("show", "-s", "--format=%cI", f"{ref}^{{commit}}") + ) + + +def latest_release_tag() -> str | Error: + """The highest final release tag (vX.Y.Z, no pre-release suffix) in the repo.""" + for tag in git("tag", "--list", "v*", "--sort=-version:refname").splitlines(): + if RELEASE_TAG_RE.match(tag): + return tag + return Error("no release tag (vX.Y.Z) found") + + +def shortlog(revspec: str) -> list[tuple[str, str]]: + """Return [(count_str, name), ...] from `git shortlog`, bots filtered out.""" + rows = [] + for line in git("shortlog", "-s", "--no-merges", revspec).splitlines(): + count, _, name = line.strip().partition("\t") + if "[bot]" not in name: + rows.append((count.strip(), name)) + return rows + + +def whatsnew_pages(dirpath: Path) -> list[WhatsNewPage]: + """The ..rst pages in ``dirpath``, sorted ascending by version.""" + pages = [ + WhatsNewPage(tuple(int(n) for n in p.stem.split(".")), p) + for p in dirpath.glob("*.rst") + if VERSION_RE.match(p.stem) + ] + return sorted(pages) + + +def latest_page(dirpath: Path) -> Path | Error: + """The highest-version whatsnew page in ``dirpath``.""" + pages = whatsnew_pages(dirpath) + if not pages: + return Error(f"no . whatsnew pages found in {dirpath}") + return pages[-1].path + + +def previous_version(path: Path) -> str | Error: + """The whatsnew version immediately before ``path`` in the same directory.""" + versions = [page.version for page in whatsnew_pages(path.parent)] + target = tuple(int(n) for n in path.stem.split(".")) + if target not in versions: + return Error(f"{path.name} is not a recognised . whatsnew page") + idx = versions.index(target) + if idx == 0: + return Error(f"{path.name} has no preceding whatsnew page to compare against") + return ".".join(str(n) for n in versions[idx - 1]) + + +def post_graphql(query: str, token: str) -> dict[str, Any] | Error: + req = urllib.request.Request( + GH_GRAPHQL, + data=json.dumps({"query": query}).encode(), + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen(req, timeout=30) as r: + body = json.load(r) + if "errors" in body: + return Error(f"GitHub GraphQL error: {body['errors']}") + data: dict[str, Any] = body["data"] + return data + + +@dataclass(kw_only=True, slots=True, frozen=True) +class Counts: + issues: int + prs: int + + +def gh_counts(since: dt.datetime, upto: dt.datetime, token: str) -> Counts | Error: + """Returns (merged_prs, closed_issues) in [since, upto] via one GraphQL request.""" + span = f"{since.date()}..{upto.date()}" + data = post_graphql( + f""" + {{ + pulls: search(query: "repo:{REPO} is:pr is:merged base:main merged:{span}", type: ISSUE, first: 1) {{ issueCount }} + issues: search(query: "repo:{REPO} is:issue is:closed closed:{span}", type: ISSUE, first: 1) {{ issueCount }} + }} + """, + token, + ) + if isinstance(data, Error): + return data + return Counts( + issues=int(data["pulls"]["issueCount"]), + prs=int(data["issues"]["issueCount"]), + ) + + +def splice(text: str, marker: str, payload: str) -> str | Error: + pattern = re.compile( + rf"(\.\. {re.escape(marker)}-start[^\n]*\n).*?(^\.\. {re.escape(marker)}-end)", + re.DOTALL | re.MULTILINE, + ) + if not pattern.search(text): + return Error( + f"Could not find marker pair '.. {marker}-start' / '.. {marker}-end' in file" + ) + return pattern.sub(lambda m: f"{m.group(1)}\n{payload}\n\n{m.group(2)}", text) + + +def main(argv: Sequence[str] | None = None) -> int: + p = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + p.add_argument( + "path", + nargs="?", + type=Path, + help="Path to the whatsnew page to update " + f"(default: the latest page in {WHATSNEW_DIR}).", + ) + p.add_argument( + "--pat", help="GitHub personal access token (or set GH_TOKEN / GITHUB_TOKEN)." + ) + p.add_argument( + "--check", + action="store_true", + help="Smoke test: count from the latest existing release tag instead of the " + "page's previous release tag (which may be unreleased on main). Used by CI, " + "which shows the resulting diff but never commits it.", + ) + args = p.parse_args(argv) + + if args.path is None: + if isinstance(path := latest_page(WHATSNEW_DIR), Error): + print(path.message, file=sys.stderr) + return 1 + else: + path = args.path + if not path.exists(): + sys.exit(f"{path} not found") + + token = args.pat or os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if not token: + p.error("a GitHub token is required (--pat or GH_TOKEN / GITHUB_TOKEN env var)") + + if isinstance(prev_version := previous_version(path), Error): + print(prev_version.message, file=sys.stderr) + return 1 + + if args.check: + if isinstance(prev_tag := latest_release_tag(), Error): + print(prev_tag.message, file=sys.stderr) + return 1 + short = prev_tag + else: + prev_tag = f"v{prev_version}.0" + short = f"v{prev_version}" + + current = shortlog(f"{prev_tag}..HEAD") + previous_names = {n for _, n in shortlog(prev_tag)} + current_names = {n for _, n in current} + new = current_names - previous_names + + ncommits = int(git("rev-list", "--count", f"{prev_tag}..HEAD")) + since = commit_date(prev_tag) + upto = commit_date("HEAD") + if isinstance(counts := gh_counts(since, upto, token), Error): + print(counts.message, file=sys.stderr) + return 1 + + bullets = "\n".join(f" - {n}" + (" *" if n in new else "") for _, n in current) + + stats = textwrap.dedent(f"""\ + * {ncommits} commits have been added since {short} + * {counts.issues} issues have been closed since {short} + * {counts.prs} pull requests have been merged since {short} + * {len(current_names)} people have contributed since {short} + * {len(new)} of which are new contributors""") + + # dedent the template before interpolating the (multi-line) bullets, so their + # own indentation is preserved rather than being clipped by dedent. + contributors = textwrap.dedent("""\ + The people who have contributed to the code for this release are: + + .. hlist:: + :columns: 4 + + {bullets} + + Where a * indicates that this release contains their first contribution to astropy.""").format( + bullets=bullets + ) + + text = path.read_text() + for marker, payload in ( + ("release-summary", stats), + ("release-contributors", contributors), + ): + if isinstance(res := splice(text, marker, payload), Error): + print(res.message, file=sys.stderr) + return 1 + text = res + path.write_text(text) + print(f"Updated {path} (since {prev_tag})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tox.ini b/tox.ini index 01d85cf9c37b..7aa57b32a03c 100644 --- a/tox.ini +++ b/tox.ini @@ -19,6 +19,7 @@ passenv = HOME,WINDIR,LC_ALL,LC_CTYPE,CC,CI,IS_CRON,ARCH_ON_CI,PY_COLORS,PYTHON_ setenv = NUMPY_WARN_IF_NO_MEM_POLICY = 1 COVERAGE_CORE = sysmon; python_version >= '3.12' + cov: COVERAGE_FILE = {toxinidir}/.coverage # For coverage, we need to pass extra options to the C compiler cov: CFLAGS = --coverage -fno-inline-functions -O0 py313t: PYTHON_GIL=0 @@ -29,6 +30,7 @@ setenv = devdeps: UV_INDEX = https://pypi.anaconda.org/liberfa/simple https://pypi.anaconda.org/scientific-python-nightly-wheels/simple mpldev: UV_INDEX = https://pypi.anaconda.org/scientific-python-nightly-wheels/simple devdeps, mpldev, predeps, devpytest: UV_INDEX_STRATEGY = unsafe-best-match # match pip's behavior + devdeps, mpldev, predeps, devpytest: UV_NO_CACHE = 1 # don't get stale wheels fitsio: ASTROPY_ALWAYS_TEST_FITSIO = true # on oldestdeps, we let warnings be warnings (-Wdefault) instead of treated-as-errors, # so we don't need to deal with filtering anything triggered from dependencies @@ -96,7 +98,6 @@ deps = devpytest: git+https://github.com/astropy/pytest-arraydiff.git devpytest: git+https://github.com/astropy/pytest-filter-subpackage.git devpytest: git+https://github.com/matplotlib/pytest-mpl.git - devpytest: git+https://github.com/astropy/pytest-astropy.git # Duplicates test_all in pyproject.toml due to upstream bug # https://github.com/tox-dev/tox/issues/3433 @@ -220,8 +221,8 @@ commands = --exclude-module tkinter \ --collect-submodules=py \ --hidden-import pytest \ - --hidden-import pytest_astropy.plugin \ - --hidden-import pytest_remotedata.plugin \ --hidden-import pytest_doctestplus.plugin \ + --hidden-import pytest_remotedata.plugin \ + --hidden-import pytest_skip_slow \ --hidden-import pytest_mpl.plugin ./run_astropy_tests --astropy-root "{toxinidir}"