From 2dacf1dec3e2293366ef655ecf18a5343e8a5d28 Mon Sep 17 00:00:00 2001 From: Ole Streicher Date: Thu, 13 Nov 2025 09:15:43 +0100 Subject: [PATCH] Import python-fitsio_1.3.0+ds.orig.tar.xz [dgit import orig python-fitsio_1.3.0+ds.orig.tar.xz] --- .git_archival.txt | 4 + .gitattributes | 3 + .github/dependabot.yml | 10 + .github/workflows/lint.yml | 42 + .github/workflows/tests-external-cfitsio.yml | 171 + .github/workflows/tests-pypi.yml | 60 + .github/workflows/tests.yml | 134 + .github/workflows/wheel.yml | 237 + .gitignore | 126 + .pre-commit-config.yaml | 31 + CHANGES.md | 889 +++ LICENSE.txt | 340 ++ MANIFEST.in | 6 + README.md | 488 ++ fitsio/__init__.py | 46 + fitsio/fits_exceptions.py | 11 + fitsio/fitsio_pywrap.c | 5384 +++++++++++++++++ fitsio/fitslib.py | 2119 +++++++ fitsio/hdu/__init__.py | 14 + fitsio/hdu/base.py | 442 ++ fitsio/hdu/image.py | 550 ++ fitsio/hdu/table.py | 2845 +++++++++ fitsio/header.py | 781 +++ .../test_gzip_compressed_image.fits.fz | Bin 0 -> 8640 bytes fitsio/tests/__init__.py | 0 fitsio/tests/checks.py | 213 + fitsio/tests/makedata.py | 439 ++ fitsio/tests/test_empty_slice.py | 20 + fitsio/tests/test_header.py | 576 ++ fitsio/tests/test_header_junk.py | 66 + fitsio/tests/test_image.py | 804 +++ fitsio/tests/test_image_compression.py | 670 ++ .../tests/test_image_compression_defaults.py | 287 + fitsio/tests/test_lib.py | 112 + fitsio/tests/test_table.py | 1658 +++++ fitsio/tests/test_util.py | 78 + fitsio/tests/test_warnings.py | 42 + fitsio/util.py | 221 + patches/Makefile.am.patch | 14 + patches/Makefile.in.patch | 267 + patches/README.md | 26 + patches/build_cfitsio_patches.py | 35 + patches/configure.ac.patch | 21 + patches/configure.patch | 42 + patches/fitsio2.h.patch | 21 + patches/getcold.c.patch | 38 + patches/getcole.c.patch | 40 + patches/imcompress.c.patch | 213 + ruff.toml | 17 + setup.py | 485 ++ zlib.tar.gz | Bin 0 -> 114845 bytes 51 files changed, 21138 insertions(+) create mode 100644 .git_archival.txt create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/tests-external-cfitsio.yml create mode 100644 .github/workflows/tests-pypi.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .github/workflows/wheel.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 CHANGES.md create mode 100644 LICENSE.txt create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 fitsio/__init__.py create mode 100644 fitsio/fits_exceptions.py create mode 100644 fitsio/fitsio_pywrap.c create mode 100644 fitsio/fitslib.py create mode 100644 fitsio/hdu/__init__.py create mode 100644 fitsio/hdu/base.py create mode 100644 fitsio/hdu/image.py create mode 100644 fitsio/hdu/table.py create mode 100644 fitsio/header.py create mode 100644 fitsio/test_images/test_gzip_compressed_image.fits.fz create mode 100644 fitsio/tests/__init__.py create mode 100644 fitsio/tests/checks.py create mode 100644 fitsio/tests/makedata.py create mode 100644 fitsio/tests/test_empty_slice.py create mode 100644 fitsio/tests/test_header.py create mode 100644 fitsio/tests/test_header_junk.py create mode 100644 fitsio/tests/test_image.py create mode 100644 fitsio/tests/test_image_compression.py create mode 100644 fitsio/tests/test_image_compression_defaults.py create mode 100644 fitsio/tests/test_lib.py create mode 100644 fitsio/tests/test_table.py create mode 100644 fitsio/tests/test_util.py create mode 100644 fitsio/tests/test_warnings.py create mode 100644 fitsio/util.py create mode 100644 patches/Makefile.am.patch create mode 100644 patches/Makefile.in.patch create mode 100644 patches/README.md create mode 100644 patches/build_cfitsio_patches.py create mode 100644 patches/configure.ac.patch create mode 100644 patches/configure.patch create mode 100644 patches/fitsio2.h.patch create mode 100644 patches/getcold.c.patch create mode 100644 patches/getcole.c.patch create mode 100644 patches/imcompress.c.patch create mode 100644 ruff.toml create mode 100644 setup.py create mode 100644 zlib.tar.gz diff --git a/.git_archival.txt b/.git_archival.txt new file mode 100644 index 0000000..a795c89 --- /dev/null +++ b/.git_archival.txt @@ -0,0 +1,4 @@ +node: 8035ed4af26c65303bde38f7d7ba1bf3ff62b9ca +node-date: 2025-11-11T11:34:39-06:00 +describe-name: 1.3.0 +ref-names: HEAD -> master, tag: 1.3.0 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..defee2f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto + +.git_archival.txt export-subst diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5f454fd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + groups: + github-actions: + patterns: + - '*' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..22c4521 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,42 @@ +name: lint + +on: + push: + branches: + - master + pull_request: null + +env: + PY_COLORS: "1" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: conda-incubator/setup-miniconda@835234971496cad1653abb28a638a281cf32541f # v3.2.0 + with: + channels: conda-forge + channel-priority: strict + show-channel-urls: true + miniforge-version: latest + + - name: install conda deps + shell: bash -l {0} + run: | + conda list + conda install pre-commit "identify>2.6" + + - name: pre-commit + shell: bash -l {0} + run: | + pre-commit run -a diff --git a/.github/workflows/tests-external-cfitsio.yml b/.github/workflows/tests-external-cfitsio.yml new file mode 100644 index 0000000..45e4f44 --- /dev/null +++ b/.github/workflows/tests-external-cfitsio.yml @@ -0,0 +1,171 @@ +name: tests-external-cfitsio + +on: + push: + branches: + - master + pull_request: null + +env: + PY_COLORS: "1" + # These compiler flags force the tests to fail if arrays are + # accessed at the C level from an unaligned location. + TEST_CFLAGS: "-fsanitize=alignment -fno-sanitize-recover=alignment" + LATEST_CFITSIO_VER: "-4.6.3" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests-external-cfitsio: + name: tests-external-cfitsio + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest] + config: + # 3.44 is the last version that did not support uint64 + - { pyver: "3.8", npver: "1", cftsver: "3440"} + # python 3 string writing fails on all 3.* builds + # so test first 4.* release + - { pyver: "3.8", npver: "1", cftsver: "-4.0.0"} + # 4.1.0 is the first version for which tests pass for uint64 + - { pyver: "3.8", npver: "1", cftsver: "-4.1.0"} + - { pyver: "3.8", npver: "1", cftsver: "latest"} + - { pyver: "3.13", npver: "2.3.0", cftsver: "latest"} + + runs-on: ${{ matrix.os }} + env: + PIP_OPTIONS: "--no-cache-dir --no-deps --no-build-isolation -v" + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: conda-incubator/setup-miniconda@835234971496cad1653abb28a638a281cf32541f # v3.2.0 + with: + python-version: ${{ matrix.config.pyver }} + channels: conda-forge + channel-priority: strict + show-channel-urls: true + miniforge-version: latest + + - name: install conda deps + shell: bash -l {0} + run: | + conda install \ + numpy=${{ matrix.config.npver }} \ + "setuptools-scm>=8" \ + wget \ + make \ + pytest \ + setuptools + conda list + + - name: build external cfitsio + shell: bash -l {0} + run: | + export CFLAGS="${CFLAGS} ${TEST_CFLAGS}" + + mkdir -p cfitsio-external-build + cd cfitsio-external-build + rm -rf * + + if [[ "${{ matrix.config.cftsver }}" == "latest" ]]; then + cftsver=${LATEST_CFITSIO_VER} + else + cftsver="${{ matrix.config.cftsver }}" + fi + + if [[ "${{ matrix.config.cftsver }}" == *3* ]]; then + config_flags="" + else + config_flags="--without-fortran --disable-shared" + fi + + cfitsio_name=cfitsio${cftsver} + wget https://heasarc.gsfc.nasa.gov/FTP/software/fitsio/c/${cfitsio_name}.tar.gz + cfitsio_dir=`tar -tzf ${cfitsio_name}.tar.gz | sed -n "1,1p" | cut -f1 -d"/"` + tar -xzvf ${cfitsio_name}.tar.gz + cd ${cfitsio_dir} + CFLAGS="-fPIC" ./configure --prefix=$HOME/cfitsio-static-install ${config_flags} + make install -j 4 + cd .. + cd .. + + - name: test non-bundled build + shell: bash -l {0} + run: | + export CFLAGS="${CFLAGS} ${TEST_CFLAGS}" + + pip install ${PIP_OPTIONS} -e . \ + --config-settings="--global-option=--use-system-fitsio" \ + --config-settings="--global-option=--system-fitsio-includedir=$HOME/cfitsio-static-install/include" \ + --config-settings="--global-option=--system-fitsio-libdir=$HOME/cfitsio-static-install/lib" + pytest -vv fitsio + python -c "import fitsio; assert not fitsio.cfitsio_has_bzip2_support()" + if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then + python -c "import fitsio; assert not fitsio.cfitsio_has_curl_support()" + else + python -c "import fitsio; assert fitsio.cfitsio_has_curl_support()" + fi + + - name: install bzip2 on linux + shell: bash -l {0} + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get install libbz2-dev + + - name: build external cfitsio w/ bzip2 + shell: bash -l {0} + run: | + export CFLAGS="${CFLAGS} ${TEST_CFLAGS}" + + mkdir -p cfitsio-external-build + cd cfitsio-external-build + rm -rf * + + if [[ "${{ matrix.config.cftsver }}" == "latest" ]]; then + cftsver=${LATEST_CFITSIO_VER} + else + cftsver="${{ matrix.config.cftsver }}" + fi + + if [[ "${{ matrix.config.cftsver }}" == *3440* ]]; then + config_flags="--with-bzip2" + else + config_flags="--without-fortran --disable-shared --with-bzip2" + fi + + cfitsio_name=cfitsio${cftsver} + wget https://heasarc.gsfc.nasa.gov/FTP/software/fitsio/c/${cfitsio_name}.tar.gz + cfitsio_dir=`tar -tzf ${cfitsio_name}.tar.gz | sed -n "1,1p" | cut -f1 -d"/"` + tar -xzvf ${cfitsio_name}.tar.gz + cd ${cfitsio_dir} + CFLAGS="-fPIC" ./configure --prefix=$HOME/cfitsio-static-install ${config_flags} + make install -j 4 + cd .. + cd .. + + - name: test non-bundled build w/ env vars w/ bzip2 + shell: bash -l {0} + run: | + export CFLAGS="${CFLAGS} ${TEST_CFLAGS}" + + pip cache purge + rm -rf build* + find . -name "*.so" -type f -delete + export FITSIO_USE_SYSTEM_FITSIO=1 + export FITSIO_SYSTEM_FITSIO_INCLUDEDIR=$HOME/cfitsio-static-install/include + export FITSIO_SYSTEM_FITSIO_LIBDIR=$HOME/cfitsio-static-install/lib + pip install ${PIP_OPTIONS} -e . + pytest -vv fitsio + python -c "import fitsio; assert fitsio.cfitsio_has_bzip2_support()" + if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then + python -c "import fitsio; assert not fitsio.cfitsio_has_curl_support()" + else + python -c "import fitsio; assert fitsio.cfitsio_has_curl_support()" + fi diff --git a/.github/workflows/tests-pypi.yml b/.github/workflows/tests-pypi.yml new file mode 100644 index 0000000..27e56e0 --- /dev/null +++ b/.github/workflows/tests-pypi.yml @@ -0,0 +1,60 @@ +name: tests-pypi + +on: + push: + branches: + - master + pull_request: null + +env: + PY_COLORS: "1" + # These compiler flags force the tests to fail if arrays are + # accessed at the C level from an unaligned location. + TEST_CFLAGS: "-fsanitize=alignment -fno-sanitize-recover=alignment" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests-pypi: + name: tests-pypi + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-22.04] + pyver: ["3.8", "3.13"] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-python@v6 + with: + python-version: '${{ matrix.pyver }}' + + - name: install pip & setuptools + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade setuptools + python -m pip install pytest + + - name: install bzip2 and other tools on linux + if: contains(matrix.os, 'ubuntu') + run: | + sudo apt-get install libbz2-dev wget make curl libcurl4-openssl-dev + + - name: build fitsio + run: | + export CFLAGS="${CFLAGS} ${TEST_CFLAGS}" + + pip install -v -e . + + - name: test fitsio + run: | + pytest -vv fitsio + python -c "import fitsio; assert fitsio.cfitsio_has_bzip2_support()" + python -c "import fitsio; assert fitsio.cfitsio_has_curl_support()" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..122639e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,134 @@ +name: tests + +on: + push: + branches: + - master + pull_request: null + +env: + PY_COLORS: "1" + # These compiler flags force the tests to fail if arrays are + # accessed at the C level from an unaligned location. + TEST_CFLAGS: "-fsanitize=alignment -fno-sanitize-recover=alignment" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + name: tests + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest] + config: + - { pyver: "3.8", npver: "1"} + - { pyver: "3.11", npver: "1.26"} + - { pyver: "3.12", npver: "1.26"} + - { pyver: "3.11", npver: "2.3.0"} + - { pyver: "3.12", npver: "2.3.0"} + - { pyver: "3.13", npver: "2.3.0"} + + runs-on: ${{ matrix.os }} + env: + PIP_OPTIONS: "--no-cache-dir --no-deps --no-build-isolation -v" + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: conda-incubator/setup-miniconda@835234971496cad1653abb28a638a281cf32541f # v3.2.0 + with: + python-version: ${{ matrix.config.pyver }} + channels: conda-forge + channel-priority: strict + show-channel-urls: true + miniforge-version: latest + + - name: install conda deps + shell: bash -l {0} + run: | + conda install \ + numpy=${{ matrix.config.npver }} \ + "setuptools-scm>=8" \ + wget \ + make \ + pytest \ + setuptools + conda list + + - name: test bundled build + shell: bash -l {0} + run: | + export CFLAGS="${CFLAGS} ${TEST_CFLAGS}" + + pip cache purge + rm -rf build* + rm -rf $HOME/cfitsio-static-install + find . -name "*.so" -type f -delete + pip install ${PIP_OPTIONS} -e . + if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then + pytest -vv fitsio + python -c "import fitsio; assert not fitsio.cfitsio_has_bzip2_support()" + python -c "import fitsio; assert not fitsio.cfitsio_has_curl_support()" + else + pytest -vv fitsio + python -c "import fitsio; assert fitsio.cfitsio_has_bzip2_support()" + python -c "import fitsio; assert fitsio.cfitsio_has_curl_support()" + fi + + - name: install bzip2 and curl on linux + shell: bash -l {0} + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get install libbz2-dev curl libcurl4-openssl-dev + + - name: test install sdist .gz with no unit tests + shell: bash -l {0} + run: | + export CFLAGS="${CFLAGS} ${TEST_CFLAGS}" + + pip cache purge + rm -rf build* + rm -rf $HOME/cfitsio-static-install + find . -name "*.so" -type f -delete + rm -rf dist + + python setup.py sdist + + pip install ${PIP_OPTIONS} dist/*.tar.gz + cd .. + python -c "import fitsio; assert not fitsio.__version__.startswith('0')" + cd - + pip uninstall fitsio --yes + + - name: test sdist + shell: bash -l {0} + run: | + export CFLAGS="${CFLAGS} ${TEST_CFLAGS}" + + pip cache purge + rm -rf build* + rm -rf $HOME/cfitsio-static-install + find . -name "*.so" -type f -delete + rm -rf dist + + python setup.py sdist + pushd dist/ + + fname=$(ls fitsio*.gz) + tar xvfz "$fname" + dname=$(echo "$fname" | sed 's/\.tar\.gz//') + pushd $dname + + pip install ${PIP_OPTIONS} -e . + pytest -vv fitsio + python -c "import fitsio; assert fitsio.cfitsio_has_bzip2_support()" + python -c "import fitsio; assert fitsio.cfitsio_has_curl_support()" + + popd + popd diff --git a/.github/workflows/wheel.yml b/.github/workflows/wheel.yml new file mode 100644 index 0000000..905db02 --- /dev/null +++ b/.github/workflows/wheel.yml @@ -0,0 +1,237 @@ +# this is based on the wheel workflow in GalSim, but adapted to our needs +name: build wheels and sdist + +on: + workflow_dispatch: + inputs: + ref: + description: 'The git ref to build wheels for. This will trigger a pypi upload.' + default: '' + required: false + type: string + cibw_skip: + description: 'Python versions to skip when building wheels.' + default: 'cp36* cp37* pp* cp38*' + required: false + type: string + pull_request: null + release: + types: + - published + +concurrency: + group: pypi + cancel-in-progress: false + +env: + PYVER: '3.11' + CIBW_SKIP_VAL: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.cibw_skip || 'cp36* cp37* pp* cp38*' }} + +jobs: + linux-manylinux: + name: linux-manylinux + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || '' }} + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-python@v6 + with: + python-version: '${{ env.PYVER }}' + + - name: build wheels + uses: pypa/cibuildwheel@v3.2.1 + env: + CIBW_BUILD: "*manylinux*" + CIBW_ARCHS: auto64 + CIBW_SKIP: ${{ env.CIBW_SKIP_VAL }} + # I think yum might always work here. But leave all options available. + CIBW_BEFORE_ALL: yum install -y bzip2-devel || apt-get install libbz2-dev || apk add --upgrade bzip2-dev + + - name: test wheel for python ${{ env.PYVER }} + run: | + pystr='${{ env.PYVER }}' + pystr=${pystr//./} + python -m pip install pip + pip install numpy pytest + pip install ./wheelhouse/*cp${pystr}*.whl + pytest --pyargs fitsio + + - uses: actions/upload-artifact@v5 + with: + name: whl-linux + path: ./wheelhouse/*.whl + + linux-musl: + name: linux-musl + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || '' }} + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-python@v6 + with: + python-version: '${{ env.PYVER }}' + + - name: build wheels + uses: pypa/cibuildwheel@v3.2.1 + env: + CIBW_BUILD: "*musllinux*" + CIBW_ARCHS: auto64 + CIBW_SKIP: ${{ env.CIBW_SKIP_VAL }} + # I think musl always uses apk, but keep all options available. + CIBW_BEFORE_ALL: yum install -y bzip2-devel || apt-get install libbz2-dev || apk add --upgrade bzip2-dev + + - uses: jirutka/setup-alpine@v1 + with: + packages: "bzip2-dev python3 py3-pip py3-numpy" + + - name: test wheel for python + shell: alpine.sh {0} + run: | + python --version + pystr=$(python --version | cut -d' ' -f 2 | cut -d'.' -f 1)$(python --version | cut -d' ' -f 2 | cut -d'.' -f 2) + mkdir test-venv + python3 -m venv test-venv + . test-venv/bin/activate + pip install numpy pytest + pip install ./wheelhouse/*cp${pystr}*musl*.whl + pytest --pyargs fitsio + deactivate + + - uses: actions/upload-artifact@v5 + with: + name: whl-musl + path: ./wheelhouse/*.whl + + osx-intel: + name: osx-intel + runs-on: macos-13 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || '' }} + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-python@v6 + with: + python-version: '${{ env.PYVER }}' + + - name: build wheels + uses: pypa/cibuildwheel@v3.2.1 + env: + CIBW_BUILD: "*macosx*" + CIBW_ARCHS: auto64 + CIBW_SKIP: ${{ env.CIBW_SKIP_VAL }} + # CIBW_BEFORE_ALL: brew install fftw || true + CIBW_ENVIRONMENT: >- + MACOSX_DEPLOYMENT_TARGET=13.0 + + - name: test wheel for python ${{ env.PYVER }} + run: | + pystr='${{ env.PYVER }}' + pystr=${pystr//./} + python -m pip install pip + pip install numpy pytest + pip install ./wheelhouse/*cp${pystr}*.whl + pytest --pyargs fitsio + + - uses: actions/upload-artifact@v5 + with: + name: whl-macos + path: ./wheelhouse/*.whl + + osx-arm: + name: osx-arm + runs-on: macos-latest + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || '' }} + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-python@v6 + with: + python-version: '${{ env.PYVER }}' + + - name: build wheels + uses: pypa/cibuildwheel@v3.2.1 + env: + CIBW_BUILD: "*macosx*" + CIBW_ARCHS: arm64 + CIBW_SKIP: ${{ env.CIBW_SKIP_VAL }} + # CIBW_BEFORE_ALL: brew install llvm libomp fftw eigen + CIBW_ENVIRONMENT: >- + MACOSX_DEPLOYMENT_TARGET=14.7 + + - name: test wheel for python ${{ env.PYVER }} + run: | + pystr='${{ env.PYVER }}' + pystr=${pystr//./} + python -m pip install pip + pip install numpy pytest + pip install ./wheelhouse/*cp${pystr}*.whl + pytest --pyargs fitsio + + - uses: actions/upload-artifact@v5 + with: + name: whl-arm + path: ./wheelhouse/*.whl + + sdist: + name: sdist + needs: [linux-manylinux, linux-musl, osx-intel, osx-arm] + # Just need to build sdist on a single machine + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || '' }} + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-python@v6 + with: + python-version: '${{ env.PYVER }}' + + - name: Install dependencies + run: | + python -m pip install -U pip + pip install -U build + + - name: download wheels + uses: actions/download-artifact@v6 + with: + path: ./wheels + pattern: whl-* + merge-multiple: true + + - name: build sdist + run: | + python -m build --sdist . + ls -l dist + tar tvfz dist/*.tar.gz + + - name: copy wheels to dist + run: | + echo ls -l wheels + ls -l wheels + cp wheels/*.whl dist + echo ls -l dist + ls -l dist + + - name: publish to pypi + uses: pypa/gh-action-pypi-publish@release/v1 + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + with: + verbose: true + skip-existing: true + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..04ec0b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,126 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# vscode +.vscode/ + +# stuff for cfitsio +cfitsio-*/cfitsio.pc +cfitsio-*/config.status +cfitsio-*/Makefile + +fitsio/_version.py + +.ruff_cache diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..acb935d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,31 @@ +ci: + autofix_commit_msg: | + [pre-commit.ci] auto fixes from pre-commit.com hooks + + for more information, see https://pre-commit.ci + autofix_prs: false + autoupdate_commit_msg: '[pre-commit.ci] pre-commit autoupdate' + autoupdate_schedule: monthly + skip: [] + submodules: false + +exclude: '(^cfitsio-.*\/)|(^patches\/.*)|(^fitsio\/test_images\/.*)|(^zlib\/.*)' + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.13.3 + hooks: + - id: ruff-check + args: [ --fix ] + - id: ruff-format + - repo: https://github.com/pre-commit/mirrors-clang-format + rev: v21.1.2 + hooks: + - id: clang-format + types_or: [c] + args: [ "-style={BasedOnStyle: llvm, IndentWidth: 4}" ] diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..0b3ef13 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,889 @@ +version 1.3.0 +------------- + +Changes + + - Introduced a `fitsio.NOT_SET` singleton for some + parameters whose defaults are deferred at the Python + level and instead set at the C level by cfitsio. The + image compression parameters now use this singleton. + However, the actual underlying defaults used have not + changed. + - Added a new function `fitsio.cfitsio_is_bundled()` + that detects if the cfitsio library is bundled with + the Python code. + - Enabled the code to track all cfitsio error messages, + including those put onto the internal stack and then + later removed. This feature should help with debugging. + - Added support for uint64 / ULONLONG data for binary tables + and images. You need cfitsio version at least 4.1.0 (or + to use the bundled cfitsio) for this feature to work. + - Installation of fitsio will fail if the `patch` command + line utility is missing. To prevent this, set the environment + variable `FITSIO_FAIL_ON_BAD_PATCHES=false`. + - The C code is now styled uniformly with clang-format. + - Updated bundled cfitsio to 4.6.3. + - Added utilities `cfitsio_has_bzip2_support` and + `cfitsio_has_curl_support` to detect at run-time if cfitsio + was built with these options. + - Added methods `delete_key` and `delete_keys` to HDUs to allow + deleting header keys. + - HDU info loading is now done lazily. + - Changed string handling in python 3 to allow for correct + null-terminated behavior when linking to external builds + of cfitsio at version 4 or larger. + +Bug Fixes + + - Fixed incorrect/unspecified minimum python version, + setting it to `>=3.8`. + - Fixed bug where we attempted to open `mem://` files + as existing files when calling `fitsio.FITS.reopen`. + - Fixed a bug where compression parameters set in filenames + were inconsistently applied, especially when compression + parameters were specified in Python too. Now the code will + raise an exception if a user sets Python keyword compression + parameters while also using filename compression parameters. + However, the `dither_seed` can be set from Python even if other + compression parameters are specified in the filename. + - Fixed bugs in lossless GZIP compression of integer types. See the + new patch `patches/imcompress.c.patch`. See https://github.com/HEASARC/cfitsio/pull/97 + and https://github.com/HEASARC/cfitsio/pull/98 for the upstream PR for the patch. + - Fixed a bug where compression parameters were cached across different HDUs. + - Fixed a bug where writing unsupported image types either did not raise an error + or did not raise the correct error. + - Fixed a bug where rectangular subsets of images were not written properly. + - Fixed automatic detection of bzip2 and curl libraries. + - Fixed handling nan values when reading and writing compressed images. + - Fixed bug in cfitsio where underflows were cast to zero when handling + nan values. See https://github.com/HEASARC/cfitsio/pull/102. + - Fixed bug in cfitsio where overwriting a tile-compressed image fails + when the new values are not lossily compressed but the old values were. + See https://github.com/HEASARC/cfitsio/pull/101. + - Fixed a bug where the FITS HDU properties could go out of sync as header + keys were added, modified, etc. + +version 1.2.8 +------------- + +Changes + + - Arrays passed to cfitsio are now forced to be + aligned via `numpy.require`. This change prevents + failures due to rare instances of unaligned memory + access on certain platforms. No additional copies + of arrays are made unless they are unaligned. As + unaligned arrays are rare, this change should have + minimal performance implications. + +Bug Fixes + + - Fixed error in PyPI uploads. + +version 1.2.7 +------------- + +Changes + + - Replace deprecated `NPY_*` constants (Michał Górny) + +version 1.2.6 +------------- + +Bug Fixes + + - Fix bug parsing header cards with free-form strings + - Fix writing and reading of string columns with length + 1 vectors in numpy 2. + - Fix building against NumPy 2.3.0. + +version 1.2.5 +------------- + +New Features + + - writing images supports the dither_seed keyword, to seed + the random number generator for subtractive dithering + - PyPI now has wheels in addition to sdists + +Bug Fixes + + - Fix bug slicing tables that have TBIT columns + +version 1.2.4 +------------- + +Changes + + - use cfitsio-4.4.1-20240617 which reverts to a free license + +version 1.2.3 +------------- + +Changes + + - bundle cfitsio 4.4.0. + - update manifest to include new cfitsio + +Bug Fixes + + - Reading images with empty slices was returning data + - Using cfitsio 4.4.0 fixes a bug reading HIERARCH+CONTINUE keywords + - zlib subdir not in manifest + +version 1.2.2 +------------- + +Changes + + - Updates for numpy version 2 (Eli Rykoff, ESS) + - setup.py: rename env variable BZIP2 to FITSIO_BZIP2_DIR (Maximillian Bensch) + - Add support for LoongArch (liuxiang) + +version 1.2.1 +------------- + +Changes + + - raise new fitsio.FITSFormatError exception when we detect format + errors in a FITS file. Currently this only raises when extensions + are not properly marked with XTENSION but we can expand this over time + +Bug Fixes + + - Bug not writing compression qlevel when it is set to None/0.0 + This was preventing lossless gzip compression + - work around cfitsio bug when creating HDU for lossless gzip compression, + reopen file instead of just an update hdu list + +version 1.2.0 +-------------- + +Changes + + - move to cfitsio 4 + +Bug Fixes + + - Fixed issue where the patched C fitsio headers were not + first in the include path. + - Added extra header guards and headers to help with compilation issues. + - Fixed builds of the bundled cfitsio code to hide symbols and directly + include the *.o files when linking. + +version 1.1.10 +-------------- + +Changes + +Bug Fixes + + - Fix errors on 32 bit builds where default numpy integer + types were 32 rather than 64 bit assumed by the C code. + - Fix checks for rows being sent to C codes + +version 1.1.9 +------------- + +Changes + + - Row subsets of table data are returned in the order sent by the user + rather than sorted and unique. E.g. rows = [2, 2, 1] will return + data corresponding to that order of rows, including duplicates. + - Removed deprecated `pkg_resources` from tests (M. Becker). + - converted tests to use pytest + - Improved doc strings (N. Tessore) + +Bug Fixes + - Bug updating header string cards was adding new key rather than updating + - Bug gzip compression when not using quantization + +version 1.1.8 +------------- + +Bug Fixes + - Bug in repr of FITS where it only worked the first time it was generated + +version 1.1.7 +------------- + +Bug Fixes + + - Bug converting extension names, should just ensure ascii rather than + subset of ascii + - Bugs in the repr of headers where history keywords had extra spaces. + Also a bug with not upper casing things like 1.0E20 which is the + fits standard + +version 1.1.6 +------------- + +Bug Fixes + + - fixed bug where Updating an existing record in an image header raises an + exception (user ussegliog) + - fix bug append not forwarding arguments to write (Nicolas Tessore) + +version 1.1.5 +--------------------------------- + +Bug Fixes + + - Deal with case that a header keyword has non-ascii + characters and the value is numerical. In this case + we cannot convert the value because fits_read_key + can segfault in some scenarios. We instead return + a string. + - Fixed residual segfaults for header cards with non-ascii + characters. + - HIERARCH keywords are now properly parsed. + - Header keywords with `*`, `#` or `?` in them now properly raise an error + before writing since the generated FITS files cannot be read. + +Changes + + - Non-allowed characters in header keywords are now converted to `_` instead + of `JUNK___...`. + +version 1.1.4 +--------------------------------- + +New Features + + - Moved most testing to GitHub actions (linux, osx). + - Added testing on ppc64le w/ TravisCI (thanks @asellappen) + +Bug Fixes + + - Don't remove BLANK keywords in header clean + - Preserve order of comments in header + +Compatibility changes + + - moved to sing `bool` rather than `np.bool` to be compatible + with numpy 1.2 + +version 1.1.3 +--------------------------------- + +This release moves to cfitsio 3.49, which has bug fixes and now properly +supports reading certain classes of lossless compressed files + +New Features + + - Added keywords to control compression + - qlevel control the quantization level + - qmethod set the quantization method + - hcomp_scale, hcomp_smooth HCOMPRESS specific settings + + A nice result of this is that one can do lossless gzip compression + (setting qlevel=0) and + - Work around some types of garbage characters that might appear + in headers + +BACKWARDS INCOMPATIBLE CHANGES + + - non-ascii junk in headers is replaced by ascii characters to + avoid segmentation faults in the python standard library + when non-unicode characters are detected. This will cause + codes that check for consistency between copied headers + to fail, since the header data is modified. + +Bug Fixes + + - Write integer keywords using the long long support rather than long + - Fix bug where a new file is started and the user can access a + fictional HDU, causing book keeping problems + - Return zero length result when requested rows have + zero length (rainwoodman) + +version 1.1.2 +--------------------------------- + +Bug Fixes + + - Fixed deprecation warnings for extra keyword arguments. + - Fixed SyntaxWarning: "is" with a literal (Michka Popoff) + +version 1.1.1 +--------------------------------- + +Bug Fixes + + - Fix bug in drvrnet.c in printf statement, causing compile + issues on some systems. + +version 1.1.0 +--------------------------------- + +Bumping the minor version due to the update of the cfitsio version + +This reverts to the behavior that compression settings are set as a toggle, +which is the cfitsio convention. The user needs to turn compression on and off +selectively. The alternative behavior, introduced in 1.0.1, broke the mode +where compression is set in the filename, as well as breaking with convention. + +New Features + + - Updated to cfitsio version 3.470 (#261) + - Add ability to stride (step value) when slicing (Dustin Jenkins) + - Add feature to flip along axis when slicing (Dustin Jenkins) + - Feature to ignore image scaling (Dustin Jenkins) + +Bug Fixes + + - Fix error reading with an empty rows argument (rainwoodman) + - Fix bug when reading slice with step, but no start/stop (Mike Jarvis) + - Fix bug with clobber when compression is sent in filename + +Deprecations + + - Removed the use of `**kwargs` in various read/write routines. This + pattern was causing silent bugs. All functions now use explicit + keyword arguments. A warning will be raised in any keyword arguments + are passed. In version `1.2`, this warning will become an error. + +version 1.0.5 +--------------------------------- + +Bug Fixes + + - fixed bug getting `None` keywords + - fixed bug writing 64 bit images (#256, #257) + - fixed HISTORY card value not being read + +version 1.0.4 +--------------------------------- + +New Features + + - support for empty keywords in header, which are supported + by the standard and are used for cosmetic comments + +Bug Fixes + + - Fix for inserting bit columns and appending data with bitcols + - deal with non-standard header values such as NAN + [by returning as strings + - fixed many bugs reading headers; these were a casualty of + the header reading optimizations put in for 1.0.1 + +version 1.0.3 +--------------------------------- + +This is a bug fix release + +Bug Fixes + + - The new header reading code did not deal properly with some + HIERARCH non-standard header key values. + +version 1.0.2 +--------------------------------- + +This is a bug fix release + +Bug Fixes + + - the read_header function was not treating the case_sensitive + keyword properly (Stephen Bailey) + +version 1.0.1 +--------------------------------- + +Backwards Incompatible Changes + + - Support for python 3 strings. + - Support for proper string null termination. This means you can read back exactly + what you wrote. However this departs from previous fitsio which used + the non-standard cfitsio convention of padding strings with spaces. + - Scalar indexing of FITS objects now returns a scalar, consistent + with numpy indexing rules (rainwoodman) + +New Features + + - Installation moved to setuptools from distutils. + - Bundling of cfitsio now done with patches against the upstream + version instead of direct edits to the upstream code. + - Speed improvements for the read_header conveniance function, and + reading of headers in general. + +Bug Fixes + + - CONTINUE in headers are now properly read. Note there is a corner + case that is mis-handled by the underlying cfitsio library. A bug + report has been sent. (thanks for help with Alex Drlica-Wagner + identifying and testing this issue) + - Fixed bug where some long strings were not properly written to headers + - Fixed bug where compression settings for an open FITS object was inherited + from the previous HDU by a new HDU + - Fixed bug where comment strings were lost when setting the value in + a FITSHDR entry + - Fixed bug where get_comment was raising ValueError rather than KeyError + - For py3 need to ensure by hand that strings sizes are greater than 0 + +Deprecations + + - removed `convert` keyword in `FITSRecord` and `FITSHDR` classes. + +version 0.9.12 +--------------------------------- + +New Features + + - Deal properly with undefined value header entries + - can delete rows from a table + - can insert rows with resize() + - can create empty HDU extension for extensions beyond 0 (Felipe Menanteau) + - sanitize string input for py3 + - GZIP_2 compression support (Felipe Menanteau) + - Improvements to python packaging for easier installation. + - Using cfitsio 3.430 now with patches for known bugs + - Now support reading and writing bit columns (Eli Rykoff) + - Can now read CONTINUE keywords in headers. It is currently + treated as a comment; full implementation to come. (Alex Drlica-Wagner) + - Can now use a standard key dict when writing a header key using + the write_key method via `**`, e.g. `write_key(**key_dict)` + (Alex Drlica-Wagner) + - Delete row sets and row ranges using the delete_rows() method + for tables + - Resize tables, adding or removing rows, using the resize() method for + tables + - make write_key usable with standard dictionary using the `**keydict` + style + - allow writing empty HDUs after the first one using + ignore_empty=True to the FITS constructor or + the write convenience function (Felipe Menanteau) + We might make this the default in the future if + it is found to be benign + +Bug Fixes + + - Only raise exception when PLIO u4/u8 is selected now that u1/u2 is supported + in cfitsio (Eli Rykoff) + - link curl library if cfitsio linked to it + - don't require numpy to run setup (Simon Conseil) + - strings with understores in headers, such as `1_000_000` are now not converted to numbers in py3 + - check that the input fields names for tables are unique after converting + to upper case + - link against libm explicitly for compatibility on some systems + +version 0.9.11 +--------------------------------- + +New Features + + - Added trim_strings option to constructor and as keyword for read methods. + If trim_strings=True is set, white space is trimmed from the end + of all string columns upon reading. This was introduced because + cfitsio internally pads strings out with spaces to the full column + width when writing, against the FITS standard. + + - Added read_raw() method to the FITS class, to read the raw underlying data + from the file (Dustin Lang) + +Bug Fixes + + - Fix bug reading hierarch keywords. recent changes to keyword parsing had + broken reading of hierarch keywords + - Fix for strings that look like expressions, e.g. '3-4' which were + being evaluated rather than returned as strings. + - Fix bug for missing key in FITSHDR object using the hdr[key] + notation. Also raise KeyError rather than ValueError + +version 0.9.10 +--------------- + +Bug Fixes + + - Fix variable length string column copying in python 3 + - Fix bug checking for max size in a variable length table column. + - Raise an exception when writing to a table with data + that has shape () + - exit test suite with non-zero exit code if a test fails + +Continuous integration + + - the travis ci now runs unit tests, ignoring those that may fail + when certain libraries/headers are not installed on the users system (for + now this is only bzip2 support) + - only particular pairs of python version/numpy version are tested + +python3 compatibility + + - the compatibility is now built into the code rather than + using 2to3 to modify code at install time. + +Workarounds + + - It turns out that when python, numpy etc. are compiled with gcc 4* + and fitsio is compiled with gcc 5* there is a problem, in some cases, + reading from an array with not aligned memory. This has to do with using + the -O3 optimization flag when compiling cfitsio. For replacing -O3 with + -O2 fixes the issue. This was an issue on linux in both anaconda python2 + and python3. + +version 0.9.9.1 +---------------------------------- + +New tag so that pypi will accept the updated version + +version 0.9.9 +---------------------------------- + +New Features + + - header_start, data_start, data_end now available in the + info dictionary, as well as the new get_offsets() method + to access these in a new dict. + (thanks Dimitri Muna for the initial version of this) + +Bug Fixes + + - Fix bug when writing new COMMENT fields (thanks Alex Drlica-Wagner for + initial fix) + - deal correctly with aligned data in some scenarios + (thanks Ole Streicher) + - use correct data type long for tile_dims_fits in + the set_compression C code. This avoids a crash + on 32 but systems. (thanks Ole Streicher) + - use correct data type npy_int64 for pointer in + get_long_slices (this function is not not correctly + named). Avoids crash on some 32 bit systems. + (thanks Ole Streicher) + - use correct data type npy_int64 for pointer in + PyFITSObject_create_image_hdu, rather than npy_intp. + (thanks Ole Streicher) + +version 0.9.8 +---------------------------------- + +New Features + + - added read_scamp_head function to read the .head files output + by SCAMP and return a FITSHDR object + - reserved header space when creating image and table extensions + and a header is being written. This can improve performance + substantially, especially on distributed file systems. + - When possible write image data at HDU creation. This can + be a big performance improvement, especially on distributed file + systems. + - Support for reading bzipped FITS files. (Dustin Lang) + + - Added option to use the system CFITSIO instead of the bundled one, + by sending --use-system-fitsio. Strongly recommend only use cfitsio + that are as new as the bundled one. Also note the bundled cfitsio + sometimes contains patches that are not yet upstream in an + official cfitsio release + - proper support for reading unsigned images compressed with PLIO. + This is a patch directly on the cfitsio code base. The same + code is in the upstream, but not yet released. + - New method reshape(dims) for images + - When writing into an existing image HDU, and larger dimensions + are required, the image is automatically expanded. + +Bug Fixes + + - Fixed broken boolean fields in new versions of numpy (rainwoodman) Fixed + - bug when image was None (for creating empty first HDU) removed -iarch in + - setup.py for mac OS X. This should + work for versions Mavericks and Snow Leapard (Christopher Bonnett) + - Reading a single string column was failing in some cases, this + has been fixed + - When creating a TableColumnSubset using [cols], the existence + of the columns is checked immediately, rather than waiting for the + check in the read() + - make sure to convert correct endianness when writing during image HDU + creation + - Corrected the repr for single column subsets + - only clean bzero,bscale,bunit from headers for TableHDU + +Dev features + + - added travis ci + +version 0.9.7 +---------------------------------- + +New Features + + - python 3 compatibility + - Adding a new HDU is now near constant time + - Can now create an empty image extension using create_image_hdu + and sending the dims= and dtype= keywords + - Can now write into a sub-section of an existing image using the + start= keyword. + - Can now use a scalar slice for reading images, e.g. + hdu[row1:row2, col] + although this still currently retains the extra dimension + - Use warnings instead of printing to stdout + - IOError is now used to indicate a number of errors that + were previously ValueError + +version 0.9.6 +-------------- + +New Features + + - use cfitsio 3370 to support new tile compression features + - FITSRecord class to encapsulate all the ways one can represent header + records. This is now used internally in the FITSHDR class instead of raw + dicts, but as FITSRecord inherits from dict this should be transparent. + - FITSCard class; inherits from FITSRecord and is a special case for header + card strings + - One can directly add a fits header card string to the FITSHDR object + using add_record + +Bug Fixes + + - use literal_eval instead of eval for evaluating header values (D. Lang) + - If input to write_keys is a FITSHDR, just use it instead of creating a + new FITSHDR object. (D. Lang) + - update existing keys when adding records to FITSHDR, except for + comment and history fields. + - fixed bug with empty string in header card + - deal with cfitsio treating first 4 comments specially + +version 0.9.5 +-------------------------------- + +Note the version 0.9.4 was skipped because some people had been using the +master branch in production, which had version 0.9.4 set. This will allow +automatic version detection to work. In the future master will not have +the next version set until release. + +New Features + + - Re-factored code to use sub-classes for each HDU type. These are called + ImageHDU, TableHDU, and AsciiTableHDU. + - Write and read 32-bit and 64-bit complex table columns + - Write and read boolean table columns (contributed by Dustin Lang) + - Specify tile dimensions for compressed images. + - write_comment and write_history methods added. + - is_compressed() for image HDUs, True if tile compressed. + - added `**keys` to the image hdu reading routines to provide a more uniform + interface for all hdu types + +Bug Fixes + + - Correct appending to COMMENT and HISTORY fields when writing a full + header object. + - Correct conversion of boolean keywords, writing and reading. + - Strip out compression related reserved keywords when writing a + user-provided header. + - Simplified reading string columns in ascii tables so that certain + incorrectly formatted tables from CASUTools are now read accurately. + The change was minimal and did not affect reading well formatted tables, + so seemed worth it. + - Support non-standard TSHORT and TFLOAT columns in ascii tables as + generated by CASUTools. They are non-standard but supporting them + does not seem to break anything (pulled from Simon Walker). + +All changes E. Sheldon except where noted. + +version 0.9.3 +-------------------------- + +New Features + + - Can write lists of arrays and dictionaries of arrays + to fits tables. + - Added iteration over HDUs in FITS class + - Added iteration to the FITSHDU object + - Added iteration to the FITSHDR header object + - added checking that a hdu exists in the file, either + by extension number or name, using the "in" syntax. e.g. + fits=fitsio.FITS(filename) + if 'name' in fits: + data=fits['name'].read() + - added `**keys` to the read_header function + - added get_exttype() to the FITSHDU class + 'BINARY_TBL' 'ASCII_TBL' 'IMAGE_HDU' + - added get_nrows() for binary tables + - added get_colnames() + - added get_filename() + - added get_info() + - added get_nrows() + - added get_vstorage() + - added is_compressed() + - added get_ext() + +minor changes + + - raise error on malformed TDIM + +Backwards incompatible changes + + - renamed some attributes; use the getters instead + - `colnames` -> `_colnames` + - `info` -> `_info` + - `filename` -> `_filename` + - `ext` -> `_ext` + - `vstorage` -> `_vstorage` + - `is_comparessed` -> `_is_compressed` + ( use the getter ) + +Bug Fixes + + - newer numpys (1.6.2) were barfing adding a python float to u4 arrays. + - Give a more clear error message for malformed TDIM header keywords + - fixed bug displaying column info for string array columns in tables + - got cfitsio patch to deal with very large compressed images, which were + not read properly. This is now in the latest cfitsio. + - implemented workaround for bug where numpy declareds 'i8' arrays as type + npy_longlong, which is not correct. + - fixed bug in order of iteration of HDUs + +version 0.9.2 +-------------------------- + +New Features + + - Much faster writing to tables when there are many columns. + - Header object now has a setitem feature + h['item'] = value + - Header stores values now instead of the string rep + - You can force names of fields read from tables to upper + or lower case, either during construction of the FITS object + using or at read time using the lower= and upper= keywords. + +bug fixes + - more sensible data structure for header keywords. Now works in all known + cases when reading and rewriting string fields. + +version 0.9.1 +------------------------- + +New features + + - Added reading of image slices, e.g. `f[ext][2:25, 10:100]` + - Added insert_column(name, data, colnum=) method for HDUs., 2011-11-14 ESS + - Added a verify_checksum() method for HDU objects. 2011-10-24, ESS + - Headers are cleaned of required keyword before writing. E.g. if you have + with fitsio.FITS(file,'rw') as fits: + fits.write(data, header=h) + Keywords like NAXIS, TTYPE* etc are removed. This allows you to read + a header from a fits file and write it to another without clobbering + the required keywords. + + - when accessing a column subset object, more metadata are shown + `f[ext][name]` + - can write None as an image for extension 0, as supported by + the spirit standard. Similarly reading gives None in that case. + - the setup.py is now set up for registering versions to pypi. + +bug fixes + + - Fixed bug that occured sometimes when reading individual columns where a + few bytes were not read. Now using the internal cfitsio buffers more + carefully. + + - Using fits_read_tblbytes when reading full rows fixes a bug that showed + up in a particular file. + + - required header keywords are stripped from input header objects before + writing. + +version 0.9.0 (2011-10-21) +------------------------- + +This is the first "official" release. A patched version of cfitsio 3.28 is now +bundled. This will make it easier for folks to install, and provide a +consistent code base with which to develop. Thanks to Eli Rykoff for +suggesting a bundle. Thanks to Eli and Martin White for helping extensively +with testing. + +On OS X, we now link properly with universal binaries on intel. Thanks to Eli +Rykoff for help with OS X testing and bug fixes. + +New features + + - Write and read variable length columns. When writing a table, any fields + declared "object" ("O" type char) in the input array will be written to a + variable length column. For numbers, this means vectors of varying + length. For strings, it means varying length strings. + + When reading, there are two options. 1) By default the data are read + into fixed length fields with padding to the maximum size in the table + column. This is a "least surprise" approach, since fancy indexing and + other array ops will work as expectd. 2) To save memory, construct the + FITS object with vstorage='object' to store the data as objects. This + storage can also be written back out to a new FITS file with variable + length columns. You can also over-ride the default vstorage when calling + read functions. + + - Write and read ascii tables. cfitsio supports writing scalar 2- and + 4-byte integers, floats and doubles. But for reading only 4-byte integers + and doubles are supported, presumably because of the ambiguity in the + tform fields. Scalar strings are fully supported in both reading and + writing. No array fields are supported for ascii. + + - Append rows to an existing table using the append method. + >>> fits.write_table(data1) + >>> fits[-1].append(data2) + + - Using the new "where" method, you can select rows in a table where an + input expression evaluates to true. The table is scanned row by row + without a large read. This is surprisingly fast, and useful for figuring + out what sections of a large file you want to extract. only requires + enough memory to hold the row indices. + + >>> w=fits[ext].where('x > 3 && y < 25') + >>> data=fits[ext].read(rows=w) + >>> data=fits[ext][w] + + - You can now read rows and columns from a table HDU using slice notation. e.g. + to read row subsets from extension 1 + >>> fits=fitsio.FITS(filename) + >>> data=fits[1][:] + >>> data=fits[1][10:30] + >>> data=fits[1][10:30:2] + + You can also specify a list of rows + >>> rows=[3,8,25] + >>> data=fits[1][rows] + + This is equivalent to + >>> data=fits[1].read(rows=rows) + + To get columns subsets, the notation is similar. The data are read + when the rows are specified. If a sequence of columns is entered, + a recarray is returned, otherwise a simple array. + >>> data=fits[1]['x'][:] + >>> data=fits[1]['x','y'][3:20] + >>> data=fits[1][column_list][row_list] + + + - Added support for EXTVER header keywords. When choosing an HDU by name, + this allows one to select among HDUs that have the same name. Thanks to + Eli Rykoff for suggesting this feature and helping with testing. + + - Name matching for table columns and extension names is not + case-insensitive by default. You can turn on case sensitivity by + constructing the FITS object with case_sensitive=True, or sending + that keyword to the convenience functions read and read_header. + + - Added write_checksum method to the FITSHDU class, which computes the + checksum for the HDU, both the data portion alone (DATASUM keyword) + and the checksum complement for the entire HDU (CHECKSUM). + + - Added an extensive test suite. Use this to run the tests + fitsio.test.test() + + - Added fitsio.cfitsio_version() function, returns the cfitsio + version as a string. + + - added read_slice method, which is used to implement the slice + notation introduced above. + +significant code changes + + - Now using fits_read_tblbytes when reading all rows and columns. This + is just as fast but does not bypass, and thus confuse, the read buffers. + - Removed many direct uses of the internal cfitsio struct objects, + preferring to use provided access functions. This allowed compilation + on older cfitsio that had different struct representations. + +bug fixes + + - too many to list in this early release. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..6d45519 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,340 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Library General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Library General +Public License instead of this License. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..c50bf66 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +include *.txt +include README.md +include cfitsio-4.6.3.tar.gz +include zlib.tar.gz +recursive-include patches * +recursive-include fitsio/test_images * diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3b4d41 --- /dev/null +++ b/README.md @@ -0,0 +1,488 @@ +# fitsio + +[![build wheels/sdist](https://github.com/esheldon/fitsio/actions/workflows/wheel.yml/badge.svg)](https://github.com/esheldon/fitsio/actions/workflows/wheel.yml) [![tests](https://github.com/esheldon/fitsio/workflows/tests/badge.svg)](https://github.com/esheldon/fitsio/actions?query=workflow%3Atests) + +A Python library to read from and write to `FITS` files. + +## Description + +This is a Python extension written in C and Python. Data are read into +numerical Python arrays. + +A version of `cfitsio` is bundled with this package, there is no need to install +your own, nor will this conflict with a version you have installed. + +## Some Features + +- Read from and write to image, binary, and ASCII table extensions. +- Read arbitrary subsets of table columns and rows without loading all the data + to memory. +- Read image subsets without reading the whole image. +- Write subsets to existing images. +- Write and read variable length table columns. +- Read images and tables using slice notation similar to `numpy` arrays. (This is like a more + powerful `memmap`, since it is column-aware for tables.) +- Append rows to an existing table. +- Delete row sets and row ranges, resize tables, or insert rows. +- Query the columns and rows in a table. +- Read and write header keywords. +- Read and write images in tile-compressed format (`RICE`, `GZIP`, `PLIO` ,`HCOMPRESS`). +- Read/write `GZIP` files directly. +- Read unix compress (`.Z`, `.zip`) and bzip2 (`.bz2`) files. +- `TDIM` information is used to return array columns in the correct shape. +- Write and read string table columns, including array columns of arbitrary + shape. +- Read and write complex, bool (logical), unsigned integer, signed bytes types. +- Write checksums into the header and verify them. +- Insert new columns into tables in-place. +- Iterate over rows in a table. Data are buffered for efficiency. +- Python 3 support, including Python 3 strings. + +## Examples + +```python +import fitsio +from fitsio import FITS,FITSHDR + +# Often you just want to quickly read or write data without bothering to +# create a FITS object. In that case, you can use the read and write +# convienience functions. + +# read all data from the first hdu that has data +filename='data.fits' +data = fitsio.read(filename) + +# read a subset of rows and columns from a table +data = fitsio.read(filename, rows=[35,1001], columns=['x','y'], ext=2) + +# read the header +h = fitsio.read_header(filename) +# read both data and header +data,h = fitsio.read(filename, header=True) + +# open the file and write a new binary table extension with the data +# array, which is a numpy array with fields, or "recarray". + +data = np.zeros(10, dtype=[('id','i8'),('ra','f8'),('dec','f8')]) +fitsio.write(filename, data) + +# Write an image to the same file. By default a new extension is +# added to the file. use clobber=True to overwrite an existing file +# instead. To append rows to an existing table, see below. + +fitsio.write(filename, image) + +# +# the FITS class gives the you the ability to explore the data, and gives +# more control +# + +# open a FITS file for reading and explore +fits=fitsio.FITS('data.fits') + +# see what is in here; the FITS object prints itself +print(fits) + +file: data.fits +mode: READONLY +extnum hdutype hduname +0 IMAGE_HDU +1 BINARY_TBL mytable + +# at the python or ipython prompt the fits object will +# print itself +>>> fits +file: data.fits +... etc + +# explore the extensions, either by extension number or +# extension name if available +>>> fits[0] + +file: data.fits +extension: 0 +type: IMAGE_HDU +image info: + data type: f8 + dims: [4096,2048] + +# by name; can also use fits[1] +>>> fits['mytable'] + +file: data.fits +extension: 1 +type: BINARY_TBL +extname: mytable +rows: 4328342 +column info: + i1scalar u1 + f f4 + fvec f4 array[2] + darr f8 array[3,2] + dvarr f8 varray[10] + s S5 + svec S6 array[3] + svar S0 vstring[8] + sarr S2 array[4,3] + +# See bottom for how to get more information for an extension + +# [-1] to refers the last HDU +>>> fits[-1] +... + +# if there are multiple HDUs with the same name, and an EXTVER +# is set, you can use it. Here extver=2 +# fits['mytable',2] + + +# read the image from extension zero +img = fits[0].read() +img = fits[0][:,:] + +# read a subset of the image without reading the whole image +img = fits[0][25:35, 45:55] + + +# read all rows and columns from a binary table extension +data = fits[1].read() +data = fits['mytable'].read() +data = fits[1][:] + +# read a subset of rows and columns. By default uses a case-insensitive +# match. The result retains the names with original case. If columns is a +# sequence, a numpy array with fields, or recarray is returned +data = fits[1].read(rows=[1,5], columns=['index','x','y']) + +# Similar but using slice notation +# row subsets +data = fits[1][10:20] +data = fits[1][10:20:2] +data = fits[1][[1,5,18]] + +# Using EXTNAME and EXTVER values +data = fits['SCI',2][10:20] + +# Slicing with reverse (flipped) striding +data = fits[1][40:25] +data = fits[1][40:25:-5] + +# all rows of column 'x' +data = fits[1]['x'][:] + +# Read a few columns at once. This is more efficient than separate read for +# each column +data = fits[1]['x','y'][:] + +# General column and row subsets. +columns=['index','x','y'] +rows = [1, 5] +data = fits[1][columns][rows] + +# data are returned in the order requested by the user +# and duplicates are preserved +rows = [2, 2, 5] +data = fits[1][columns][rows] + +# iterate over rows in a table hdu +# faster if we buffer some rows, let's buffer 1000 at a time +fits=fitsio.FITS(filename,iter_row_buffer=1000) +for row in fits[1]: + print(row) + +# iterate over HDUs in a FITS object +for hdu in fits: + data=hdu.read() + +# Note dvarr shows type varray[10] and svar shows type vstring[8]. These +# are variable length columns and the number specified is the maximum size. +# By default they are read into fixed-length fields in the output array. +# You can over-ride this by constructing the FITS object with the vstorage +# keyword or specifying vstorage when reading. Sending vstorage='object' +# will store the data in variable size object fields to save memory; the +# default is vstorage='fixed'. Object fields can also be written out to a +# new FITS file as variable length to save disk space. + +fits = fitsio.FITS(filename,vstorage='object') +# OR +data = fits[1].read(vstorage='object') +print(data['dvarr'].dtype) + dtype('object') + + +# you can grab a FITS HDU object to simplify notation +hdu1 = fits[1] +data = hdu1['x','y'][35:50] + +# get rows that satisfy the input expression. See "Row Filtering +# Specification" in the cfitsio manual (note no temporary table is +# created in this case, contrary to the cfitsio docs) +w=fits[1].where("x > 0.25 && y < 35.0") +data = fits[1][w] + +# read the header +h = fits[0].read_header() +print(h['BITPIX']) + -64 + +fits.close() + + +# now write some data +fits = FITS('test.fits','rw') + + +# create a rec array. Note vstr +# is a variable length string +nrows=35 +data = np.zeros(nrows, dtype=[('index','i4'),('vstr','O'),('x','f8'), + ('arr','f4',(3,4))]) +data['index'] = np.arange(nrows,dtype='i4') +data['x'] = np.random.random(nrows) +data['vstr'] = [str(i) for i in xrange(nrows)] +data['arr'] = np.arange(nrows*3*4,dtype='f4').reshape(nrows,3,4) + +# create a new table extension and write the data +fits.write(data) + +# can also be a list of ordinary arrays if you send the names +array_list=[xarray,yarray,namearray] +names=['x','y','name'] +fits.write(array_list, names=names) + +# similarly a dict of arrays +fits.write(dict_of_arrays) +fits.write(dict_of_arrays, names=names) # control name order + +# append more rows to the table. The fields in data2 should match columns +# in the table. missing columns will be filled with zeros +fits[-1].append(data2) + +# insert a new column into a table +fits[-1].insert_column('newcol', data) + +# insert with a specific colnum +fits[-1].insert_column('newcol', data, colnum=2) + +# overwrite rows +fits[-1].write(data) + +# overwrite starting at a particular row. The table will grow if needed +fits[-1].write(data, firstrow=350) + + +# create an image +img=np.arange(2*3,dtype='i4').reshape(2,3) + +# write an image in a new HDU (if this is a new file, the primary HDU) +fits.write(img) + +# write an image with rice compression +fits.write(img, compress='rice') + +# control the compression +fimg=np.random.normal(size=2*3).reshape(2, 3) +fits.write(img, compress='rice', qlevel=16, qmethod='SUBTRACTIVE_DITHER_2') + +# lossless gzip compression for integers or floating point +fits.write(img, compress='gzip', qlevel=None) +fits.write(fimg, compress='gzip', qlevel=None) + +# overwrite the image +fits[ext].write(img2) + +# write into an existing image, starting at the location [300,400] +# the image will be expanded if needed +fits[ext].write(img3, start=[300,400]) + +# change the shape of the image on disk +fits[ext].reshape([250,100]) + +# add checksums for the data +fits[-1].write_checksum() + +# can later verify data integridy +fits[-1].verify_checksum() + +# you can also write a header at the same time. The header can be +# - a simple dict (no comments) +# - a list of dicts with 'name','value','comment' fields +# - a FITSHDR object + +hdict = {'somekey': 35, 'location': 'kitt peak'} +fits.write(data, header=hdict) +hlist = [{'name':'observer', 'value':'ES', 'comment':'who'}, + {'name':'location','value':'CTIO'}, + {'name':'photometric','value':True}] +fits.write(data, header=hlist) +hdr=FITSHDR(hlist) +fits.write(data, header=hdr) + +# you can add individual keys to an existing HDU +fits[1].write_key(name, value, comment="my comment") + +# Write multiple header keys to an existing HDU. Here records +# is the same as sent with header= above +fits[1].write_keys(records) + +# write special COMMENT fields +fits[1].write_comment("observer JS") +fits[1].write_comment("we had good weather") + +# write special history fields +fits[1].write_history("processed with software X") +fits[1].write_history("re-processed with software Y") + +fits.close() + +# using a context, the file is closed automatically after leaving the block +with FITS('path/to/file') as fits: + data = fits[ext].read() + + # you can check if a header exists using "in": + if 'blah' in fits: + data=fits['blah'].read() + if 2 in f: + data=fits[2].read() + +# methods to get more information about extension. For extension 1: +f[1].get_info() # lots of info about the extension +f[1].has_data() # returns True if data is present in extension +f[1].get_extname() +f[1].get_extver() +f[1].get_extnum() # return zero-offset extension number +f[1].get_exttype() # 'BINARY_TBL' or 'ASCII_TBL' or 'IMAGE_HDU' +f[1].get_offsets() # byte offsets (header_start, data_start, data_end) +f[1].is_compressed() # for images. True if tile-compressed +f[1].get_colnames() # for tables +f[1].get_colname(colnum) # for tables find the name from column number +f[1].get_nrows() # for tables +f[1].get_rec_dtype() # for tables +f[1].get_rec_column_descr() # for tables +f[1].get_vstorage() # for tables, storage mechanism for variable + # length columns + +# public attributes you can feel free to change as needed +f[1].lower # If True, lower case colnames on output +f[1].upper # If True, upper case colnames on output +f[1].case_sensitive # if True, names are matched case sensitive +``` + +## Installation + +The easiest way is using `pip` or `conda`. To get the latest release + +```bash +pip install fitsio + +# update fitsio (and everything else) +pip install fitsio --upgrade + +# if pip refuses to update to a newer version +pip install fitsio --upgrade --ignore-installed + +# if you only want to upgrade fitsio +pip install fitsio --no-deps --upgrade --ignore-installed + +# for conda, use conda-forge +conda install -c conda-forge fitsio +``` + +You can also get the latest source tarball release from + +```url +https://pypi.python.org/pypi/fitsio +``` + +or the bleeding edge source from GitHub or use git. To check out +the code for the first time + +```bash +git clone https://github.com/esheldon/fitsio.git +``` + +Or at a later time to update to the latest + +```bash +cd fitsio +git update +``` + +Use `tar xvfz` to unpack the file, enter the `fitsio` directory and type + +```bash +pip install . +``` + +## Requirements + +- python >=3.8 +- a C compiler and build tools like `make`, `patch`, etc. +- numpy (See the note below. Generally, numpy 1.11 or later is better.) + +### Do not use `numpy` 1.10.0 or 1.10.1 + +There is a serious performance regression in `numpy` 1.10 that results +in `fitsio` running tens to hundreds of times slower. A fix may be +forthcoming in a later release. Please comment on GitHub issue +[numpy/issues/6467](https://github.com/numpy/numpy/issues/6467) +here if this has already impacted your work + +## Tests + +The unit tests should all pass for full support. + +```bash +pytest fitsio +``` + +Some tests may fail if certain libraries are not available, such +as bzip2. This failure only implies that bzipped files cannot +be read, without affecting other functionality. + +## Linting and Code Formatting + +We use the `pre-commit` framework for linting and code formatting. To +run the linting and code formatting, use the following command + +```bash +pre-commit run -a +``` + +## Notes on Usage and Features + +### `cfitsio` bundling + +We bundle cfitsio partly because many deployed versions of cfitsio in the +wild do not have support for interesting features like tiled image compression. +Bundling a version that meets our needs is a safe alternative. + +### Array Ordering + +Since numpy uses C order, FITS uses fortran order, we have to write the TDIM +and image dimensions in reverse order, but write the data as is. Then we need +to also reverse the dims as read from the header when creating the numpy dtype, +but read as is. + +### `distutils` vs `setuptools` + +As of version `1.0.0`, `fitsio` has been transitioned to `setuptools` for packaging +and installation. There are many reasons to do this (and to not do this). However, +at a practical level, what this means for you is that you may have trouble uninstalling +older versions with `pip` via `pip uninstall fitsio`. If you do, the best thing to do is +to manually remove the files manually. See this [stackoverflow question](https://stackoverflow.com/questions/402359/how-do-you-uninstall-a-python-package-that-was-installed-using-distutils) +for example. + +### Python 3 Strings + +As of version `1.0.0`, fitsio now supports Python 3 strings natively. This support +means that for Python 3, native strings are read from and written correctly to +FITS files. All byte string columns are treated as ASCII-encoded unicode strings +as well. For FITS files written with a previous version of fitsio, the data +in Python 3 will now come back as a string and not a byte string. Note that this +support is not the same as full unicode support. Internally, fitsio only supports +the ASCII character set. + +## TODO + +- HDU groups: does anyone use these? If so open an issue! diff --git a/fitsio/__init__.py b/fitsio/__init__.py new file mode 100644 index 0000000..2d84ee9 --- /dev/null +++ b/fitsio/__init__.py @@ -0,0 +1,46 @@ +# flake8: noqa +""" +A python library to read and write data to FITS files using cfitsio. +See the docs at https://github.com/esheldon/fitsio for example +usage. +""" + +try: + from ._version import __version__ +except ImportError: + __version__ = None + +from . import fitslib + +from .fitslib import ( + FITS, + read, + read_header, + read_scamp_head, + write, + READONLY, + READWRITE, + NOCOMPRESS, + RICE_1, + GZIP_1, + GZIP_2, + PLIO_1, + HCOMPRESS_1, + NO_DITHER, + SUBTRACTIVE_DITHER_1, + SUBTRACTIVE_DITHER_2, + NOT_SET, +) + +from .header import FITSHDR, FITSRecord, FITSCard +from .hdu import BINARY_TBL, ASCII_TBL, IMAGE_HDU + +from . import util +from .util import ( + cfitsio_version, + FITSRuntimeWarning, + cfitsio_is_bundled, +) +from ._fitsio_wrap import cfitsio_has_bzip2_support, cfitsio_has_curl_support + +from .fits_exceptions import FITSFormatError diff --git a/fitsio/fits_exceptions.py b/fitsio/fits_exceptions.py new file mode 100644 index 0000000..196d241 --- /dev/null +++ b/fitsio/fits_exceptions.py @@ -0,0 +1,11 @@ +class FITSFormatError(Exception): + """ + Format error in FITS file + """ + + def __init__(self, value): + super(FITSFormatError, self).__init__(value) + self.value = value + + def __str__(self): + return str(self.value) diff --git a/fitsio/fitsio_pywrap.c b/fitsio/fitsio_pywrap.c new file mode 100644 index 0000000..5a24dd3 --- /dev/null +++ b/fitsio/fitsio_pywrap.c @@ -0,0 +1,5384 @@ +/* + * fitsio_pywrap.c + * + * This is a CPython wrapper for the cfitsio library. + + Copyright (C) 2011 Erin Sheldon, BNL. erin dot sheldon at gmail dot com + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "fitsio.h" +#include "fitsio2.h" +#include +#include +// #include "fitsio_pywrap_lists.h" +#include + +// this is not defined anywhere in cfitsio except in +// the fits file structure +#define CFITSIO_MAX_ARRAY_DIMS 99 + +// not sure where this is defined in numpy... +#define NUMPY_MAX_DIMS 32 + +// max len of python error message +#define PYFITS_ERRMSG_LEN 1024 + +struct PyFITSObject { + PyObject_HEAD fitsfile *fits; + // we store the python error message here so that we record all error + // messages as they happen. sometimes cfitsio will clear + // the error stack and this removes important debugging info + char pyfits_errmsg[PYFITS_ERRMSG_LEN]; +}; + +// check unicode for python3, string for python2 +static int is_python_string(const PyObject *obj) { +#if PY_MAJOR_VERSION >= 3 + return PyUnicode_Check(obj) || PyBytes_Check(obj); +#else + return PyUnicode_Check(obj) || PyString_Check(obj); +#endif +} + +/* + Ensure all elements of the null terminated string are ascii, replacing + non-ascii characters with a ? +*/ + +static void convert_to_ascii(char *str) { + size_t size = 0, i = 0; + int cval = 0; + + size = strlen(str); + for (i = 0; i < size; i++) { + cval = (int)str[i]; + if (cval < 0 || cval > 127) { + str[i] = '?'; + } + } +} + +/* + Replace non ascii characters with _ +*/ +static int convert_extname_to_ascii(char *str) { + int was_converted = 0; + size_t size = 0, i = 0; + int cval = 0; + + size = strlen(str); + for (i = 0; i < size; i++) { + cval = (int)str[i]; + + if (cval < 0 || cval > 127) { + was_converted = 1; + str[i] = '_'; + } + } + return was_converted; +} + +/* + Replace bad keyword characters (?, *, #) or non-ascii with valid _ + characters. +*/ +static int +convert_keyword_to_allowed_ascii_template_and_nonascii_only(char *str) { + int isbad = 0, was_converted = 0; + size_t size = 0, i = 0; + int cval = 0; + + size = strlen(str); + for (i = 0; i < size; i++) { + cval = (int)str[i]; + + isbad = (cval == '?') || (cval == '*') || (cval == '#'); + + if (isbad || cval < 32 || cval > 126) { + was_converted = 1; + str[i] = '_'; + } + } + return was_converted; +} + +/* +return 1 if a keyword has non-standard FITS keyword characters. +*/ +static int has_invalid_keyword_chars(char *str) { + int isbad = 0; + size_t size = 0, i = 0; + int cval = 0; + + size = strlen(str); + for (i = 0; i < size; i++) { + cval = (int)str[i]; + + isbad = + !((cval >= 'A' && cval <= 'Z') || (cval >= 'a' && cval <= 'z') || + (cval >= '0' && cval <= '9') || (cval == '-') || (cval == '_')); + } + return isbad; +} + +/* + + get a string version of the object. New memory + is allocated and the receiver must clean it up. + +*/ + +// unicode is common to python 2 and 3 +static char *get_unicode_as_string(PyObject *obj) { + PyObject *tmp = NULL; + char *strdata = NULL; + tmp = PyObject_CallMethod(obj, "encode", NULL); + + strdata = strdup(PyBytes_AsString(tmp)); + Py_XDECREF(tmp); + + return strdata; +} + +static char *get_object_as_string(PyObject *obj) { + PyObject *format = NULL; + PyObject *args = NULL; + char *strdata = NULL; + PyObject *tmpobj1 = NULL; + + if (PyUnicode_Check(obj)) { + + strdata = get_unicode_as_string(obj); + + } else { + +#if PY_MAJOR_VERSION >= 3 + + if (PyBytes_Check(obj)) { + strdata = strdup(PyBytes_AsString(obj)); + } else { + PyObject *tmpobj2 = NULL; + format = Py_BuildValue("s", "%s"); + // this is not a string object + args = PyTuple_New(1); + + PyTuple_SetItem(args, 0, obj); + tmpobj2 = PyUnicode_Format(format, args); + tmpobj1 = PyObject_CallMethod(tmpobj2, "encode", NULL); + + Py_XDECREF(args); + Py_XDECREF(tmpobj2); + + strdata = strdup(PyBytes_AsString(tmpobj1)); + Py_XDECREF(tmpobj1); + Py_XDECREF(format); + } + +#else + // convert to a string as needed + if (PyString_Check(obj)) { + strdata = strdup(PyString_AsString(obj)); + } else { + format = Py_BuildValue("s", "%s"); + args = PyTuple_New(1); + + PyTuple_SetItem(args, 0, obj); + tmpobj1 = PyString_Format(format, args); + + strdata = strdup(PyString_AsString(tmpobj1)); + Py_XDECREF(args); + Py_XDECREF(tmpobj1); + Py_XDECREF(format); + } +#endif + } + + return strdata; +} + +static void set_ioerr_string_from_status(int status, + struct PyFITSObject *self) { + char status_str[FLEN_STATUS], errmsg[FLEN_ERRMSG]; + char str_with_newline[1024]; + int nleft; + // sometimes this function is called without an instantiated PyFITSPObject. + // in that case, we use a local buffer and grab all of the information we + // can to set the error string. this edge case means we will sometimes miss + // error messages that cfitsio puts on the stack but then removes later. + char message_if_null[PYFITS_ERRMSG_LEN]; + + // pointer to the error message buffer we are using + char *message; + + // always init error message to zero length string + // init of self->pyfits_errmsg is done when object is created + message_if_null[0] = '\0'; + + if (self != NULL) { + message = &(self->pyfits_errmsg[0]); + } else { + message = &(message_if_null[0]); + } + + // the number of characters left is the number of characters not + // used in the message variable minus one. We remove one for the + // C null termination character. So if the variable is PYFITS_ERRMSG_LEN + // characters long, then nleft is PYFITS_ERRMSG_LEN - 1. + // This works because: + // - strncat always adds the null termination character + // - strlen returns the number of characters up to the first null (and + // does not include the null itself in the total count). + // Thus we never have to account for the null ourselves except in the + // initial number of characters we have to use for holding error messages + // (which we set to one less than the total storage we have so strncat + // always has room for the null.) + nleft = PYFITS_ERRMSG_LEN - strlen(message) - 1; + + if (status) { + fits_get_errstatus(status, status_str); /* get the error description */ + + sprintf(str_with_newline, "FITSIO status = %d: %s\n", status, + status_str); + if (nleft >= strlen(str_with_newline)) { + strncat(message, str_with_newline, nleft); + nleft -= strlen(str_with_newline); + } + + while (nleft > 0 && + fits_read_errmsg(errmsg)) { /* get error stack messages */ + sprintf(str_with_newline, "%s\n", errmsg); + if (nleft >= strlen(str_with_newline)) { + strncat(message, str_with_newline, nleft); + nleft -= strlen(str_with_newline); + } else { + break; + } + } + PyErr_SetString(PyExc_IOError, message); + } + return; +} + +/* + string list helper functions +*/ + +struct stringlist { + size_t size; + char **data; +}; + +static struct stringlist *stringlist_new(void) { + struct stringlist *slist = NULL; + + slist = malloc(sizeof(struct stringlist)); + slist->size = 0; + slist->data = NULL; + return slist; +} +// push a copy of the string onto the string list +static void stringlist_push(struct stringlist *slist, const char *str) { + size_t newsize = 0; + size_t i = 0; + + newsize = slist->size + 1; + slist->data = realloc(slist->data, sizeof(char *) * newsize); + slist->size += 1; + + i = slist->size - 1; + + slist->data[i] = strdup(str); +} + +static void stringlist_push_size(struct stringlist *slist, size_t slen) { + size_t newsize = 0; + size_t i = 0; + + newsize = slist->size + 1; + slist->data = realloc(slist->data, sizeof(char *) * newsize); + slist->size += 1; + + i = slist->size - 1; + + slist->data[i] = calloc(slen + 1, sizeof(char)); + // slist->data[i] = malloc(sizeof(char)*(slen+1)); + // memset(slist->data[i], 0, slen+1); +} +static struct stringlist *stringlist_delete(struct stringlist *slist) { + if (slist != NULL) { + size_t i = 0; + if (slist->data != NULL) { + for (i = 0; i < slist->size; i++) { + free(slist->data[i]); + } + } + free(slist->data); + free(slist); + } + return NULL; +} + +/* +static void stringlist_print(struct stringlist* slist) { + size_t i=0; + if (slist == NULL) { + return; + } + for (i=0; isize; i++) { + printf(" slist[%ld]: %s\n", i, slist->data[i]); + } +} +*/ + +static int stringlist_addfrom_listobj(struct stringlist *slist, + PyObject *listObj, const char *listname) { + size_t size = 0, i = 0; + char *tmpstr = NULL; + + if (!PyList_Check(listObj)) { + PyErr_Format(PyExc_ValueError, "Expected a list for %s.", listname); + return 1; + } + size = PyList_Size(listObj); + + for (i = 0; i < size; i++) { + PyObject *tmp = PyList_GetItem(listObj, i); + if (!is_python_string(tmp)) { + PyErr_Format(PyExc_ValueError, "Expected only strings in %s list.", + listname); + return 1; + } + tmpstr = get_object_as_string(tmp); + stringlist_push(slist, tmpstr); + free(tmpstr); + } + return 0; +} + +static void add_double_to_dict(PyObject *dict, const char *key, double value) { + PyObject *tobj = NULL; + tobj = PyFloat_FromDouble(value); + PyDict_SetItemString(dict, key, tobj); + Py_XDECREF(tobj); +} + +static void add_long_to_dict(PyObject *dict, const char *key, long value) { + PyObject *tobj = NULL; + tobj = PyLong_FromLong(value); + PyDict_SetItemString(dict, key, tobj); + Py_XDECREF(tobj); +} + +static void add_long_long_to_dict(PyObject *dict, const char *key, + long long value) { + PyObject *tobj = NULL; + tobj = PyLong_FromLongLong(value); + PyDict_SetItemString(dict, key, tobj); + Py_XDECREF(tobj); +} + +static void add_string_to_dict(PyObject *dict, const char *key, + const char *str) { + PyObject *tobj = NULL; + tobj = Py_BuildValue("s", str); + PyDict_SetItemString(dict, key, tobj); + Py_XDECREF(tobj); +} + +static void add_none_to_dict(PyObject *dict, const char *key) { + PyDict_SetItemString(dict, key, Py_None); + Py_XINCREF(Py_None); +} +static void add_true_to_dict(PyObject *dict, const char *key) { + PyDict_SetItemString(dict, key, Py_True); + Py_XINCREF(Py_True); +} +static void add_false_to_dict(PyObject *dict, const char *key) { + PyDict_SetItemString(dict, key, Py_False); + Py_XINCREF(Py_False); +} + +/* +static +void append_long_to_list(PyObject* list, long value) { + PyObject* tobj=NULL; + tobj=PyLong_FromLong(value); + PyList_Append(list, tobj); + Py_XDECREF(tobj); +} +*/ + +static void append_long_long_to_list(PyObject *list, long long value) { + PyObject *tobj = NULL; + tobj = PyLong_FromLongLong(value); + PyList_Append(list, tobj); + Py_XDECREF(tobj); +} + +/* +static +void append_string_to_list(PyObject* list, const char* str) { + PyObject* tobj=NULL; + tobj=Py_BuildValue("s",str); + PyList_Append(list, tobj); + Py_XDECREF(tobj); +} +*/ + +static int PyFITSObject_init(struct PyFITSObject *self, PyObject *args, + PyObject *kwds) { + char *filename; + int mode; + int status = 0; + int create = 0; + + // init the error message to an empty string + self->pyfits_errmsg[0] = '\0'; + + if (!PyArg_ParseTuple(args, (char *)"sii", &filename, &mode, &create)) { + return -1; + } + + if (create) { + // create and open + if (fits_create_file(&self->fits, filename, &status)) { + set_ioerr_string_from_status(status, self); + return -1; + } + } else { + if (fits_open_file(&self->fits, filename, mode, &status)) { + set_ioerr_string_from_status(status, self); + return -1; + } + } + + return 0; +} + +static PyObject *PyFITSObject_repr(struct PyFITSObject *self) { + + if (self->fits != NULL) { + int status = 0; + char filename[FLEN_FILENAME]; + char repr[2056]; + + if (fits_file_name(self->fits, filename, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + sprintf(repr, "fits file: %s", filename); + return Py_BuildValue("s", repr); + } else { + return Py_BuildValue("s", "none"); + } +} + +static PyObject *PyFITSObject_filename(struct PyFITSObject *self) { + + if (self->fits != NULL) { + int status = 0; + char filename[FLEN_FILENAME]; + PyObject *fnameObj = NULL; + if (fits_file_name(self->fits, filename, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + fnameObj = Py_BuildValue("s", filename); + return fnameObj; + } else { + PyErr_SetString(PyExc_ValueError, + "file is not open, cannot determine name"); + return NULL; + } +} + +static PyObject *PyFITSObject_close(struct PyFITSObject *self) { + int status = 0; + if (fits_close_file(self->fits, &status)) { + self->fits = NULL; + /* + set_ioerr_string_from_status(status, self); + return NULL; + */ + } + self->fits = NULL; + Py_RETURN_NONE; +} + +static void PyFITSObject_dealloc(struct PyFITSObject *self) { + int status = 0; + fits_close_file(self->fits, &status); +#if PY_MAJOR_VERSION >= 3 + // introduced in python 2.6 + Py_TYPE(self)->tp_free((PyObject *)self); +#else + // old way, removed in python 3 + self->ob_type->tp_free((PyObject *)self); +#endif +} + +// this will need to be updated for array string columns. +// I'm using a tcolumn* here, could cause problems +static long get_groupsize(tcolumn *colptr) { + long gsize = 0; + if (colptr->tdatatype == TSTRING) { + // gsize = colptr->twidth; + gsize = colptr->trepeat; + } else { + gsize = colptr->twidth * colptr->trepeat; + } + return gsize; +} +static npy_int64 *get_int64_from_array(PyArrayObject *arr, npy_intp *ncols) { + + npy_int64 *colnums; + int npy_type = 0, check = 0; + + if (!PyArray_Check(arr)) { + PyErr_SetString(PyExc_TypeError, "int64 array must be an array."); + return NULL; + } + + npy_type = PyArray_TYPE(arr); + + // on some platforms, creating an 'i8' array gives it a longlong + // dtype. Just make sure it is 8 bytes + check = + (npy_type == NPY_INT64) | + (npy_type == NPY_LONGLONG && sizeof(npy_longlong) == sizeof(npy_int64)); + if (!check) { + PyErr_Format(PyExc_TypeError, + "array must be an int64 array (%d), got %d.", NPY_INT64, + npy_type); + return NULL; + } + if (!PyArray_ISCONTIGUOUS(arr)) { + PyErr_SetString(PyExc_TypeError, "int64 array must be a contiguous."); + return NULL; + } + + colnums = PyArray_DATA(arr); + *ncols = PyArray_SIZE(arr); + + return colnums; +} + +// move hdu by name and possibly version, return the hdu number +static PyObject *PyFITSObject_movnam_hdu(struct PyFITSObject *self, + PyObject *args) { + int status = 0; + int hdutype = ANY_HDU; // means we don't care if its image or table + char *extname = NULL; + int extver = 0; // zero means it is ignored + int hdunum = 0; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_ValueError, "fits file is NULL"); + return NULL; + } + + if (!PyArg_ParseTuple(args, (char *)"isi", &hdutype, &extname, &extver)) { + return NULL; + } + + if (fits_movnam_hdu(self->fits, hdutype, extname, extver, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + fits_get_hdu_num(self->fits, &hdunum); + return PyLong_FromLong((long)hdunum); +} + +static PyObject *PyFITSObject_movabs_hdu(struct PyFITSObject *self, + PyObject *args) { + int hdunum = 0, hdutype = 0; + int status = 0; + PyObject *hdutypeObj = NULL; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_ValueError, "fits file is NULL"); + return NULL; + } + + if (!PyArg_ParseTuple(args, (char *)"i", &hdunum)) { + return NULL; + } + + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + hdutypeObj = PyLong_FromLong((long)hdutype); + return hdutypeObj; +} + +// get info for the specified HDU +static PyObject *PyFITSObject_get_hdu_info(struct PyFITSObject *self, + PyObject *args) { + int hdunum = 0, hdutype = 0, ext = 0, ignore_scaling = FALSE; + int status = 0, tstatus = 0, is_compressed = 0; + PyObject *dict = NULL; + + char extname[FLEN_VALUE]; + char hduname[FLEN_VALUE]; + int extver = 0, hduver = 0; + + long long header_start; + long long data_start; + long long data_end; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_ValueError, "fits file is NULL"); + return NULL; + } + + if (!PyArg_ParseTuple(args, (char *)"ii", &hdunum, &ignore_scaling)) { + return NULL; + } + + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (ignore_scaling == TRUE && + fits_set_bscale(self->fits, 1.0, 0.0, &status)) { + return NULL; + } + + dict = PyDict_New(); + ext = hdunum - 1; + + add_long_to_dict(dict, "hdunum", (long)hdunum); + add_long_to_dict(dict, "extnum", (long)ext); + add_long_to_dict(dict, "hdutype", (long)hdutype); + + tstatus = 0; + if (fits_read_key(self->fits, TSTRING, "EXTNAME", extname, NULL, + &tstatus) == 0) { + convert_extname_to_ascii(extname); + add_string_to_dict(dict, "extname", extname); + } else { + add_string_to_dict(dict, "extname", ""); + } + + tstatus = 0; + if (fits_read_key(self->fits, TSTRING, "HDUNAME", hduname, NULL, + &tstatus) == 0) { + convert_extname_to_ascii(hduname); + add_string_to_dict(dict, "hduname", hduname); + } else { + add_string_to_dict(dict, "hduname", ""); + } + + tstatus = 0; + if (fits_read_key(self->fits, TINT, "EXTVER", &extver, NULL, &tstatus) == + 0) { + add_long_to_dict(dict, "extver", (long)extver); + } else { + add_long_to_dict(dict, "extver", (long)0); + } + + tstatus = 0; + if (fits_read_key(self->fits, TINT, "HDUVER", &hduver, NULL, &tstatus) == + 0) { + add_long_to_dict(dict, "hduver", (long)hduver); + } else { + add_long_to_dict(dict, "hduver", (long)0); + } + + tstatus = 0; + is_compressed = fits_is_compressed_image(self->fits, &tstatus); + add_long_to_dict(dict, "is_compressed_image", (long)is_compressed); + + // get byte offsets + if (0 == fits_get_hduaddrll(self->fits, &header_start, &data_start, + &data_end, &tstatus)) { + add_long_long_to_dict(dict, "header_start", (long)header_start); + add_long_long_to_dict(dict, "data_start", (long)data_start); + add_long_long_to_dict(dict, "data_end", (long)data_end); + } else { + add_long_long_to_dict(dict, "header_start", -1); + add_long_long_to_dict(dict, "data_start", -1); + add_long_long_to_dict(dict, "data_end", -1); + } + + int ndims = 0; + int maxdim = CFITSIO_MAX_ARRAY_DIMS; + LONGLONG dims[CFITSIO_MAX_ARRAY_DIMS]; + if (hdutype == IMAGE_HDU) { + // move this into it's own func + int tstatus = 0; + int bitpix = 0; + int bitpix_equiv = 0; + char comptype[20]; + PyObject *dimsObj = PyList_New(0); + int i = 0; + + // if (fits_read_imghdrll(self->fits, maxdim, simple_p, &bitpix, &ndims, + // dims, pcount_p, gcount_p, extend_p, &status)) + // { + if (fits_get_img_paramll(self->fits, maxdim, &bitpix, &ndims, dims, + &tstatus)) { + add_string_to_dict(dict, "error", + "could not determine image parameters"); + } else { + add_long_to_dict(dict, "ndims", (long)ndims); + add_long_to_dict(dict, "img_type", (long)bitpix); + + if (ignore_scaling == TRUE) { + // Get the raw type if scaling is being ignored. + fits_get_img_type(self->fits, &bitpix_equiv, &status); + } else { + fits_get_img_equivtype(self->fits, &bitpix_equiv, &status); + } + + add_long_to_dict(dict, "img_equiv_type", (long)bitpix_equiv); + + tstatus = 0; + if (fits_read_key(self->fits, TSTRING, "ZCMPTYPE", comptype, NULL, + &tstatus) == 0) { + convert_to_ascii(comptype); + add_string_to_dict(dict, "comptype", comptype); + } else { + add_none_to_dict(dict, "comptype"); + } + + for (i = 0; i < ndims; i++) { + append_long_long_to_list(dimsObj, (long long)dims[i]); + } + PyDict_SetItemString(dict, "dims", dimsObj); + Py_XDECREF(dimsObj); + } + + } else if (hdutype == BINARY_TBL) { + int tstatus = 0; + LONGLONG nrows = 0; + int ncols = 0; + PyObject *colinfo = PyList_New(0); + int i = 0, j = 0; + + fits_get_num_rowsll(self->fits, &nrows, &tstatus); + fits_get_num_cols(self->fits, &ncols, &tstatus); + add_long_long_to_dict(dict, "nrows", (long long)nrows); + add_long_to_dict(dict, "ncols", (long)ncols); + + { + PyObject *d = NULL; + tcolumn *col = NULL; + struct stringlist *names = NULL; + struct stringlist *tforms = NULL; + names = stringlist_new(); + tforms = stringlist_new(); + + for (i = 0; i < ncols; i++) { + stringlist_push_size(names, 70); + stringlist_push_size(tforms, 70); + } + // just get the names: no other way to do it! + fits_read_btblhdrll(self->fits, ncols, NULL, NULL, names->data, + tforms->data, NULL, NULL, NULL, &tstatus); + + for (i = 0; i < ncols; i++) { + d = PyDict_New(); + int type = 0; + LONGLONG repeat = 0; + LONGLONG width = 0; + + convert_to_ascii(names->data[i]); + add_string_to_dict(d, "name", names->data[i]); + convert_to_ascii(tforms->data[i]); + add_string_to_dict(d, "tform", tforms->data[i]); + + fits_get_coltypell(self->fits, i + 1, &type, &repeat, &width, + &tstatus); + add_long_to_dict(d, "type", (long)type); + add_long_long_to_dict(d, "repeat", (long long)repeat); + add_long_long_to_dict(d, "width", (long long)width); + + fits_get_eqcoltypell(self->fits, i + 1, &type, &repeat, &width, + &tstatus); + add_long_to_dict(d, "eqtype", (long)type); + + tstatus = 0; + if (fits_read_tdimll(self->fits, i + 1, maxdim, &ndims, dims, + &tstatus)) { + add_none_to_dict(d, "tdim"); + } else { + PyObject *dimsObj = PyList_New(0); + for (j = 0; j < ndims; j++) { + append_long_long_to_list(dimsObj, (long long)dims[j]); + } + + PyDict_SetItemString(d, "tdim", dimsObj); + Py_XDECREF(dimsObj); + } + + // using the struct, could cause problems + // actually, we can use ffgcprll to get this info, but will + // be redundant with some others above + col = &self->fits->Fptr->tableptr[i]; + add_double_to_dict(d, "tscale", col->tscale); + add_double_to_dict(d, "tzero", col->tzero); + + PyList_Append(colinfo, d); + Py_XDECREF(d); + } + names = stringlist_delete(names); + tforms = stringlist_delete(tforms); + + PyDict_SetItemString(dict, "colinfo", colinfo); + Py_XDECREF(colinfo); + } + } else { + int tstatus = 0; + LONGLONG nrows = 0; + int ncols = 0; + PyObject *colinfo = PyList_New(0); + int i = 0, j = 0; + + fits_get_num_rowsll(self->fits, &nrows, &tstatus); + fits_get_num_cols(self->fits, &ncols, &tstatus); + add_long_long_to_dict(dict, "nrows", (long long)nrows); + add_long_to_dict(dict, "ncols", (long)ncols); + + { + tcolumn *col = NULL; + struct stringlist *names = NULL; + struct stringlist *tforms = NULL; + names = stringlist_new(); + tforms = stringlist_new(); + + for (i = 0; i < ncols; i++) { + stringlist_push_size(names, 70); + stringlist_push_size(tforms, 70); + } + // just get the names: no other way to do it! + + // rowlen nrows + fits_read_atblhdrll(self->fits, ncols, NULL, NULL, + // tfields tbcol units + NULL, names->data, NULL, tforms->data, NULL, + // extname + NULL, &tstatus); + + for (i = 0; i < ncols; i++) { + PyObject *d = PyDict_New(); + int type = 0; + LONGLONG repeat = 0; + LONGLONG width = 0; + + convert_to_ascii(names->data[i]); + add_string_to_dict(d, "name", names->data[i]); + convert_to_ascii(tforms->data[i]); + add_string_to_dict(d, "tform", tforms->data[i]); + + fits_get_coltypell(self->fits, i + 1, &type, &repeat, &width, + &tstatus); + add_long_to_dict(d, "type", (long)type); + add_long_long_to_dict(d, "repeat", (long long)repeat); + add_long_long_to_dict(d, "width", (long long)width); + + fits_get_eqcoltypell(self->fits, i + 1, &type, &repeat, &width, + &tstatus); + add_long_to_dict(d, "eqtype", (long)type); + + tstatus = 0; + if (fits_read_tdimll(self->fits, i + 1, maxdim, &ndims, dims, + &tstatus)) { + add_none_to_dict(dict, "tdim"); + } else { + PyObject *dimsObj = PyList_New(0); + for (j = 0; j < ndims; j++) { + append_long_long_to_list(dimsObj, (long long)dims[j]); + } + + PyDict_SetItemString(d, "tdim", dimsObj); + Py_XDECREF(dimsObj); + } + + // using the struct, could cause problems + // actually, we can use ffgcprll to get this info, but will + // be redundant with some others above + col = &self->fits->Fptr->tableptr[i]; + add_double_to_dict(d, "tscale", col->tscale); + add_double_to_dict(d, "tzero", col->tzero); + + PyList_Append(colinfo, d); + Py_XDECREF(d); + } + names = stringlist_delete(names); + tforms = stringlist_delete(tforms); + + PyDict_SetItemString(dict, "colinfo", colinfo); + Py_XDECREF(colinfo); + } + } + return dict; +} + +// get info for the specified HDU +static PyObject *PyFITSObject_get_hdu_name_version(struct PyFITSObject *self, + PyObject *args) { + int hdunum = 0, hdutype = 0; + int status = 0; + + char extname[FLEN_VALUE]; + int extver = 0; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_ValueError, "fits file is NULL"); + return NULL; + } + + if (!PyArg_ParseTuple(args, (char *)"i", &hdunum)) { + return NULL; + } + + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + status = 0; + if (fits_read_key(self->fits, TINT, "EXTVER", &extver, NULL, &status) != + 0) { + extver = 0; + } + + status = 0; + if (fits_read_key(self->fits, TSTRING, "EXTNAME", extname, NULL, &status) == + 0) { + return Py_BuildValue("si", extname, extver); + } else { + return Py_BuildValue("si", "", extver); + } +} + +// this is the parameter that goes in the type for fits_write_col +static int npy_to_fits_table_type(int npy_dtype, int write_bitcols) { + + char mess[255]; + switch (npy_dtype) { + case NPY_BOOL: + if (write_bitcols) { + return TBIT; + } else { + return TLOGICAL; + } + case NPY_UINT8: + return TBYTE; + case NPY_INT8: + return TSBYTE; + case NPY_UINT16: + return TUSHORT; + case NPY_INT16: + return TSHORT; + case NPY_UINT32: + if (sizeof(unsigned int) == sizeof(npy_uint32)) { + return TUINT; + } else if (sizeof(unsigned long) == sizeof(npy_uint32)) { + return TULONG; + } else { + PyErr_SetString(PyExc_TypeError, + "could not determine 4 byte unsigned integer type"); + return -9999; + } + case NPY_INT32: + if (sizeof(int) == sizeof(npy_int32)) { + return TINT; + } else if (sizeof(long) == sizeof(npy_int32)) { + return TLONG; + } else { + PyErr_SetString(PyExc_TypeError, + "could not determine 4 byte integer type"); + return -9999; + } + + case NPY_INT64: + if (sizeof(long long) == sizeof(npy_int64)) { + return TLONGLONG; + } else if (sizeof(long) == sizeof(npy_int64)) { + return TLONG; + } else if (sizeof(int) == sizeof(npy_int64)) { + return TINT; + } else { + PyErr_SetString(PyExc_TypeError, + "could not determine 8 byte integer type"); + return -9999; + } +#ifdef TULONGLONG + case NPY_UINT64: + if (sizeof(unsigned long long) == sizeof(npy_uint64)) { + return TULONGLONG; + } else if (sizeof(unsigned long) == sizeof(npy_uint64)) { + return TULONG; + } else if (sizeof(unsigned int) == sizeof(npy_uint64)) { + return TUINT; + } else { + PyErr_SetString(PyExc_TypeError, + "could not determine 8 byte unsigned integer type"); + return -9999; + } +#else + case NPY_UINT64: + PyErr_SetString(PyExc_TypeError, + "Unsigned 8 byte integer images are not " + "supported by the FITS standard"); + return -9999; +#endif + + case NPY_FLOAT32: + return TFLOAT; + case NPY_FLOAT64: + return TDOUBLE; + + case NPY_COMPLEX64: + return TCOMPLEX; + case NPY_COMPLEX128: + return TDBLCOMPLEX; + + case NPY_STRING: + return TSTRING; + + default: + sprintf(mess, "Unsupported numpy table datatype %d", npy_dtype); + PyErr_SetString(PyExc_TypeError, mess); + return -9999; + } + + return 0; +} + +static int npy_to_fits_image_types(int npy_dtype, int *fits_img_type, + int *fits_datatype) { + + char mess[255]; + switch (npy_dtype) { + case NPY_UINT8: + *fits_img_type = BYTE_IMG; + *fits_datatype = TBYTE; + break; + case NPY_INT8: + *fits_img_type = SBYTE_IMG; + *fits_datatype = TSBYTE; + break; + case NPY_UINT16: + *fits_img_type = USHORT_IMG; + *fits_datatype = TUSHORT; + break; + case NPY_INT16: + *fits_img_type = SHORT_IMG; + *fits_datatype = TSHORT; + break; + + case NPY_UINT32: + //*fits_img_type = ULONG_IMG; + if (sizeof(unsigned short) == sizeof(npy_uint32)) { + *fits_img_type = USHORT_IMG; + *fits_datatype = TUSHORT; + } else if (sizeof(unsigned int) == sizeof(npy_uint32)) { + // there is no UINT_IMG, so use ULONG_IMG + *fits_img_type = ULONG_IMG; + *fits_datatype = TUINT; + } else if (sizeof(unsigned long) == sizeof(npy_uint32)) { + *fits_img_type = ULONG_IMG; + *fits_datatype = TULONG; + } else { + PyErr_SetString(PyExc_TypeError, + "could not determine 4 byte unsigned integer type"); + *fits_datatype = -9999; + return 1; + } + break; + + case NPY_INT32: + //*fits_img_type = LONG_IMG; + if (sizeof(short) == sizeof(npy_int32)) { + *fits_img_type = SHORT_IMG; + *fits_datatype = TINT; + } else if (sizeof(int) == sizeof(npy_int32)) { + // there is no UINT_IMG, so use ULONG_IMG + *fits_img_type = LONG_IMG; + *fits_datatype = TINT; + } else if (sizeof(long) == sizeof(npy_int32)) { + *fits_img_type = LONG_IMG; + *fits_datatype = TLONG; + } else { + PyErr_SetString(PyExc_TypeError, + "could not determine 4 byte integer type"); + *fits_datatype = -9999; + return 1; + } + break; + + case NPY_INT64: + if (sizeof(LONGLONG) == sizeof(npy_int64)) { + *fits_img_type = LONGLONG_IMG; + *fits_datatype = TLONGLONG; + } else if (sizeof(long) == sizeof(npy_int64)) { + *fits_img_type = LONG_IMG; + *fits_datatype = TLONG; + } else if (sizeof(int) == sizeof(npy_int64)) { + // there is no UINT_IMG, so use ULONG_IMG + *fits_img_type = LONG_IMG; + *fits_datatype = TINT; + } else if (sizeof(long long) == sizeof(npy_int64)) { + // we don't expect to get here + *fits_img_type = LONGLONG_IMG; + *fits_datatype = TLONGLONG; + } else { + PyErr_SetString(PyExc_TypeError, + "could not determine 8 byte integer type"); + *fits_datatype = -9999; + return 1; + } + break; + +#ifdef TULONGLONG + case NPY_UINT64: + if (sizeof(ULONGLONG) == sizeof(npy_uint64)) { + *fits_img_type = ULONGLONG_IMG; + *fits_datatype = TULONGLONG; + } else if (sizeof(unsigned long) == sizeof(npy_uint64)) { + *fits_img_type = ULONG_IMG; + *fits_datatype = TULONG; + } else if (sizeof(unsigned int) == sizeof(npy_uint64)) { + // there is no UINT_IMG, so use ULONG_IMG + *fits_img_type = ULONG_IMG; + *fits_datatype = TUINT; + } else if (sizeof(unsigned long long) == sizeof(npy_uint64)) { + // we don't expect to get here + *fits_img_type = ULONGLONG_IMG; + *fits_datatype = TULONGLONG; + } else { + PyErr_SetString(PyExc_TypeError, + "could not determine 8 byte unsigned integer type"); + *fits_datatype = -9999; + return 1; + } + break; +#else + case NPY_UINT64: + PyErr_SetString(PyExc_TypeError, + "Unsigned 8 byte integer images are not " + "supported by the FITS standard"); + return -9999; +#endif + + case NPY_FLOAT32: + *fits_img_type = FLOAT_IMG; + *fits_datatype = TFLOAT; + break; + case NPY_FLOAT64: + *fits_img_type = DOUBLE_IMG; + *fits_datatype = TDOUBLE; + break; + + default: + sprintf(mess, "Unsupported numpy image datatype %d", npy_dtype); + PyErr_SetString(PyExc_TypeError, mess); + *fits_datatype = -9999; + return 1; + break; + } + + return 0; +} + +/* + * this is really only for reading variable length columns since we should be + * able to just read the bytes for normal columns + */ +static int fits_to_npy_table_type(int fits_dtype, int *isvariable) { + + if (fits_dtype < 0) { + *isvariable = 1; + } else { + *isvariable = 0; + } + + switch (abs(fits_dtype)) { + case TBIT: + return NPY_INT8; + case TLOGICAL: // literal T or F stored as char + return NPY_INT8; + case TBYTE: + return NPY_UINT8; + case TSBYTE: + return NPY_INT8; + + case TUSHORT: + if (sizeof(unsigned short) == sizeof(npy_uint16)) { + return NPY_UINT16; + } else if (sizeof(unsigned short) == sizeof(npy_uint8)) { + return NPY_UINT8; + } else { + PyErr_SetString(PyExc_TypeError, + "could not determine numpy type for fits TUSHORT"); + return -9999; + } + case TSHORT: + if (sizeof(short) == sizeof(npy_int16)) { + return NPY_INT16; + } else if (sizeof(short) == sizeof(npy_int8)) { + return NPY_INT8; + } else { + PyErr_SetString(PyExc_TypeError, + "could not determine numpy type for fits TSHORT"); + return -9999; + } + + case TUINT: + if (sizeof(unsigned int) == sizeof(npy_uint32)) { + return NPY_UINT32; + } else if (sizeof(unsigned int) == sizeof(npy_uint64)) { + return NPY_UINT64; + } else if (sizeof(unsigned int) == sizeof(npy_uint16)) { + return NPY_UINT16; + } else { + PyErr_SetString(PyExc_TypeError, + "could not determine numpy type for fits TUINT"); + return -9999; + } + case TINT: + if (sizeof(int) == sizeof(npy_int32)) { + return NPY_INT32; + } else if (sizeof(int) == sizeof(npy_int64)) { + return NPY_INT64; + } else if (sizeof(int) == sizeof(npy_int16)) { + return NPY_INT16; + } else { + PyErr_SetString(PyExc_TypeError, + "could not determine numpy type for fits TINT"); + return -9999; + } + + case TULONG: + if (sizeof(unsigned long) == sizeof(npy_uint32)) { + return NPY_UINT32; + } else if (sizeof(unsigned long) == sizeof(npy_uint64)) { + return NPY_UINT64; + } else if (sizeof(unsigned long) == sizeof(npy_uint16)) { + return NPY_UINT16; + } else { + PyErr_SetString(PyExc_TypeError, + "could not determine numpy type for fits TULONG"); + return -9999; + } + case TLONG: + if (sizeof(unsigned long) == sizeof(npy_int32)) { + return NPY_INT32; + } else if (sizeof(unsigned long) == sizeof(npy_int64)) { + return NPY_INT64; + } else if (sizeof(long) == sizeof(npy_int16)) { + return NPY_INT16; + } else { + PyErr_SetString(PyExc_TypeError, + "could not determine numpy type for fits TLONG"); + return -9999; + } + +#ifdef TULONGLONG + case TULONGLONG: + if (sizeof(ULONGLONG) == sizeof(npy_uint64)) { + return NPY_UINT64; + } else if (sizeof(ULONGLONG) == sizeof(npy_uint32)) { + return NPY_UINT32; + } else if (sizeof(ULONGLONG) == sizeof(npy_uint16)) { + return NPY_UINT16; + } else { + PyErr_SetString( + PyExc_TypeError, + "could not determine numpy type for fits TULONGLONG"); + return -9999; + } +#endif + + case TLONGLONG: + if (sizeof(LONGLONG) == sizeof(npy_int64)) { + return NPY_INT64; + } else if (sizeof(LONGLONG) == sizeof(npy_int32)) { + return NPY_INT32; + } else if (sizeof(LONGLONG) == sizeof(npy_int16)) { + return NPY_INT16; + } else { + PyErr_SetString( + PyExc_TypeError, + "could not determine numpy type for fits TLONGLONG"); + return -9999; + } + + case TFLOAT: + return NPY_FLOAT32; + case TDOUBLE: + return NPY_FLOAT64; + + case TCOMPLEX: + return NPY_COMPLEX64; + case TDBLCOMPLEX: + return NPY_COMPLEX128; + + case TSTRING: + return NPY_STRING; + + default: + PyErr_Format(PyExc_TypeError, "Unsupported FITS table datatype %d", + fits_dtype); + return -9999; + } + + return 0; +} + +static int create_empty_hdu(struct PyFITSObject *self) { + int status = 0; + int bitpix = SHORT_IMG; + int naxis = 0; + long *naxes = NULL; + if (fits_create_img(self->fits, bitpix, naxis, naxes, &status)) { + set_ioerr_string_from_status(status, self); + return 1; + } + + return 0; +} + +// follows fits convention that return value is true +// for failure +// +// exception strings are set internally +// +// length checking should happen in python +// +// note tile dims are written reverse order since +// python orders C and fits orders Fortran +static int set_compression(struct PyFITSObject *self, fitsfile *fits, + int comptype, PyObject *tile_dims_obj, int *status) { + + npy_int64 *tile_dims_py = NULL; + long *tile_dims_fits = NULL; + npy_intp ndims = 0, i = 0; + + // can be NOCOMPRESS (0) + if (fits_set_compression_type(fits, comptype, status)) { + set_ioerr_string_from_status(*status, self); + goto _set_compression_bail; + return 1; + } + + if (tile_dims_obj != Py_None) { + + tile_dims_py = + get_int64_from_array((PyArrayObject *)tile_dims_obj, &ndims); + if (tile_dims_py == NULL) { + *status = 1; + } else { + tile_dims_fits = calloc(ndims, sizeof(long)); + if (!tile_dims_fits) { + PyErr_Format(PyExc_MemoryError, "failed to allocate %ld longs", + ndims); + goto _set_compression_bail; + } + + for (i = 0; i < ndims; i++) { + tile_dims_fits[ndims - i - 1] = tile_dims_py[i]; + } + + fits_set_tile_dim(fits, ndims, tile_dims_fits, status); + + free(tile_dims_fits); + tile_dims_fits = NULL; + } + } + +_set_compression_bail: + return *status; +} + +static int pyarray_get_ndim(PyArrayObject *arr) { return arr->nd; } + +/* + Create an image extension, possible writing data as well. + + We allow creating from dimensions rather than from the input image shape, + writing into the HDU later + + It is useful to create the extension first so we can write keywords into the + header before adding data. This avoids moving the data if the header grows + too large. + + However, on distributed file systems it can be more efficient to write + the data at this time due to slowness with updating the file in place. + + */ + +static PyObject *PyFITSObject_create_image_hdu(struct PyFITSObject *self, + PyObject *args, PyObject *kwds) { + int ndims = 0; + long *dims = NULL; + int image_datatype = 0; // fits type for image, AKA bitpix + int datatype = 0; // type for the data we entered + + PyObject *array_obj = NULL, *dims_obj = NULL; + PyArrayObject *array = NULL, *dims_array = NULL; + PyObject *comptype_obj = NULL; + PyObject *tile_dims_obj = NULL; + PyObject *qlevel_obj = NULL; + PyObject *qmethod_obj = NULL; + PyObject *dither_seed_obj = NULL; + PyObject *hcomp_scale_obj = NULL; + PyObject *hcomp_smooth_obj = NULL; + + int npy_dtype = 0, nkeys = 0, write_data = 0; + int i = 0; + int status = 0; + int py_status = 0; + + char *extname = NULL; + int extver = 0; + float qlevel = 0; + int qmethod = 0; + int dither_seed = 0; + float hcomp_scale = 0; + int hcomp_smooth = 0; + int comptype = 0; + int orig_dither_seed; + int got_dither_seed = 0; + int any_nan = 0; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_ValueError, "fits file is NULL"); + return NULL; + } + + // We do allow overriding the dither seed and then putting it back + // Luckily cfitsio has public APIs for that so this should be robust + if (fits_get_dither_seed(self->fits, &orig_dither_seed, &status)) { + set_ioerr_string_from_status(status, self); + goto create_image_hdu_cleanup; + } else { + // tell the code to put back the dither seed later + got_dither_seed = 1; + } + + static char *kwlist[] = { + "array", "nkeys", "dims", "comptype", "tile_dims", + "qlevel", "qmethod", "dither_seed", "hcomp_scale", "hcomp_smooth", + "extname", "extver", "any_nan", NULL, + }; + if (!PyArg_ParseTupleAndKeywords( + args, kwds, "Oi|OOOOOOOOsii", kwlist, &array_obj, &nkeys, &dims_obj, + &comptype_obj, &tile_dims_obj, &qlevel_obj, &qmethod_obj, + &dither_seed_obj, &hcomp_scale_obj, &hcomp_smooth_obj, &extname, + &extver, &any_nan)) { + py_status = 1; + goto create_image_hdu_cleanup; + } + + if (array_obj == Py_None) { + if (create_empty_hdu(self)) { + // error string is set in create_empty_hdu + return NULL; + } + } else { + array = (PyArrayObject *)array_obj; + if (!PyArray_Check(array)) { + PyErr_SetString(PyExc_TypeError, "input must be an array."); + py_status = 1; + goto create_image_hdu_cleanup; + } + + npy_dtype = PyArray_TYPE(array); + if (npy_to_fits_image_types(npy_dtype, &image_datatype, &datatype)) { + py_status = 1; + goto create_image_hdu_cleanup; + } + + if (PyArray_Check(dims_obj)) { + // get dims from input, which must be of type 'i8' + // this means we are not writing the array that was input, + // it is only used to determine the data type + dims_array = (PyArrayObject *)dims_obj; + + npy_int64 *tptr = NULL, tmp = 0; + ndims = PyArray_SIZE(dims_array); + dims = calloc(ndims, sizeof(long)); + for (i = 0; i < ndims; i++) { + tptr = (npy_int64 *)PyArray_GETPTR1(dims_array, i); + tmp = *tptr; + dims[ndims - i - 1] = (long)tmp; + } + write_data = 0; + } else { + // we get the dimensions from the array, which means we are going + // to write it as well + ndims = pyarray_get_ndim(array); + dims = calloc(ndims, sizeof(long)); + for (i = 0; i < ndims; i++) { + dims[ndims - i - 1] = PyArray_DIM(array, i); + } + write_data = 1; + } + + if (comptype_obj != Py_None) { + comptype = (int)PyLong_AsLong(comptype_obj); + } else { + // get what the code is going to use just in case we override the + // HCOMPRESS options + comptype = self->fits->Fptr->request_compress_type; + } + + if (qlevel_obj != Py_None) { + qlevel = (float)PyFloat_AsDouble(qlevel_obj); + } + + if (qmethod_obj != Py_None) { + qmethod = (int)PyLong_AsLong(qmethod_obj); + } + + if (dither_seed_obj != Py_None) { + dither_seed = (int)PyLong_AsLong(dither_seed_obj); + } + + if (hcomp_scale_obj != Py_None) { + hcomp_scale = (float)PyFloat_AsDouble(hcomp_scale_obj); + } + + if (hcomp_smooth_obj != Py_None) { + hcomp_smooth = (int)PyLong_AsLong(hcomp_smooth_obj); + } + + if ((comptype_obj != Py_None) || (tile_dims_obj != Py_None)) { + // exception strings are set internally + if (set_compression(self, self->fits, comptype, tile_dims_obj, + &status)) { + goto create_image_hdu_cleanup; + } + } + + if (qlevel_obj != Py_None) { + if (fits_set_quantize_level(self->fits, qlevel, &status)) { + set_ioerr_string_from_status(status, self); + goto create_image_hdu_cleanup; + } + } + + if (qmethod_obj != Py_None) { + if (fits_set_quantize_method(self->fits, qmethod, &status)) { + set_ioerr_string_from_status(status, self); + goto create_image_hdu_cleanup; + } + } + + if (dither_seed_obj != Py_None) { + if (fits_set_dither_seed(self->fits, dither_seed, &status)) { + set_ioerr_string_from_status(status, self); + goto create_image_hdu_cleanup; + } + } + + if (comptype == HCOMPRESS_1) { + if (hcomp_scale_obj != Py_None) { + if (fits_set_hcomp_scale(self->fits, hcomp_scale, &status)) { + set_ioerr_string_from_status(status, self); + goto create_image_hdu_cleanup; + } + } + + if (hcomp_smooth_obj != Py_None) { + if (fits_set_hcomp_smooth(self->fits, hcomp_smooth, &status)) { + set_ioerr_string_from_status(status, self); + goto create_image_hdu_cleanup; + } + } + } + + if (fits_create_img(self->fits, image_datatype, ndims, dims, &status)) { + set_ioerr_string_from_status(status, self); + goto create_image_hdu_cleanup; + } + } + if (extname != NULL) { + if (strlen(extname) > 0) { + + // comments are NULL + if (fits_update_key_str(self->fits, "EXTNAME", extname, NULL, + &status)) { + set_ioerr_string_from_status(status, self); + goto create_image_hdu_cleanup; + } + if (extver > 0) { + if (fits_update_key_lng(self->fits, "EXTVER", (LONGLONG)extver, + NULL, &status)) { + set_ioerr_string_from_status(status, self); + goto create_image_hdu_cleanup; + } + } + } + } + + if (nkeys > 0) { + if (fits_set_hdrsize(self->fits, nkeys, &status)) { + set_ioerr_string_from_status(status, self); + goto create_image_hdu_cleanup; + } + } + + if (write_data) { + int firstpixel = 1; + LONGLONG nelements = 0; + void *data = NULL; + nelements = PyArray_SIZE(array); + data = PyArray_DATA(array); + if (any_nan) { + float fnullval = INFINITY; + double dnullval = INFINITY; + void *nullval_ptr; + + if (datatype == TFLOAT) { + nullval_ptr = (void *)(&fnullval); + } else if (datatype == TDOUBLE) { + nullval_ptr = (void *)(&dnullval); + } else { + nullval_ptr = NULL; + } + + if (fits_write_imgnull(self->fits, datatype, firstpixel, nelements, + data, nullval_ptr, &status)) { + set_ioerr_string_from_status(status, self); + goto create_image_hdu_cleanup; + } + } else { + if (fits_write_img(self->fits, datatype, firstpixel, nelements, + data, &status)) { + set_ioerr_string_from_status(status, self); + goto create_image_hdu_cleanup; + } + } + } + + // this flushes all buffers + if (fits_flush_file(self->fits, &status)) { + set_ioerr_string_from_status(status, self); + goto create_image_hdu_cleanup; + } + +create_image_hdu_cleanup: + free(dims); + dims = NULL; + if ((comptype_obj != Py_None) || (tile_dims_obj != Py_None) || + (qlevel_obj != Py_None) || (qmethod_obj != Py_None) || + (hcomp_scale_obj != Py_None) || (hcomp_smooth_obj != Py_None)) { + if (fits_unset_compression_request(self->fits, &status)) { + set_ioerr_string_from_status(status, self); + } + } + + // put back the dither seed after we reset any compression params + if (got_dither_seed == 1) { + if (fits_set_dither_seed(self->fits, orig_dither_seed, &status)) { + set_ioerr_string_from_status(status, self); + } + } + + if (status != 0 || py_status != 0) { + return NULL; + } + + Py_RETURN_NONE; +} + +// reshape the image to specified dims +// the input array must be of type int64 +static PyObject *PyFITSObject_reshape_image(struct PyFITSObject *self, + PyObject *args) { + + int status = 0; + int hdunum = 0, hdutype = 0; + PyObject *dims_obj = NULL; + PyArrayObject *dims_array = NULL; + LONGLONG dims[CFITSIO_MAX_ARRAY_DIMS] = {0}; + LONGLONG dims_orig[CFITSIO_MAX_ARRAY_DIMS] = {0}; + int ndims = 0, ndims_orig = 0; + npy_int64 dim = 0; + npy_intp i = 0; + int bitpix = 0, maxdim = CFITSIO_MAX_ARRAY_DIMS; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_ValueError, "fits file is NULL"); + return NULL; + } + + if (!PyArg_ParseTuple(args, (char *)"iO", &hdunum, &dims_obj)) { + return NULL; + } + dims_array = (PyArrayObject *)dims_obj; + + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // existing image params, just to get bitpix + if (fits_get_img_paramll(self->fits, maxdim, &bitpix, &ndims_orig, + dims_orig, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + ndims = PyArray_SIZE(dims_array); + for (i = 0; i < ndims; i++) { + dim = *(npy_int64 *)PyArray_GETPTR1(dims_array, i); + dims[i] = (LONGLONG)dim; + } + + if (fits_resize_imgll(self->fits, bitpix, ndims, dims, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +// write the image to an existing HDU created using create_image_hdu +// dims are not checked +static PyObject *PyFITSObject_write_image(struct PyFITSObject *self, + PyObject *args) { + int hdunum = 0; + int hdutype = 0; + LONGLONG nelements = 1; + PY_LONG_LONG firstpixel_py = 0; + LONGLONG firstpixel = 0; + PY_LONG_LONG any_nan_py = 0; + LONGLONG any_nan = 0; + int image_datatype = 0; // fits type for image, AKA bitpix + int datatype = 0; // type for the data we entered + + PyObject *array_obj = NULL; + PyArrayObject *array = NULL; + void *data = NULL; + int npy_dtype = 0; + int status = 0; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_ValueError, "fits file is NULL"); + return NULL; + } + + if (!PyArg_ParseTuple(args, (char *)"iOLL", &hdunum, &array_obj, + &firstpixel_py, &any_nan_py)) { + return NULL; + } + array = (PyArrayObject *)array_obj; + + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (!PyArray_Check(array)) { + PyErr_SetString(PyExc_TypeError, "input must be an array."); + return NULL; + } + + npy_dtype = PyArray_TYPE(array); + if (npy_to_fits_image_types(npy_dtype, &image_datatype, &datatype)) { + return NULL; + } + + data = PyArray_DATA(array); + nelements = PyArray_SIZE(array); + firstpixel = (LONGLONG)firstpixel_py; + any_nan = (LONGLONG)any_nan_py; + + if (any_nan_py) { + float fnullval = INFINITY; + double dnullval = INFINITY; + void *nullval_ptr; + + if (datatype == TFLOAT) { + nullval_ptr = (void *)(&fnullval); + } else if (datatype == TDOUBLE) { + nullval_ptr = (void *)(&dnullval); + } else { + nullval_ptr = NULL; + } + + if (fits_write_imgnull(self->fits, datatype, firstpixel, nelements, + data, nullval_ptr, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + } else { + if (fits_write_img(self->fits, datatype, firstpixel, nelements, data, + &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + } + // this is a full file close and reopen + if (fits_flush_file(self->fits, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +// write a rectangular subset to the image in an existing HDU +// created using create_image_hdu +// dims are not checked +static PyObject *PyFITSObject_write_subset(struct PyFITSObject *self, + PyObject *args) { + int hdunum = 0; + int hdutype = 0; + int image_datatype = 0; // fits type for image, AKA bitpix + int datatype = 0; // type for the data we entered + + long fpixel[CFITSIO_MAX_ARRAY_DIMS] = {0}; + long lpixel[CFITSIO_MAX_ARRAY_DIMS] = {0}; + npy_int64 pixval = 0; + npy_intp i = 0; + + PyObject *array_obj = NULL; + PyObject *firstpixel_obj = NULL; + PyObject *lastpixel_obj = NULL; + PyArrayObject *array = NULL; + PyArrayObject *firstpixel = NULL; + PyArrayObject *lastpixel = NULL; + void *data = NULL; + int npy_dtype = 0, ndims; + int status = 0; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_ValueError, "fits file is NULL"); + return NULL; + } + + if (!PyArg_ParseTuple(args, (char *)"iOOO", &hdunum, &array_obj, + &firstpixel_obj, &lastpixel_obj)) { + return NULL; + } + + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + array = (PyArrayObject *)array_obj; + if (!PyArray_Check(array)) { + PyErr_SetString(PyExc_TypeError, "input data must be an array."); + return NULL; + } + npy_dtype = PyArray_TYPE(array); + if (npy_to_fits_image_types(npy_dtype, &image_datatype, &datatype)) { + return NULL; + } + + firstpixel = (PyArrayObject *)firstpixel_obj; + if (!PyArray_Check(firstpixel)) { + PyErr_SetString(PyExc_TypeError, "input firstpixel must be an array."); + return NULL; + } + + lastpixel = (PyArrayObject *)lastpixel_obj; + if (!PyArray_Check(lastpixel)) { + PyErr_SetString(PyExc_TypeError, "input lastpixel must be an array."); + return NULL; + } + + if (PyArray_SIZE(firstpixel) != PyArray_SIZE(lastpixel)) { + PyErr_SetString( + PyExc_TypeError, + "input firstpixel and lastpixel must have the same size."); + return NULL; + } + + ndims = PyArray_SIZE(firstpixel); + for (i = 0; i < ndims; i++) { + pixval = *(npy_int64 *)PyArray_GETPTR1(firstpixel, i); + fpixel[i] = (long)pixval; + + pixval = *(npy_int64 *)PyArray_GETPTR1(lastpixel, i); + lpixel[i] = (long)pixval; + } + + data = PyArray_DATA(array); + if (fits_write_subset(self->fits, datatype, fpixel, lpixel, data, + &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // this is a full file close and reopen + if (fits_flush_file(self->fits, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +/* + * Write tdims from the list. The list must be the expected length. + * Entries must be strings or None; if None the tdim is not written. + * + * The keys are written as TDIM{colnum} + */ +static int add_tdims_from_listobj(struct PyFITSObject *self, fitsfile *fits, + PyObject *tdimObj, int ncols) { + int status = 0, i = 0; + size_t size = 0; + char keyname[20]; + int colnum = 0; + PyObject *tmp = NULL; + char *tdim = NULL; + + if (tdimObj == NULL || tdimObj == Py_None) { + // it is ok for it to be empty + return 0; + } + + if (!PyList_Check(tdimObj)) { + PyErr_SetString(PyExc_ValueError, "Expected a list for tdims"); + return 1; + } + + size = PyList_Size(tdimObj); + if (size != (size_t)ncols) { + PyErr_Format(PyExc_ValueError, + "Expected %d elements in tdims list, got %ld", ncols, + size); + return 1; + } + + for (i = 0; i < ncols; i++) { + colnum = i + 1; + tmp = PyList_GetItem(tdimObj, i); + if (tmp != Py_None) { + if (!is_python_string(tmp)) { + PyErr_SetString(PyExc_ValueError, + "Expected only strings or None for tdim"); + return 1; + } + + sprintf(keyname, "TDIM%d", colnum); + + tdim = get_object_as_string(tmp); + fits_write_key(fits, TSTRING, keyname, tdim, NULL, &status); + free(tdim); + + if (status) { + set_ioerr_string_from_status(status, self); + return 1; + } + } + } + + return 0; +} + +// create a new table structure. No physical rows are added yet. +static PyObject *PyFITSObject_create_table_hdu(struct PyFITSObject *self, + PyObject *args, PyObject *kwds) { + int status = 0; + int table_type = 0, nkeys = 0; + int nfields = 0; + LONGLONG nrows = 0; // start empty + + static char *kwlist[] = {"table_type", "nkeys", "ttyp", + "tform", "tunit", "tdim", + "extname", "extver", NULL}; + // these are all strings + PyObject *ttypObj = NULL; + PyObject *tformObj = NULL; + PyObject *tunitObj = NULL; // optional + PyObject *tdimObj = NULL; // optional + + // these must be freed + struct stringlist *ttyp = NULL; + struct stringlist *tform = NULL; + struct stringlist *tunit = NULL; + // struct stringlist* tdim=stringlist_new(); + char *extname = NULL; + char *extname_use = NULL; + int extver = 0; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "iiOO|OOsi", kwlist, + &table_type, &nkeys, &ttypObj, &tformObj, + &tunitObj, &tdimObj, &extname, &extver)) { + return NULL; + } + + ttyp = stringlist_new(); + tform = stringlist_new(); + tunit = stringlist_new(); + if (stringlist_addfrom_listobj(ttyp, ttypObj, "names")) { + status = 99; + goto create_table_cleanup; + } + + if (stringlist_addfrom_listobj(tform, tformObj, "formats")) { + status = 99; + goto create_table_cleanup; + } + + if (tunitObj != NULL && tunitObj != Py_None) { + if (stringlist_addfrom_listobj(tunit, tunitObj, "units")) { + status = 99; + goto create_table_cleanup; + } + } + + if (extname != NULL) { + if (strlen(extname) > 0) { + extname_use = extname; + } + } + nfields = ttyp->size; + if (fits_create_tbl(self->fits, table_type, nrows, nfields, ttyp->data, + tform->data, tunit->data, extname_use, &status)) { + set_ioerr_string_from_status(status, self); + goto create_table_cleanup; + } + + if (add_tdims_from_listobj(self, self->fits, tdimObj, nfields)) { + status = 99; + goto create_table_cleanup; + } + + if (extname_use != NULL) { + if (extver > 0) { + + if (fits_update_key_lng(self->fits, "EXTVER", (LONGLONG)extver, + NULL, &status)) { + set_ioerr_string_from_status(status, self); + goto create_table_cleanup; + } + } + } + + if (nkeys > 0) { + if (fits_set_hdrsize(self->fits, nkeys, &status)) { + set_ioerr_string_from_status(status, self); + goto create_table_cleanup; + } + } + + // this does a full close and reopen + if (fits_flush_file(self->fits, &status)) { + set_ioerr_string_from_status(status, self); + goto create_table_cleanup; + } + +create_table_cleanup: + ttyp = stringlist_delete(ttyp); + tform = stringlist_delete(tform); + tunit = stringlist_delete(tunit); + // tdim = stringlist_delete(tdim); + + if (status != 0) { + return NULL; + } + Py_RETURN_NONE; +} + +// create a new table structure. No physical rows are added yet. +static PyObject *PyFITSObject_insert_col(struct PyFITSObject *self, + PyObject *args, PyObject *kwds) { + int status = 0; + int hdunum = 0; + int colnum = 0; + + int hdutype = 0; + + static char *kwlist[] = {"hdunum", "colnum", "ttyp", "tform", "tdim", NULL}; + // these are all strings + char *ttype = NULL; // field name + char *tform = NULL; // format + PyObject *tdimObj = NULL; // optional, a list of len 1 + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "iiss|O", kwlist, &hdunum, + &colnum, &ttype, &tform, &tdimObj)) { + return NULL; + } + + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (fits_insert_col(self->fits, colnum, ttype, tform, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // OK if dims are not sent + if (tdimObj != NULL && tdimObj != Py_None) { + PyObject *tmp = NULL; + char *tdim = NULL; + char keyname[20]; + + sprintf(keyname, "TDIM%d", colnum); + tmp = PyList_GetItem(tdimObj, 0); + + tdim = get_object_as_string(tmp); + fits_write_key(self->fits, TSTRING, keyname, tdim, NULL, &status); + free(tdim); + + if (status) { + set_ioerr_string_from_status(status, self); + return NULL; + } + } + + // this does a full close and reopen + if (fits_flush_file(self->fits, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +// No error checking performed here +static int write_string_column( + struct PyFITSObject *self, fitsfile *fits, /* I - FITS file pointer */ + int hdutype, /* I - one of ASCII_TABLE or BINARY_TABLE */ + int colnum, /* I - number of column to write (1 = 1st col) */ + LONGLONG firstrow, /* I - first row to write (1 = 1st row) */ + LONGLONG firstelem, /* I - first vector element to write (1 = 1st) */ + LONGLONG nelem, /* I - number of strings to write */ + char *data, int *status) { /* IO - error status */ + + if (hdutype == ASCII_TBL || CFITSIO_MAJOR < 4) { + LONGLONG i = 0; + LONGLONG twidth = 0; + // need to create a char** representation of the data, just point back + // into the data array at string width offsets. the fits_write_col_str + // takes care of skipping between fields. + char *cdata = NULL; + char **strdata = NULL; + + // using struct def here, could cause problems + twidth = fits->Fptr->tableptr[colnum - 1].twidth; + + strdata = malloc(nelem * sizeof(char *)); + if (strdata == NULL) { + PyErr_SetString(PyExc_MemoryError, + "could not allocate temporary string pointers"); + *status = 99; + return 1; + } + cdata = (char *)data; + for (i = 0; i < nelem; i++) { + strdata[i] = &cdata[twidth * i]; + } + + if (fits_write_col_str(fits, colnum, firstrow, firstelem, nelem, + strdata, status)) { + set_ioerr_string_from_status(*status, self); + free(strdata); + return 1; + } + + free(strdata); + } else { // BINARY_TABLE + // for string columns in binary tables, cfitsio has a special mode + // where you can write the the bytes directly. let's do that to + // preserve nulls and prevent + // cfitsio from padding with spaces + + LONGLONG nelem_byt; + unsigned char *strdata_byt; + + // using struct def here, could cause problems + nelem_byt = nelem * (fits->Fptr->tableptr[colnum - 1].twidth); + strdata_byt = (unsigned char *)data; + + if (fits_write_col_byt(fits, colnum, firstrow, firstelem, nelem_byt, + strdata_byt, status)) { + set_ioerr_string_from_status(*status, self); + return 1; + } + } + + return 0; +} + +// write a column, starting at firstrow. On the python side, the firstrow kwd +// should default to 1. +// You can append rows using firstrow = nrows+1 +/* +static PyObject * +PyFITSObject_write_column(struct PyFITSObject* self, PyObject* args, PyObject* +kwds) { int status=0; int hdunum=0; int hdutype=0; int colnum=0; int +write_bitcols=0; PyObject* array=NULL; + + void* data=NULL; + PY_LONG_LONG firstrow_py=0; + LONGLONG firstrow=1; + LONGLONG firstelem=1; + LONGLONG nelem=0; + int npy_dtype=0; + int fits_dtype=0; + + static char *kwlist[] = +{"hdunum","colnum","array","firstrow","write_bitcols", NULL}; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_ValueError, "fits file is NULL"); + return NULL; + } + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "iiOLi", + kwlist, &hdunum, &colnum, &array, +&firstrow_py, &write_bitcols)) { return NULL; + } + firstrow = (LONGLONG) firstrow_py; + + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + + if (!PyArray_Check(array)) { + PyErr_SetString(PyExc_ValueError,"only arrays can be written to +columns"); return NULL; + } + + npy_dtype = PyArray_TYPE(array); + fits_dtype = npy_to_fits_table_type(npy_dtype, write_bitcols); + if (fits_dtype == -9999) { + return NULL; + } + if (fits_dtype == TLOGICAL) { + int tstatus=0, ttype=0; + LONGLONG trepeat=0, twidth=0; + // if the column exists and is declared TBIT we will write + // that way instead + if (fits_get_coltypell(self->fits, colnum, + &ttype, &trepeat, &twidth, &tstatus)==0) { + // if we don't get here its because the column doesn't exist + // yet and that's ok + if (ttype==TBIT) { + fits_dtype=TBIT; + } + } + } + + + + data = PyArray_DATA(array); + nelem = PyArray_SIZE(array); + + if (fits_dtype == TSTRING) { + + // this is my wrapper for strings + if (write_string_column(self, self->fits, colnum, firstrow, firstelem, +nelem, data, &status)) { set_ioerr_string_from_status(status, self); return +NULL; + } + } else if (fits_dtype == TBIT) { + if (fits_write_col_bit(self->fits, colnum, firstrow, firstelem, nelem, +data, &status)) { set_ioerr_string_from_status(status, self); return NULL; + } + } else { + if( fits_write_col(self->fits, fits_dtype, colnum, firstrow, firstelem, +nelem, data, &status)) { set_ioerr_string_from_status(status, self); return +NULL; + } + } + + // this is a full file close and reopen + if (fits_flush_file(self->fits, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + + Py_RETURN_NONE; +} +*/ + +static PyObject *PyFITSObject_write_columns(struct PyFITSObject *self, + PyObject *args, PyObject *kwds) { + int status = 0; + int hdunum = 0; + int hdutype = 0; + int write_bitcols = 0; + // void **data_ptrs=NULL; + PyObject *colnum_list = NULL; + PyObject *array_list = NULL; + PyObject *tmp_obj = NULL; + PyArrayObject *tmp_array = NULL; + + Py_ssize_t ncols = 0; + + void *data = NULL; + PY_LONG_LONG firstrow_py = 0; + LONGLONG firstrow = 1, thisrow = 0; + LONGLONG firstelem = 1; + LONGLONG nelem = 0; + LONGLONG *nperrow = NULL; + int npy_dtype = 0; + int *fits_dtypes = NULL; + int *is_string = NULL, *colnums = NULL; + void **array_ptrs = NULL; + + npy_intp ndim = 0, *dims = NULL; + Py_ssize_t irow = 0, icol = 0, j = 0; + ; + + static char *kwlist[] = {"hdunum", "colnums", "arraylist", + "firstrow", "write_bitcols", NULL}; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_ValueError, "fits file is NULL"); + return NULL; + } + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "iOOLi", kwlist, &hdunum, + &colnum_list, &array_list, &firstrow_py, + &write_bitcols)) { + return NULL; + } + firstrow = (LONGLONG)firstrow_py; + + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (!PyList_Check(colnum_list)) { + PyErr_SetString(PyExc_ValueError, "colnums must be a list"); + return NULL; + } + if (!PyList_Check(array_list)) { + PyErr_SetString(PyExc_ValueError, "colnums must be a list"); + return NULL; + } + ncols = PyList_Size(colnum_list); + if (ncols == 0) { + goto _fitsio_pywrap_write_columns_bail; + } + if (ncols != PyList_Size(array_list)) { + PyErr_Format(PyExc_ValueError, + "colnum and array lists not same size: %ld/%ld", ncols, + PyList_Size(array_list)); + } + + // from here on we'll have some temporary arrays we have to free + is_string = calloc(ncols, sizeof(int)); + colnums = calloc(ncols, sizeof(int)); + array_ptrs = calloc(ncols, sizeof(void *)); + nperrow = calloc(ncols, sizeof(LONGLONG)); + fits_dtypes = calloc(ncols, sizeof(int)); + + for (icol = 0; icol < ncols; icol++) { + + tmp_obj = PyList_GetItem(colnum_list, icol); +#if PY_MAJOR_VERSION >= 3 + colnums[icol] = 1 + (int)PyLong_AsLong(tmp_obj); +#else + colnums[icol] = 1 + (int)PyInt_AsLong(tmp_obj); +#endif + + tmp_array = (PyArrayObject *)PyList_GetItem(array_list, icol); + npy_dtype = PyArray_TYPE(tmp_array); + + fits_dtypes[icol] = npy_to_fits_table_type(npy_dtype, write_bitcols); + if (fits_dtypes[icol] == -9999) { + status = 1; + goto _fitsio_pywrap_write_columns_bail; + } + if (fits_dtypes[icol] == TLOGICAL) { + int tstatus = 0, ttype = 0; + LONGLONG trepeat = 0, twidth = 0; + // if the column exists and is declared TBIT we will write + // that way instead + if (fits_get_coltypell(self->fits, colnums[icol], &ttype, &trepeat, + &twidth, &tstatus) == 0) { + // if we don't get here its because the column doesn't exist + // yet and that's ok + if (ttype == TBIT) { + fits_dtypes[icol] = TBIT; + } + } + } + + if (fits_dtypes[icol] == TSTRING) { + is_string[icol] = 1; + } + ndim = PyArray_NDIM(tmp_array); + dims = PyArray_DIMS(tmp_array); + if (icol == 0) { + nelem = dims[0]; + } else { + if (dims[0] != nelem) { + PyErr_Format(PyExc_ValueError, + "not all entries have same row count, " + "%lld/%ld", + nelem, dims[0]); + status = 1; + goto _fitsio_pywrap_write_columns_bail; + } + } + + array_ptrs[icol] = tmp_array; + + nperrow[icol] = 1; + for (j = 1; j < ndim; j++) { + nperrow[icol] *= dims[j]; + } + } + + for (irow = 0; irow < nelem; irow++) { + thisrow = firstrow + irow; + for (icol = 0; icol < ncols; icol++) { + data = PyArray_GETPTR1(array_ptrs[icol], irow); + if (is_string[icol]) { + if (write_string_column(self, self->fits, hdutype, + colnums[icol], thisrow, firstelem, + nperrow[icol], (char *)data, &status)) { + set_ioerr_string_from_status(status, self); + goto _fitsio_pywrap_write_columns_bail; + } + + } else if (fits_dtypes[icol] == TBIT) { + if (fits_write_col_bit(self->fits, colnums[icol], thisrow, + firstelem, nperrow[icol], data, + &status)) { + set_ioerr_string_from_status(status, self); + goto _fitsio_pywrap_write_columns_bail; + } + } else { + if (fits_write_col(self->fits, fits_dtypes[icol], colnums[icol], + thisrow, firstelem, nperrow[icol], data, + &status)) { + set_ioerr_string_from_status(status, self); + goto _fitsio_pywrap_write_columns_bail; + } + } + } + } + /* + nelem = PyArray_SIZE(array); + + if (fits_dtype == TSTRING) { + + // this is my wrapper for strings + if (write_string_column(self, self->fits, colnum, firstrow, firstelem, + nelem, data, &status)) { set_ioerr_string_from_status(status, self); return + NULL; + } + + } else { + if( fits_write_col(self->fits, fits_dtype, colnum, firstrow, firstelem, + nelem, data, &status)) { set_ioerr_string_from_status(status, self); return + NULL; + } + } + + // this is a full file close and reopen + if (fits_flush_file(self->fits, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + */ + +_fitsio_pywrap_write_columns_bail: + free(is_string); + is_string = NULL; + free(colnums); + colnums = NULL; + free(array_ptrs); + array_ptrs = NULL; + free(nperrow); + nperrow = NULL; + free(fits_dtypes); + fits_dtypes = NULL; + if (status != 0) { + return NULL; + } + Py_RETURN_NONE; +} + +// No error checking performed here +static int write_var_string_column( + fitsfile *fits, /* I - FITS file pointer */ + int colnum, /* I - number of column to write (1 = 1st col) */ + LONGLONG firstrow, /* I - first row to write (1 = 1st row) */ + PyArrayObject *array, int *status) { /* IO - error status */ + + LONGLONG firstelem = 1; // ignored + LONGLONG nelem = 1; // ignored + npy_intp nrows = 0; + npy_intp i = 0; + char *ptr = NULL; + int res = 0; + + PyObject *el = NULL; + char *strdata = NULL; + char *strarr[1]; + + nrows = PyArray_SIZE(array); + for (i = 0; i < nrows; i++) { + ptr = PyArray_GetPtr(array, &i); + el = PyArray_GETITEM(array, ptr); + + strdata = get_object_as_string(el); + + // just a container + strarr[0] = strdata; + res = fits_write_col_str(fits, colnum, firstrow + i, firstelem, nelem, + strarr, status); + + free(strdata); + if (res > 0) { + goto write_var_string_column_cleanup; + } + } + +write_var_string_column_cleanup: + + if (*status > 0) { + return 1; + } + + return 0; +} + +/* + * No error checking performed here + */ +static int write_var_num_column( + struct PyFITSObject *self, fitsfile *fits, /* I - FITS file pointer */ + int colnum, /* I - number of column to write (1 = 1st col) */ + LONGLONG firstrow, /* I - first row to write (1 = 1st row) */ + int fits_dtype, PyArrayObject *array, int *status) { /* IO - error status */ + + LONGLONG firstelem = 1; + npy_intp nelem = 0; + npy_intp nrows = 0; + npy_intp i = 0; + PyObject *el = NULL; + PyArrayObject *el_array = NULL; + void *data = NULL; + void *ptr = NULL; + + int npy_dtype = 0, isvariable = 0; + + int mindepth = 1, maxdepth = 0; + PyObject *context = NULL; + int requirements = NPY_ARRAY_C_CONTIGUOUS | NPY_ARRAY_ALIGNED | + NPY_ARRAY_NOTSWAPPED | NPY_ARRAY_ELEMENTSTRIDES; + + int res = 0; + + npy_dtype = fits_to_npy_table_type(fits_dtype, &isvariable); + + nrows = PyArray_SIZE(array); + for (i = 0; i < nrows; i++) { + ptr = PyArray_GetPtr((PyArrayObject *)array, &i); + el = PyArray_GETITEM(array, ptr); + + // a copy is only made if needed + el_array = (PyArrayObject *)PyArray_CheckFromAny( + el, PyArray_DescrFromType(npy_dtype), mindepth, maxdepth, + requirements, context); + if (el_array == NULL) { + // error message will already be set + return 1; + } + + nelem = PyArray_SIZE(el_array); + data = PyArray_DATA(el_array); + res = fits_write_col(fits, abs(fits_dtype), colnum, firstrow + i, + firstelem, (LONGLONG)nelem, data, status); + Py_XDECREF(el_array); + + if (res > 0) { + set_ioerr_string_from_status(*status, self); + return 1; + } + } + + return 0; +} + +/* + * write a variable length column, starting at firstrow. On the python side, + * the firstrow kwd should default to 1. You can append rows using firstrow = + * nrows+1 + * + * The input array should be of type NPY_OBJECT, and the elements + * should be either all strings or numpy arrays of the same type + */ + +static PyObject *PyFITSObject_write_var_column(struct PyFITSObject *self, + PyObject *args, PyObject *kwds) { + int status = 0; + int hdunum = 0; + int hdutype = 0; + int colnum = 0; + PyObject *array_obj = NULL; + PyArrayObject *array = NULL; + + PY_LONG_LONG firstrow_py = 0; + LONGLONG firstrow = 1; + int npy_dtype = 0; + int fits_dtype = 0; + + static char *kwlist[] = {"hdunum", "colnum", "array", "firstrow", NULL}; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_ValueError, "fits file is NULL"); + return NULL; + } + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "iiOL", kwlist, &hdunum, + &colnum, &array_obj, &firstrow_py)) { + return NULL; + } + firstrow = (LONGLONG)firstrow_py; + + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (!PyArray_Check(array_obj)) { + PyErr_SetString(PyExc_ValueError, + "only arrays can be written to columns"); + return NULL; + } + array = (PyArrayObject *)array_obj; + + npy_dtype = PyArray_TYPE(array); + if (npy_dtype != NPY_OBJECT) { + PyErr_SetString( + PyExc_TypeError, + "only object arrays can be written to variable length columns"); + return NULL; + } + + // determine the fits dtype for this column. We will use this to get data + // from the array for writing + if (fits_get_eqcoltypell(self->fits, colnum, &fits_dtype, NULL, NULL, + &status) > 0) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (fits_dtype == -TSTRING) { + if (write_var_string_column(self->fits, colnum, firstrow, array, + &status)) { + if (status != 0) { + set_ioerr_string_from_status(status, self); + } + return NULL; + } + } else { + if (write_var_num_column(self, self->fits, colnum, firstrow, fits_dtype, + array, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + } + + // this is a full file close and reopen + if (fits_flush_file(self->fits, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +/* + case for writing an entire record +*/ +static PyObject *PyFITSObject_write_record(struct PyFITSObject *self, + PyObject *args) { + int status = 0; + int hdunum = 0; + int hdutype = 0; + + char *cardin = NULL; + char card[FLEN_CARD]; + + if (!PyArg_ParseTuple(args, (char *)"is", &hdunum, &cardin)) { + return NULL; + } + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + // card not null terminated, so we copy everything. GCC will + // warn about this + strncpy(card, cardin, FLEN_CARD); + + if (fits_write_record(self->fits, card, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // this does not close and reopen + if (fits_flush_buffer(self->fits, 0, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +// let python do the conversions +static PyObject *PyFITSObject_write_string_key(struct PyFITSObject *self, + PyObject *args) { + int status = 0; + int hdunum = 0; + int hdutype = 0; + + char *keyname = NULL; + char *value = NULL; + char *comment = NULL; + char *comment_in = NULL; + + if (!PyArg_ParseTuple(args, (char *)"isss", &hdunum, &keyname, &value, + &comment_in)) { + return NULL; + } + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (strlen(comment_in) > 0) { + comment = comment_in; + } + + if (fits_update_key_longstr(self->fits, keyname, value, comment, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // this does not close and reopen + if (fits_flush_buffer(self->fits, 0, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject *PyFITSObject_write_double_key(struct PyFITSObject *self, + PyObject *args) { + int status = 0; + int hdunum = 0; + int hdutype = 0; + + int decimals = -15; + + char *keyname = NULL; + double value = 0; + char *comment = NULL; + char *comment_in = NULL; + + if (!PyArg_ParseTuple(args, (char *)"isds", &hdunum, &keyname, &value, + &comment_in)) { + return NULL; + } + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (strlen(comment_in) > 0) { + comment = comment_in; + } + + if (fits_update_key_dbl(self->fits, keyname, value, decimals, comment, + &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // this does not close and reopen + if (fits_flush_buffer(self->fits, 0, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject *PyFITSObject_write_long_long_key(struct PyFITSObject *self, + PyObject *args) { + int status = 0; + int hdunum = 0; + int hdutype = 0; + + char *keyname = NULL; + long long value = 0; + char *comment = NULL; + char *comment_in = NULL; + + if (!PyArg_ParseTuple(args, (char *)"isLs", &hdunum, &keyname, &value, + &comment_in)) { + return NULL; + } + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (strlen(comment_in) > 0) { + comment = comment_in; + } + + if (fits_update_key_lng(self->fits, keyname, (LONGLONG)value, comment, + &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // this does not close and reopen + if (fits_flush_buffer(self->fits, 0, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject *PyFITSObject_write_logical_key(struct PyFITSObject *self, + PyObject *args) { + int status = 0; + int hdunum = 0; + int hdutype = 0; + + char *keyname = NULL; + int value = 0; + char *comment = NULL; + char *comment_in = NULL; + + if (!PyArg_ParseTuple(args, (char *)"isis", &hdunum, &keyname, &value, + &comment_in)) { + return NULL; + } + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (strlen(comment_in) > 0) { + comment = comment_in; + } + + if (fits_update_key_log(self->fits, keyname, value, comment, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // this does not close and reopen + if (fits_flush_buffer(self->fits, 0, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +// let python do the conversions +static PyObject *PyFITSObject_write_comment(struct PyFITSObject *self, + PyObject *args) { + int status = 0; + int hdunum = 0; + int hdutype = 0; + + char *comment = NULL; + + if (!PyArg_ParseTuple(args, (char *)"is", &hdunum, &comment)) { + return NULL; + } + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (fits_write_comment(self->fits, comment, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // this does not close and reopen + if (fits_flush_buffer(self->fits, 0, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +// let python do the conversions +static PyObject *PyFITSObject_write_history(struct PyFITSObject *self, + PyObject *args) { + int status = 0; + int hdunum = 0; + int hdutype = 0; + + char *history = NULL; + + if (!PyArg_ParseTuple(args, (char *)"is", &hdunum, &history)) { + return NULL; + } + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (fits_write_history(self->fits, history, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // this does not close and reopen + if (fits_flush_buffer(self->fits, 0, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +// ADW: Adapted from ffpcom and ffphis in putkey.c +static int fits_write_continue(fitsfile *fptr, /* I - FITS file pointer */ + const char *cont, /* I - continue string */ + int *status) /* IO - error status */ +/* + Write 1 or more CONTINUE keywords. If the history string is too + long to fit on a single keyword (72 chars) then it will automatically + be continued on multiple CONTINUE keywords. +*/ +{ + char card[FLEN_CARD]; + int len, ii; + + if (*status > 0) /* inherit input status value if > 0 */ + return (*status); + + len = strlen(cont); + ii = 0; + + for (; len > 0; len -= 72) { + strcpy(card, "CONTINUE"); + strncat(card, &cont[ii], 72); + ffprec(fptr, card, status); + ii += 72; + } + + return (*status); +} + +// let python do the conversions +static PyObject *PyFITSObject_write_continue(struct PyFITSObject *self, + PyObject *args) { + int status = 0; + int hdunum = 0; + int hdutype = 0; + + char *value = NULL; + + if (!PyArg_ParseTuple(args, (char *)"is", &hdunum, &value)) { + return NULL; + } + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (fits_write_continue(self->fits, value, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // this does not close and reopen + if (fits_flush_buffer(self->fits, 0, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject *PyFITSObject_write_undefined_key(struct PyFITSObject *self, + PyObject *args) { + int status = 0; + int hdunum = 0; + int hdutype = 0; + + char *keyname = NULL; + char *comment = NULL; + char *comment_in = NULL; + + if (!PyArg_ParseTuple(args, (char *)"iss", &hdunum, &keyname, + &comment_in)) { + return NULL; + } + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (strlen(comment_in) > 0) { + comment = comment_in; + } + + if (fits_update_key_null(self->fits, keyname, comment, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // this does not close and reopen + if (fits_flush_buffer(self->fits, 0, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +// let python do the conversions +static PyObject *PyFITSObject_delete_key(struct PyFITSObject *self, + PyObject *args) { + int status = 0; + int hdunum = 0; + int hdutype = 0; + + char *keyname = NULL; + + if (!PyArg_ParseTuple(args, (char *)"is", &hdunum, &keyname)) { + return NULL; + } + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (fits_delete_key(self->fits, keyname, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // this does not close and reopen + if (fits_flush_buffer(self->fits, 0, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +/* + insert a set of rows +*/ + +static PyObject *PyFITSObject_insert_rows(struct PyFITSObject *self, + PyObject *args, PyObject *kwds) { + int status = 0; + int hdunum = 0; + + int hdutype = 0; + PY_LONG_LONG firstrow_py = 0, nrows_py = 0; + LONGLONG firstrow = 0, nrows = 0; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_ValueError, "fits file is NULL"); + return NULL; + } + + if (!PyArg_ParseTuple(args, (char *)"iLL", &hdunum, &firstrow_py, + &nrows_py)) { + return NULL; + } + + firstrow = (LONGLONG)firstrow_py; + nrows = (LONGLONG)nrows_py; + + if (nrows <= 0) { + // nothing to do, just return + Py_RETURN_NONE; + } + + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (fits_insert_rows(self->fits, firstrow, nrows, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // this does a full close and reopen + if (fits_flush_file(self->fits, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +/* + + delete a range of rows + + input stop is like a python slice, so exclusive, but 1-offset + rather than 0-offset +*/ + +static PyObject *PyFITSObject_delete_row_range(struct PyFITSObject *self, + PyObject *args, PyObject *kwds) { + int status = 0; + int hdunum = 0; + + int hdutype = 0; + PY_LONG_LONG slice_start_py = 0, slice_stop_py = 0; + LONGLONG slice_start = 0, slice_stop = 0, nrows = 0; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_ValueError, "fits file is NULL"); + return NULL; + } + + if (!PyArg_ParseTuple(args, (char *)"iLL", &hdunum, &slice_start_py, + &slice_stop_py)) { + return NULL; + } + + slice_start = (LONGLONG)slice_start_py; + slice_stop = (LONGLONG)slice_stop_py; + nrows = slice_stop - slice_start; + + if (nrows <= 0) { + // nothing to do, just return + Py_RETURN_NONE; + } + + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (fits_delete_rows(self->fits, slice_start, nrows, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // this does a full close and reopen + if (fits_flush_file(self->fits, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +/* + + delete a specific set of rows, 1-offset + + no type checking is applied to the rows +*/ + +static PyObject *PyFITSObject_delete_rows(struct PyFITSObject *self, + PyObject *args, PyObject *kwds) { + int status = 0; + int hdunum = 0; + + int hdutype = 0; + PyObject *rows_obj = NULL; + PyArrayObject *rows_array = NULL; + LONGLONG *rows = NULL, nrows = 0; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_ValueError, "fits file is NULL"); + return NULL; + } + + if (!PyArg_ParseTuple(args, (char *)"iO", &hdunum, &rows_obj)) { + return NULL; + } + rows_array = (PyArrayObject *)rows_obj; + + rows = (LONGLONG *)PyArray_DATA(rows_array); + nrows = PyArray_SIZE(rows_array); + if (nrows <= 0) { + Py_RETURN_NONE; + } + + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (fits_delete_rowlistll(self->fits, rows, nrows, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // this does a full close and reopen + if (fits_flush_file(self->fits, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +/* + * read a single, entire column from an ascii table into the input array. This + * version uses the standard read column instead of our by-bytes version. + * + * A number of assumptions are made, such as that columns are scalar, which + * is true for ascii. + */ + +static int read_ascii_column_all(fitsfile *fits, int colnum, + PyArrayObject *array, int *status) { + + int npy_dtype = 0; + int fits_dtype = 0; + + npy_intp nelem = 0; + LONGLONG firstelem = 1; + LONGLONG firstrow = 1; + int *anynul = NULL; + void *nulval = 0; + char *nulstr = " "; + void *data = NULL; + char *cdata = NULL; + + npy_dtype = PyArray_TYPE(array); + fits_dtype = npy_to_fits_table_type(npy_dtype, 0); + + nelem = PyArray_SIZE(array); + + if (fits_dtype == TSTRING) { + npy_intp i = 0; + LONGLONG rownum = 0; + + for (i = 0; i < nelem; i++) { + cdata = PyArray_GETPTR1(array, i); + rownum = (LONGLONG)(1 + i); + if (fits_read_col_str(fits, colnum, rownum, firstelem, 1, nulstr, + &cdata, anynul, status) > 0) { + return 1; + } + } + + /* + + LONGLONG twidth=0; + char** strdata=NULL; + + cdata = (char*) PyArray_DATA(array); + + strdata=malloc(nelem*sizeof(char*)); + if (NULL==strdata) { + PyErr_SetString(PyExc_MemoryError, "could not allocate temporary + string pointers"); *status = 99; return 1; + + } + + + twidth=fits->Fptr->tableptr[colnum-1].twidth; + for (i=0; i 0) { free(strdata); return 1; + } + + free(strdata); + */ + + } else { + data = PyArray_DATA(array); + if (fits_read_col(fits, fits_dtype, colnum, firstrow, firstelem, nelem, + nulval, data, anynul, status) > 0) { + return 1; + } + } + + return 0; +} +static int read_ascii_column_byrow(fitsfile *fits, int colnum, + PyArrayObject *array, PyArrayObject *rows, + PyArrayObject *sortind, int *status) { + + int npy_dtype = 0; + int fits_dtype = 0; + + npy_intp nelem = 0; + LONGLONG firstelem = 1; + LONGLONG rownum = 0; + npy_int64 si = 0; + npy_intp nrows = -1; + + int *anynul = NULL; + void *nulval = 0; + char *nulstr = " "; + void *data = NULL; + char *cdata = NULL; + + int dorows = 0; + + npy_intp i = 0; + + npy_dtype = PyArray_TYPE(array); + fits_dtype = npy_to_fits_table_type(npy_dtype, 0); + + nelem = PyArray_SIZE(array); + + if ((PyObject *)rows != Py_None) { + dorows = 1; + nrows = PyArray_SIZE(rows); + if (nrows != nelem) { + PyErr_Format(PyExc_ValueError, + "input array[%ld] and rows[%ld] have different size", + nelem, nrows); + return 1; + } + } + + for (i = 0; i < nrows; i++) { + if (dorows) { + si = *(npy_int64 *)PyArray_GETPTR1(sortind, i); + rownum = (LONGLONG)(*(npy_int64 *)PyArray_GETPTR1(rows, si)); + rownum += 1; + } else { + rownum = (LONGLONG)(1 + i); + } + // assuming 1-D + data = PyArray_GETPTR1(array, si); + if (fits_dtype == TSTRING) { + cdata = (char *)data; + if (fits_read_col_str(fits, colnum, rownum, firstelem, 1, nulstr, + &cdata, anynul, status) > 0) { + return 1; + } + } else { + if (fits_read_col(fits, fits_dtype, colnum, rownum, firstelem, 1, + nulval, data, anynul, status) > 0) { + return 1; + } + } + } + + return 0; +} + +static int read_ascii_column(fitsfile *fits, int colnum, PyArrayObject *array, + PyArrayObject *rows, PyArrayObject *sortind, + int *status) { + + int ret = 0; + if ((PyObject *)rows != Py_None || !PyArray_ISCONTIGUOUS(array)) { + ret = + read_ascii_column_byrow(fits, colnum, array, rows, sortind, status); + } else { + ret = read_ascii_column_all(fits, colnum, array, status); + } + + return ret; +} + +// read a subset of rows for the input column +// the row array is assumed to be unique and sorted. +static int read_binary_column(fitsfile *fits, int colnum, npy_intp nrows, + npy_int64 *rows, npy_int64 *sortind, void *vdata, + npy_intp stride, int *status) { + + FITSfile *hdu = NULL; + tcolumn *colptr = NULL; + LONGLONG file_pos = 0, irow = 0; + npy_int64 row = 0, si = 0; + + LONGLONG repeat = 0; + LONGLONG width = 0; + + // use char for pointer arith. It's actually ok to use void as char but + // this is just in case. + char *data = NULL, *ptr = NULL; + + data = (char *)vdata; + + // using struct defs here, could cause problems + hdu = fits->Fptr; + colptr = hdu->tableptr + (colnum - 1); + + repeat = colptr->trepeat; + width = colptr->tdatatype == TSTRING ? 1 : colptr->twidth; + + for (irow = 0; irow < nrows; irow++) { + if (rows != NULL) { + si = sortind[irow]; + row = rows[si]; + } else { + row = irow; + } + + ptr = data + si * stride; + + file_pos = hdu->datastart + row * hdu->rowlength + colptr->tbcol; + ffmbyt(fits, file_pos, REPORT_EOF, status); + if (ffgbytoff(fits, width, repeat, 0, (void *)ptr, status)) { + return 1; + } + } + + return 0; +} + +/* + * read from a column into an input array + */ +static PyObject *PyFITSObject_read_column(struct PyFITSObject *self, + PyObject *args) { + int hdunum = 0; + int hdutype = 0; + int colnum = 0; + + FITSfile *hdu = NULL; + int status = 0; + + PyObject *array_obj = NULL, *rows_obj = NULL, *sortind_obj = NULL; + PyArrayObject *array = NULL, *rows_array = NULL, *sortind_array = NULL; + + if (!PyArg_ParseTuple(args, (char *)"iiOOO", &hdunum, &colnum, &array_obj, + &rows_obj, &sortind_obj)) { + return NULL; + } + + array = (PyArrayObject *)array_obj; + rows_array = (PyArrayObject *)rows_obj; + sortind_array = (PyArrayObject *)sortind_obj; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // using struct defs here, could cause problems + hdu = self->fits->Fptr; + if (hdutype == IMAGE_HDU) { + PyErr_SetString(PyExc_RuntimeError, + "Cannot yet read columns from an IMAGE_HDU"); + return NULL; + } + if (colnum < 1 || colnum > hdu->tfield) { + PyErr_SetString(PyExc_RuntimeError, + "requested column is out of bounds"); + return NULL; + } + + if (hdutype == ASCII_TBL) { + if (read_ascii_column(self->fits, colnum, array, rows_array, + sortind_array, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + } else { + void *data = PyArray_DATA(array); + npy_intp nrows = 0, nsortind = 0; + npy_int64 *rows = NULL, *sortind = NULL; + npy_intp stride = PyArray_STRIDE(array, 0); + if (rows_obj == Py_None) { + nrows = hdu->numrows; + } else { + rows = get_int64_from_array(rows_array, &nrows); + sortind = get_int64_from_array(sortind_array, &nsortind); + } + + if (read_binary_column(self->fits, colnum, nrows, rows, sortind, data, + stride, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + } + Py_RETURN_NONE; +} + +/* + * Free all the elements in the python list as well as the list itself + */ +static void free_all_python_list(PyObject *list) { + if (PyList_Check(list)) { + Py_ssize_t i = 0; + for (i = 0; i < PyList_Size(list); i++) { + Py_XDECREF(PyList_GetItem(list, i)); + } + } + Py_XDECREF(list); +} + +static PyObject *read_var_string(fitsfile *fits, int colnum, LONGLONG row, + LONGLONG nchar, int *status) { + LONGLONG firstelem = 1; + char *str = NULL; + char *strarr[1]; + PyObject *stringObj = NULL; + void *nulval = 0; + int *anynul = NULL; + + str = calloc(nchar + 1, sizeof(char)); + if (str == NULL) { + PyErr_Format(PyExc_MemoryError, + "Could not allocate string of size %lld", nchar); + return NULL; + } + + strarr[0] = str; + if (fits_read_col(fits, TSTRING, colnum, row, firstelem, nchar, nulval, + strarr, anynul, status) > 0) { + goto read_var_string_cleanup; + } +#if PY_MAJOR_VERSION >= 3 + // bytes + stringObj = Py_BuildValue("y", str); +#else + stringObj = Py_BuildValue("s", str); +#endif + if (NULL == stringObj) { + PyErr_Format(PyExc_MemoryError, + "Could not allocate py string of size %lld", nchar); + goto read_var_string_cleanup; + } + +read_var_string_cleanup: + free(str); + + return stringObj; +} +static PyObject *read_var_nums(fitsfile *fits, int colnum, LONGLONG row, + LONGLONG nelem, int fits_dtype, int npy_dtype, + int *status) { + LONGLONG firstelem = 1; + PyArrayObject *array = NULL; + void *nulval = 0; + int *anynul = NULL; + npy_intp dims[1]; + int fortran = 0; + void *data = NULL; + + dims[0] = nelem; + array = (PyArrayObject *)PyArray_ZEROS(1, dims, npy_dtype, fortran); + if (array == NULL) { + PyErr_Format(PyExc_MemoryError, + "Could not allocate array type %d size %lld", npy_dtype, + nelem); + return NULL; + } + data = PyArray_DATA(array); + if (fits_read_col(fits, abs(fits_dtype), colnum, row, firstelem, nelem, + nulval, data, anynul, status) > 0) { + Py_XDECREF(array); + return NULL; + } + + return (PyObject *)array; +} +/* + * read a variable length column as a list of arrays + * what about strings? + */ +static PyObject *PyFITSObject_read_var_column_as_list(struct PyFITSObject *self, + PyObject *args) { + int hdunum = 0; + int colnum = 0; + PyObject *rows_obj = NULL, *sortind_obj = NULL; + PyArrayObject *rows_array = NULL, *sortind_array = NULL; + + int hdutype = 0; + int ncols = 0; + const npy_int64 *rows = NULL, *sortind = NULL; + LONGLONG nrows = 0; + int get_all_rows = 0; + + int status = 0, tstatus = 0; + + int fits_dtype = 0; + int npy_dtype = 0; + int isvariable = 0; + LONGLONG repeat = 0; + LONGLONG width = 0; + LONGLONG offset = 0; + LONGLONG i = 0; + LONGLONG row = 0; + npy_int64 si = 0; + + PyObject *listObj = NULL; + PyObject *tempObj = NULL; + + if (!PyArg_ParseTuple(args, (char *)"iiOO", &hdunum, &colnum, &rows_obj, + &sortind_obj)) { + return NULL; + } + rows_array = (PyArrayObject *)rows_obj; + sortind_array = (PyArrayObject *)sortind_obj; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (hdutype == IMAGE_HDU) { + PyErr_SetString(PyExc_RuntimeError, + "Cannot yet read columns from an IMAGE_HDU"); + return NULL; + } + // using struct defs here, could cause problems + fits_get_num_cols(self->fits, &ncols, &status); + if (colnum < 1 || colnum > ncols) { + PyErr_SetString(PyExc_RuntimeError, + "requested column is out of bounds"); + return NULL; + } + + if (fits_get_coltypell(self->fits, colnum, &fits_dtype, &repeat, &width, + &status) > 0) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + npy_dtype = fits_to_npy_table_type(fits_dtype, &isvariable); + if (npy_dtype < 0) { + return NULL; + } + if (!isvariable) { + PyErr_Format(PyExc_TypeError, "Column %d not a variable length %d", + colnum, fits_dtype); + return NULL; + } + + if (rows_obj == Py_None) { + fits_get_num_rowsll(self->fits, &nrows, &tstatus); + get_all_rows = 1; + } else { + npy_intp tnrows = 0, nsortind = 0; + rows = (const npy_int64 *)get_int64_from_array(rows_array, &tnrows); + sortind = + (const npy_int64 *)get_int64_from_array(sortind_array, &nsortind); + nrows = (LONGLONG)tnrows; + get_all_rows = 0; + } + + listObj = PyList_New(0); + + for (i = 0; i < nrows; i++) { + tempObj = NULL; + + if (get_all_rows) { + row = i + 1; + } else { + si = sortind[i]; + row = (LONGLONG)(rows[si] + 1); + } + + // repeat holds how many elements are in this row + if (fits_read_descriptll(self->fits, colnum, row, &repeat, &offset, + &status) > 0) { + goto read_var_column_cleanup; + } + + if (fits_dtype == -TSTRING) { + tempObj = read_var_string(self->fits, colnum, row, repeat, &status); + } else { + tempObj = read_var_nums(self->fits, colnum, row, repeat, fits_dtype, + npy_dtype, &status); + } + if (tempObj == NULL) { + tstatus = 1; + goto read_var_column_cleanup; + } + PyList_Append(listObj, tempObj); + Py_XDECREF(tempObj); + } + +read_var_column_cleanup: + + if (status != 0 || tstatus != 0) { + Py_XDECREF(tempObj); + free_all_python_list(listObj); + if (status != 0) { + set_ioerr_string_from_status(status, self); + } + return NULL; + } + + return listObj; +} + +// read specified columns and rows +static int read_binary_rec_columns(fitsfile *fits, npy_intp ncols, + npy_int64 *colnums, npy_intp nrows, + npy_int64 *rows, npy_int64 *sortind, + PyArrayObject *array, int *status) { + FITSfile *hdu = NULL; + tcolumn *colptr = NULL; + LONGLONG file_pos = 0; + npy_intp col = 0; + npy_int64 colnum = 0; + char *ptr = NULL; + + npy_intp irow = 0; + npy_int64 row = 0, si = 0; + + LONGLONG gsize = 0; // number of bytes in column + LONGLONG repeat = 0; + LONGLONG width = 0; + + // using struct defs here, could cause problems + hdu = fits->Fptr; + + for (irow = 0; irow < nrows; irow++) { + if (rows != NULL) { + si = sortind[irow]; + row = rows[si]; + } else { + si = irow; + row = irow; + } + + ptr = (char *)PyArray_GETPTR1(array, si); + for (col = 0; col < ncols; col++) { + + colnum = colnums[col]; + colptr = hdu->tableptr + (colnum - 1); + + repeat = colptr->trepeat; + width = colptr->tdatatype == TSTRING ? 1 : colptr->twidth; + + file_pos = hdu->datastart + row * hdu->rowlength + colptr->tbcol; + + if (colptr->tdatatype == TBIT) { + if (fits_read_col_bit(fits, colnum, row + 1, 1, repeat, ptr, + status)) { + return 1; + } + } else { + // can just do one status check, since status are inherited. + ffmbyt(fits, file_pos, REPORT_EOF, status); + if (ffgbytoff(fits, width, repeat, 0, (void *)ptr, status)) { + return 1; + } + } + + gsize = repeat * width; + ptr += gsize; + } + } + + return 0; +} + +// python method for reading specified columns and rows +static PyObject *PyFITSObject_read_columns_as_rec(struct PyFITSObject *self, + PyObject *args) { + int hdunum = 0; + int hdutype = 0; + npy_intp ncols = 0; + npy_int64 *colnums = NULL; + FITSfile *hdu = NULL; + + int status = 0; + + PyObject *columns_obj = NULL, *array_obj = NULL, *rows_obj = NULL, + *sortind_obj = NULL; + + npy_intp nrows, nsortind; + npy_int64 *rows = NULL, *sortind = NULL; + + if (!PyArg_ParseTuple(args, (char *)"iOOOO", &hdunum, &columns_obj, + &array_obj, &rows_obj, &sortind_obj)) { + return NULL; + } + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + goto recread_columns_cleanup; + } + + if (hdutype == IMAGE_HDU) { + PyErr_SetString(PyExc_RuntimeError, + "Cannot read IMAGE_HDU into a recarray"); + return NULL; + } + + colnums = get_int64_from_array((PyArrayObject *)columns_obj, &ncols); + if (colnums == NULL) { + return NULL; + } + + hdu = self->fits->Fptr; + + if (rows_obj == Py_None) { + nrows = hdu->numrows; + } else { + rows = get_int64_from_array((PyArrayObject *)rows_obj, &nrows); + if (rows == NULL) { + return NULL; + } + sortind = get_int64_from_array((PyArrayObject *)sortind_obj, &nsortind); + if (sortind == NULL) { + return NULL; + } + } + if (read_binary_rec_columns(self->fits, ncols, colnums, nrows, rows, + sortind, (PyArrayObject *)array_obj, &status)) { + goto recread_columns_cleanup; + } + +recread_columns_cleanup: + + if (status != 0) { + set_ioerr_string_from_status(status, self); + return NULL; + } + Py_RETURN_NONE; +} + +/* + * read specified columns and rows + * + * Move by offset instead of just groupsize; this allows us to read into a + * recarray while skipping some fields, e.g. variable length array fields, to + * be read separately. + * + * If rows is NULL, then nrows are read consecutively. + */ + +static int read_columns_as_rec_byoffset( + fitsfile *fits, npy_intp ncols, + const npy_int64 *colnums, // columns to read from file + const npy_int64 + *field_offsets, // offsets of corresponding fields within array + npy_intp nrows, const npy_int64 *rows, const npy_int64 *sortind, char *data, + npy_intp recsize, int *status) { + + FITSfile *hdu = NULL; + tcolumn *colptr = NULL; + LONGLONG file_pos = 0; + npy_intp col = 0; + npy_int64 colnum = 0; + + char *ptr = NULL; + + int get_all_rows = 1; + npy_intp irow = 0; + npy_int64 row = 0, si = 0; + + long groupsize = 0; // number of bytes in column + long ngroups = 1; // number to read, one for row-by-row reading + long group_gap = 0; // gap between groups, zero since we aren't using it + + if (rows != NULL) { + get_all_rows = 0; + } + + // using struct defs here, could cause problems + hdu = fits->Fptr; + for (irow = 0; irow < nrows; irow++) { + if (get_all_rows) { + row = irow; + si = irow; + } else { + si = sortind[irow]; + row = rows[si]; + } + for (col = 0; col < ncols; col++) { + + // point to this field in the array, allows for skipping + ptr = data + si * recsize + field_offsets[col]; + + colnum = colnums[col]; + colptr = hdu->tableptr + (colnum - 1); + + groupsize = get_groupsize(colptr); + + file_pos = hdu->datastart + row * hdu->rowlength + colptr->tbcol; + + // can just do one status check, since status are inherited. + ffmbyt(fits, file_pos, REPORT_EOF, status); + if (ffgbytoff(fits, groupsize, ngroups, group_gap, (void *)ptr, + status)) { + return 1; + } + } + } + + return 0; +} + +/* python method for reading specified columns and rows, moving by offset in + * the array to allow some fields not read. + * + * columnsObj is the columns in the fits file to read. + * offsetsObj is the offsets of the corresponding fields into the array. + */ +static PyObject * +PyFITSObject_read_columns_as_rec_byoffset(struct PyFITSObject *self, + PyObject *args) { + int status = 0; + int hdunum = 0; + int hdutype = 0; + + npy_intp ncols = 0; + npy_intp noffsets = 0; + npy_intp nrows = 0, nsortind = 0; + const npy_int64 *colnums = NULL; + const npy_int64 *offsets = NULL; + const npy_int64 *rows = NULL, *sortind = NULL; + + PyObject *columns_obj = NULL; + PyObject *offsets_obj = NULL; + PyObject *rows_obj = NULL; + PyObject *sortind_obj = NULL; + + PyObject *array_obj = NULL; + PyArrayObject *array = NULL; + void *data = NULL; + npy_intp recsize = 0; + + if (!PyArg_ParseTuple(args, (char *)"iOOOOO", &hdunum, &columns_obj, + &offsets_obj, &array_obj, &rows_obj, &sortind_obj)) { + return NULL; + } + + array = (PyArrayObject *)array_obj; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + goto recread_columns_byoffset_cleanup; + } + + if (hdutype == IMAGE_HDU) { + PyErr_SetString(PyExc_RuntimeError, + "Cannot read IMAGE_HDU into a recarray"); + return NULL; + } + + colnums = (const npy_int64 *)get_int64_from_array( + (PyArrayObject *)columns_obj, &ncols); + if (colnums == NULL) { + return NULL; + } + offsets = (const npy_int64 *)get_int64_from_array( + (PyArrayObject *)offsets_obj, &noffsets); + if (offsets == NULL) { + return NULL; + } + if (noffsets != ncols) { + PyErr_Format(PyExc_ValueError, + "%ld columns requested but got %ld offsets", ncols, + noffsets); + return NULL; + } + + if (rows_obj != Py_None) { + rows = (const npy_int64 *)get_int64_from_array( + (PyArrayObject *)rows_obj, &nrows); + sortind = (const npy_int64 *)get_int64_from_array( + (PyArrayObject *)sortind_obj, &nsortind); + } else { + nrows = PyArray_SIZE(array); + } + + data = PyArray_DATA(array); + recsize = PyArray_ITEMSIZE(array); + if (read_columns_as_rec_byoffset(self->fits, ncols, colnums, offsets, nrows, + rows, sortind, (char *)data, recsize, + &status) > 0) { + goto recread_columns_byoffset_cleanup; + } + +recread_columns_byoffset_cleanup: + + if (status != 0) { + set_ioerr_string_from_status(status, self); + return NULL; + } + Py_RETURN_NONE; +} + +// read specified rows, all columns +static int read_rec_bytes_byrow(fitsfile *fits, npy_intp nrows, npy_int64 *rows, + npy_int64 *sortind, void *vdata, int *status) { + + FITSfile *hdu = NULL; + + npy_intp irow = 0, si = 0; + LONGLONG firstrow = 1; + LONGLONG firstchar = 1; + + // use char for pointer arith. It's actually ok to use void as char but + // this is just in case. + unsigned char *ptr, *data; + + // using struct defs here, could cause problems + hdu = fits->Fptr; + // ptr = (unsigned char*) data; + data = (unsigned char *)vdata; + + for (irow = 0; irow < nrows; irow++) { + + si = sortind[irow]; + + // Input is zero-offset + firstrow = 1 + (LONGLONG)rows[si]; + + ptr = data + si * hdu->rowlength; + + if (fits_read_tblbytes(fits, firstrow, firstchar, hdu->rowlength, ptr, + status)) { + return 1; + } + + // ptr += hdu->rowlength; + } + + return 0; +} +// read specified rows, all columns +/* +static int read_rec_bytes_byrowold( + fitsfile* fits, + npy_intp nrows, npy_int64* rows, + void* data, int* status) { + FITSfile* hdu=NULL; + LONGLONG file_pos=0; + + npy_intp irow=0; + npy_int64 row=0; + + // use char for pointer arith. It's actually ok to use void as char but + // this is just in case. + char* ptr; + + long ngroups=1; // number to read, one for row-by-row reading + long offset=0; // gap between groups, not stride. zero since we aren't +using it + + // using struct defs here, could cause problems + hdu = fits->Fptr; + ptr = (char*) data; + + for (irow=0; irowdatastart + row*hdu->rowlength; + + // can just do one status check, since status are inherited. + ffmbyt(fits, file_pos, REPORT_EOF, status); + if (ffgbytoff(fits, hdu->rowlength, ngroups, offset, (void*) ptr, +status)) { return 1; + } + ptr += hdu->rowlength; + } + + return 0; +} +*/ + +// python method to read all columns but subset of rows +static PyObject *PyFITSObject_read_rows_as_rec(struct PyFITSObject *self, + PyObject *args) { + int hdunum = 0; + int hdutype = 0; + + int status = 0; + void *data = NULL; + + PyObject *array_obj = NULL, *rows_obj = NULL, *sortind_obj = NULL; + npy_intp nrows = 0, nsortind = 0; + npy_int64 *rows = NULL; + npy_int64 *sortind = NULL; + + if (!PyArg_ParseTuple(args, (char *)"iOOO", &hdunum, &array_obj, &rows_obj, + &sortind_obj)) { + return NULL; + } + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + goto recread_byrow_cleanup; + } + + if (hdutype == IMAGE_HDU) { + PyErr_SetString(PyExc_RuntimeError, + "Cannot read IMAGE_HDU into a recarray"); + return NULL; + } + + data = PyArray_DATA((PyArrayObject *)array_obj); + + rows = get_int64_from_array((PyArrayObject *)rows_obj, &nrows); + if (rows == NULL) { + return NULL; + } + sortind = get_int64_from_array((PyArrayObject *)sortind_obj, &nsortind); + if (sortind == NULL) { + return NULL; + } + + if (read_rec_bytes_byrow(self->fits, nrows, rows, sortind, data, &status)) { + goto recread_byrow_cleanup; + } + +recread_byrow_cleanup: + + if (status != 0) { + set_ioerr_string_from_status(status, self); + return NULL; + } + Py_RETURN_NONE; +} + +/* Read the range of rows, 1-offset. It is assumed the data match the table + * perfectly. + */ + +static int read_rec_range(fitsfile *fits, LONGLONG firstrow, LONGLONG nrows, + void *data, int *status) { + // can also use this for reading row ranges + LONGLONG firstchar = 1; + LONGLONG nchars = 0; + + nchars = (fits->Fptr)->rowlength * nrows; + + if (fits_read_tblbytes(fits, firstrow, firstchar, nchars, + (unsigned char *)data, status)) { + return 1; + } + + return 0; +} + +/* here rows are 1-offset, unlike when reading a specific subset of rows */ +static PyObject *PyFITSObject_read_as_rec(struct PyFITSObject *self, + PyObject *args) { + int hdunum = 0; + int hdutype = 0; + + int status = 0; + PyObject *array_obj = NULL; + void *data = NULL; + + PY_LONG_LONG firstrow = 0; + PY_LONG_LONG lastrow = 0; + PY_LONG_LONG nrows = 0; + + if (!PyArg_ParseTuple(args, (char *)"iLLO", &hdunum, &firstrow, &lastrow, + &array_obj)) { + return NULL; + } + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + goto recread_asrec_cleanup; + } + + if (hdutype == IMAGE_HDU) { + PyErr_SetString(PyExc_RuntimeError, + "Cannot read IMAGE_HDU into a recarray"); + return NULL; + } + + data = PyArray_DATA((PyArrayObject *)array_obj); + + nrows = lastrow - firstrow + 1; + if (read_rec_range(self->fits, (LONGLONG)firstrow, (LONGLONG)nrows, data, + &status)) { + goto recread_asrec_cleanup; + } + +recread_asrec_cleanup: + + if (status != 0) { + set_ioerr_string_from_status(status, self); + return NULL; + } + Py_RETURN_NONE; +} + +static int fits_is_compressed_with_nulls(fitsfile *fits) { + int cstatus = 0, clstatus = 0, hstatus = 0; + int colnum, zblank; + if (fits_is_compressed_image(fits, &cstatus) && + (ffgcno(fits, CASEINSEN, "ZBLANK", &colnum, &clstatus) <= 0 || + ffgky(fits, TINT, "ZBLANK", &zblank, NULL, &hstatus) <= 0)) { + return 1; + } else { + return 0; + } +} + +// read an n-dimensional "image" into the input array. Only minimal checking +// of the input array is done. +// Note numpy allows a maximum of 32 dimensions +static PyObject *PyFITSObject_read_image(struct PyFITSObject *self, + PyObject *args) { + int hdunum = 0; + int hdutype = 0; + int status = 0; + PyObject *array_obj = NULL; + PyArrayObject *array = NULL; + void *data = NULL; + int npy_dtype = 0; + int dummy = 0, fits_read_dtype = 0; + + int maxdim = NUMPY_MAX_DIMS; // numpy maximum + int datatype = 0; // type info for axis + int naxis = 0; // number of axes + int i = 0; + LONGLONG naxes[NUMPY_MAX_DIMS]; + ; // size of each axis + LONGLONG firstpixels[NUMPY_MAX_DIMS]; + LONGLONG size = 0; + npy_intp arrsize = 0; + + int anynul = 0; + + if (!PyArg_ParseTuple(args, (char *)"iO", &hdunum, &array_obj)) { + return NULL; + } + + array = (PyArrayObject *)array_obj; + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + return NULL; + } + + if (fits_get_img_paramll(self->fits, maxdim, &datatype, &naxis, naxes, + &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + // make sure dims match + size = 0; + size = naxes[0]; + for (i = 1; i < naxis; i++) { + size *= naxes[i]; + } + arrsize = PyArray_SIZE(array); + data = PyArray_DATA(array); + + if (size != arrsize) { + PyErr_Format(PyExc_RuntimeError, + "Input array size is %ld but on disk array size is %lld", + arrsize, size); + return NULL; + } + + npy_dtype = PyArray_TYPE(array); + npy_to_fits_image_types(npy_dtype, &dummy, &fits_read_dtype); + + float fnullval = NAN; + double dnullval = NAN; + void *nullval_ptr = NULL; + + // we only set null checking for compressed images of + // floating point data + // nans works fine for non-compressed images and we do + // not consider int data + if (fits_is_compressed_with_nulls(self->fits)) { + if (fits_read_dtype == TFLOAT) { + nullval_ptr = (void *)(&fnullval); + } else if (fits_read_dtype == TDOUBLE) { + nullval_ptr = (void *)(&dnullval); + } + } + + for (i = 0; i < naxis; i++) { + firstpixels[i] = 1; + } + if (fits_read_pixll(self->fits, fits_read_dtype, firstpixels, size, + nullval_ptr, data, &anynul, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject *PyFITSObject_read_raw(struct PyFITSObject *self, + PyObject *args) { + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + // fitsfile* fits = self->fits; + FITSfile *FITS = self->fits->Fptr; + int status = 0; + char *filedata; + LONGLONG sz; + LONGLONG io_pos; + PyObject *stringobj; + + // Flush (close & reopen HDU) to make everything consistent + ffflus(self->fits, &status); + if (status) { + PyErr_Format(PyExc_RuntimeError, + "Failed to flush FITS file data to disk; CFITSIO code %i", + status); + return NULL; + } + // Allocate buffer for string + sz = FITS->filesize; + // Create python string object of requested size, unitialized + stringobj = PyBytes_FromStringAndSize(NULL, sz); + if (!stringobj) { + PyErr_Format( + PyExc_RuntimeError, + "Failed to allocate python string object to hold FITS file " + "data: %i bytes", + (int)sz); + return NULL; + } + // Grab pointer to the memory buffer of the python string object + filedata = PyBytes_AsString(stringobj); + if (!filedata) { + Py_DECREF(stringobj); + return NULL; + } + // Remember old file position + io_pos = FITS->io_pos; + // Seek to beginning of file + if (ffseek(FITS, 0)) { + Py_DECREF(stringobj); + PyErr_Format(PyExc_RuntimeError, + "Failed to seek to beginning of FITS file"); + return NULL; + } + // Read into filedata + if (ffread(FITS, sz, filedata, &status)) { + Py_DECREF(stringobj); + PyErr_Format(PyExc_RuntimeError, + "Failed to read file data into memory: CFITSIO code %i", + status); + return NULL; + } + // Seek back to where we were + if (ffseek(FITS, io_pos)) { + Py_DECREF(stringobj); + PyErr_Format(PyExc_RuntimeError, + "Failed to seek back to original FITS file position"); + return NULL; + } + return stringobj; +} + +static int get_long_slices(PyArrayObject *fpix_arr, PyArrayObject *lpix_arr, + PyArrayObject *step_arr, long **fpix, long **lpix, + long **step) { + + int i = 0; + npy_int64 *ptr = NULL; + npy_intp fsize = 0, lsize = 0, ssize = 0; + + fsize = PyArray_SIZE(fpix_arr); + lsize = PyArray_SIZE(lpix_arr); + ssize = PyArray_SIZE(step_arr); + + if (lsize != fsize || ssize != fsize) { + PyErr_SetString(PyExc_RuntimeError, "start/end/step must be same len"); + return 1; + } + + *fpix = calloc(fsize, sizeof(long)); + *lpix = calloc(fsize, sizeof(long)); + *step = calloc(fsize, sizeof(long)); + + for (i = 0; i < fsize; i++) { + ptr = PyArray_GETPTR1(fpix_arr, i); + (*fpix)[i] = *ptr; + ptr = PyArray_GETPTR1(lpix_arr, i); + (*lpix)[i] = *ptr; + ptr = PyArray_GETPTR1(step_arr, i); + (*step)[i] = *ptr; + } + return 0; +} + +// read an n-dimensional "image" into the input array. Only minimal checking +// of the input array is done. +static PyObject *PyFITSObject_read_image_slice(struct PyFITSObject *self, + PyObject *args) { + int hdunum = 0; + int hdutype = 0; + int status = 0; + PyObject *fpix_obj = NULL; + PyObject *lpix_obj = NULL; + PyObject *step_obj = NULL; + int ignore_scaling = FALSE; + PyObject *array = NULL; + long *fpix = NULL; + long *lpix = NULL; + long *step = NULL; + void *data = NULL; + int npy_dtype = 0; + int dummy = 0, fits_read_dtype = 0; + + int anynul = 0; + + if (!PyArg_ParseTuple(args, (char *)"iOOOiO", &hdunum, &fpix_obj, &lpix_obj, + &step_obj, &ignore_scaling, &array)) { + return NULL; + } + + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + return NULL; + } + + if (ignore_scaling == TRUE && + fits_set_bscale(self->fits, 1.0, 0.0, &status)) { + return NULL; + } + + if (get_long_slices((PyArrayObject *)fpix_obj, (PyArrayObject *)lpix_obj, + (PyArrayObject *)step_obj, &fpix, &lpix, &step)) { + return NULL; + } + data = PyArray_DATA((PyArrayObject *)array); + + npy_dtype = PyArray_TYPE((PyArrayObject *)array); + npy_to_fits_image_types(npy_dtype, &dummy, &fits_read_dtype); + + float fnullval = NAN; + double dnullval = NAN; + void *nullval_ptr = NULL; + + // we only set null checking for compressed images of + // floating point data + // nans works fine for non-compressed images and we do + // not consider int data + if (fits_is_compressed_with_nulls(self->fits)) { + if (fits_read_dtype == TFLOAT) { + nullval_ptr = (void *)(&fnullval); + } else if (fits_read_dtype == TDOUBLE) { + nullval_ptr = (void *)(&dnullval); + } + } + + if (fits_read_subset(self->fits, fits_read_dtype, fpix, lpix, step, + nullval_ptr, data, &anynul, &status)) { + set_ioerr_string_from_status(status, self); + goto read_image_slice_cleanup; + } + +read_image_slice_cleanup: + free(fpix); + free(lpix); + free(step); + + if (status != 0) { + return NULL; + } + + Py_RETURN_NONE; +} + +static int hierarch_is_string(const char *card) { + int i = 0, is_string_value = 1; + + for (i = 0; i < 78; i++) { + if (card[i] == '=') { + // we found the equals, now if it is a string we + // now exactly where the quote must be + if (card[i + 2] == '\'') { + is_string_value = 1; + } else { + is_string_value = 0; + } + } + } + return is_string_value; +} + +// read the entire header as list of dicts with name,value,comment and full +// card +static PyObject *PyFITSObject_read_header(struct PyFITSObject *self, + PyObject *args) { + int status = 0; + int hdunum = 0; + int hdutype = 0; + int lcont = 0, lcomm = 0, ls = 0; + int tocomp = 0; + int is_comment_or_history = 0, is_blank_key = 0; + char *longstr = NULL; + + char keyname[FLEN_KEYWORD]; + char value[FLEN_VALUE]; + char comment[FLEN_COMMENT]; + char scomment[FLEN_COMMENT]; + char card[FLEN_CARD]; + long is_string_value = 0; + + LONGLONG lval = 0; + double dval = 0; + + int nkeys = 0, morekeys = 0, i = 0; + int has_equals = 0, has_quote = 0, was_converted = 0, is_hierarch = 0; + + PyObject *list = NULL; + PyObject *dict = NULL; // to hold the dict for each record + + lcont = strlen("CONTINUE"); + lcomm = strlen("COMMENT"); + + if (!PyArg_ParseTuple(args, (char *)"i", &hdunum)) { + return NULL; + } + + if (self->fits == NULL) { + PyErr_SetString(PyExc_RuntimeError, "FITS file is NULL"); + return NULL; + } + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (fits_get_hdrspace(self->fits, &nkeys, &morekeys, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + list = PyList_New(0); + for (i = 0; i < nkeys; i++) { + + // the full card + if (fits_read_record(self->fits, i + 1, card, &status)) { + Py_XDECREF(list); + set_ioerr_string_from_status(status, self); + return NULL; + } + + // this just returns the character string stored in the header; we + // can eval in python + if (fits_read_keyn(self->fits, i + 1, keyname, value, scomment, + &status)) { + Py_XDECREF(list); + set_ioerr_string_from_status(status, self); + return NULL; + } + + ls = strlen(keyname); + tocomp = (ls < lcont) ? ls : lcont; + + is_blank_key = 0; + is_hierarch = 0; + if (ls == 0) { + is_blank_key = 1; + } else { + + // skip CONTINUE, we already read the data + if (strncmp(keyname, "CONTINUE", tocomp) == 0) { + continue; + } + + if (strncmp(keyname, "COMMENT", tocomp) == 0 || + strncmp(keyname, "HISTORY", tocomp) == 0) { + is_comment_or_history = 1; + + } else { + is_comment_or_history = 0; + + if (fits_read_key_longstr(self->fits, keyname, &longstr, + comment, &status)) { + Py_XDECREF(list); + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (strncmp(card, "HIERARCH", 8) == 0) { + is_hierarch = 1; + if (hierarch_is_string(card)) { + is_string_value = 1; + } else { + is_string_value = 0; + } + } else { + int j; + has_equals = (card[8] == '=') ? 1 : 0; + has_quote = 0; + // Look for 0 or more space characters followed by a quote + // character. + for (j = 10; j < FLEN_CARD; j++) { + if (card[j] == '\'') { + has_quote = 1; + break; + } else if (card[j] != ' ') { + break; + } + } + if (has_equals && has_quote) { + is_string_value = 1; + } else { + is_string_value = 0; + } + } + } + } + + dict = PyDict_New(); + convert_to_ascii(card); + add_string_to_dict(dict, "card_string", card); + + if (is_blank_key) { + add_none_to_dict(dict, "name"); + add_string_to_dict(dict, "value", ""); + convert_to_ascii(scomment); + add_string_to_dict(dict, "comment", scomment); + + } else if (is_comment_or_history) { + // comment or history + convert_to_ascii(scomment); + add_string_to_dict(dict, "name", keyname); + add_string_to_dict(dict, "value", scomment); + add_string_to_dict(dict, "comment", scomment); + + } else { + + if (is_hierarch) { + // if a key is hierarch, then any ascii character is allowed + // except + // *, ? and #. Thus we convert any of those (and any chars we + // find that don't correspond to something written, ascii <= 32 + // or 127) to an underscore if the key is converted, then we + // cannot parse it further with cfitsio + was_converted = + convert_keyword_to_allowed_ascii_template_and_nonascii_only( + keyname); + } else { + // for non-hierach keys, we cannot use the cfitsio functions if + // any character besides those in the fits conventions + // (A-Z,a-z,0-9,_,-) are present. Thus we flag those and store + // their values as a string if this happens. + was_converted = has_invalid_keyword_chars(keyname); + + // in order to actually store the key in the python dict, we + // have to cut out any non-ascii chars we additionally convert + // the template chars to that the fits data we make can be + // written back without error note that the check by + // has_invalid_keyword_chars is more stringent than the checks + // done here, so if any conversion is done it has already been + // flagged above. + convert_keyword_to_allowed_ascii_template_and_nonascii_only( + keyname); + } + add_string_to_dict(dict, "name", keyname); + convert_to_ascii(comment); + add_string_to_dict(dict, "comment", comment); + + // if not a comment but empty value, put in None + tocomp = (ls < lcomm) ? ls : lcomm; + // if (!is_string_value && 0==strlen(longstr) && !is_comment) { + if (!is_string_value && 0 == strlen(longstr)) { + + add_none_to_dict(dict, "value"); + + } else { + + // if it's a string we just store it. + if (is_string_value) { + convert_to_ascii(longstr); + add_string_to_dict(dict, "value", longstr); + } else if (longstr[0] == 'T') { + add_true_to_dict(dict, "value"); + } else if (longstr[0] == 'F') { + add_false_to_dict(dict, "value"); + } else if (was_converted) { + // if we had to convert bad characters in the keyword name + // we can't attempt to get a numerical value using + // fits_read_key because some characters in a keyword name + // cause a seg fault + convert_to_ascii(longstr); + add_string_to_dict(dict, "value", longstr); + } else if ((strchr(longstr, '.') != NULL) || + (strchr(longstr, 'E') != NULL) || + (strchr(longstr, 'e') != NULL)) { + // we found a floating point value + fits_read_key(self->fits, TDOUBLE, keyname, &dval, comment, + &status); + add_double_to_dict(dict, "value", dval); + } else { + + // we might have found an integer + if (fits_read_key(self->fits, TLONGLONG, keyname, &lval, + comment, &status)) { + + // something non standard, just store it as a string + convert_to_ascii(longstr); + add_string_to_dict(dict, "value", longstr); + status = 0; + + } else { + add_long_long_to_dict(dict, "value", (long long)lval); + } + } + } + } + + free(longstr); + longstr = NULL; + + PyList_Append(list, dict); + Py_XDECREF(dict); + } + + return list; +} + +static PyObject *PyFITSObject_write_checksum(struct PyFITSObject *self, + PyObject *args) { + int status = 0; + int hdunum = 0; + int hdutype = 0; + + unsigned long datasum = 0; + unsigned long hdusum = 0; + + PyObject *dict = NULL; + + if (!PyArg_ParseTuple(args, (char *)"i", &hdunum)) { + return NULL; + } + + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (fits_write_chksum(self->fits, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + if (fits_get_chksum(self->fits, &datasum, &hdusum, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + dict = PyDict_New(); + add_long_long_to_dict(dict, "datasum", (long long)datasum); + add_long_long_to_dict(dict, "hdusum", (long long)hdusum); + + return dict; +} +static PyObject *PyFITSObject_verify_checksum(struct PyFITSObject *self, + PyObject *args) { + int status = 0; + int hdunum = 0; + int hdutype = 0; + + int dataok = 0, hduok = 0; + + PyObject *dict = NULL; + + if (!PyArg_ParseTuple(args, (char *)"i", &hdunum)) { + return NULL; + } + + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + if (fits_verify_chksum(self->fits, &dataok, &hduok, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + dict = PyDict_New(); + add_long_to_dict(dict, "dataok", (long)dataok); + add_long_to_dict(dict, "hduok", (long)hduok); + + return dict; +} + +static PyObject *PyFITSObject_where(struct PyFITSObject *self, PyObject *args) { + int status = 0; + int hdunum = 0; + int hdutype = 0; + char *expression = NULL; + + long firstrow; + long nrows; + long ngood = 0; + char *row_status = NULL; + + // Indices of rows for which expression is true + PyObject *indices_obj = NULL; + int ndim = 1; + npy_intp dims[1]; + npy_intp *data = NULL; + long i = 0; + + if (!PyArg_ParseTuple(args, (char *)"isll", &hdunum, &expression, &firstrow, + &nrows)) { + return NULL; + } + + if (firstrow < 1 || nrows < 1) { + PyErr_SetString(PyExc_ValueError, + "firstrow and nrows must be positive integers"); + return NULL; + } + + if (fits_movabs_hdu(self->fits, hdunum, &hdutype, &status)) { + set_ioerr_string_from_status(status, self); + return NULL; + } + + row_status = malloc(nrows * sizeof(char)); + if (row_status == NULL) { + PyErr_SetString(PyExc_MemoryError, + "Could not allocate row_status array"); + return NULL; + } + + if (fits_find_rows(self->fits, expression, firstrow, nrows, &ngood, + row_status, &status)) { + set_ioerr_string_from_status(status, self); + goto where_function_cleanup; + } + + dims[0] = ngood; + indices_obj = PyArray_EMPTY(ndim, dims, NPY_INTP, 0); + if (indices_obj == NULL) { + PyErr_SetString(PyExc_MemoryError, "Could not allocate index array"); + goto where_function_cleanup; + } + + if (ngood > 0) { + data = PyArray_DATA((PyArrayObject *)indices_obj); + + for (i = 0; i < nrows; i++) { + if (row_status[i]) { + *data = (npy_intp)i; + data++; + } + } + } +where_function_cleanup: + free(row_status); + return indices_obj; +} + +// generic functions, not tied to an object + +static PyObject *PyFITS_cfitsio_version(void) { + float version = 0; + fits_get_version(&version); + return PyFloat_FromDouble((double)version); +} + +static PyObject *PyFITS_cfitsio_is_bundled(void) { +#ifdef FITSIO_USING_SYSTEM_FITSIO + Py_RETURN_FALSE; +#else + Py_RETURN_TRUE; +#endif +} + +static PyObject *PyFITS_cfitsio_has_bzip2_support(void) { +#ifdef FITSIO_HAS_BZIP2_SUPPORT + Py_RETURN_TRUE; +#else + Py_RETURN_FALSE; +#endif +} + +static PyObject *PyFITS_cfitsio_has_curl_support(void) { +#ifdef FITSIO_HAS_CURL_SUPPORT + Py_RETURN_TRUE; +#else + Py_RETURN_FALSE; +#endif +} + +static PyObject *PyFITS_cfitsio_null_value_for_nan(void) { + return PyFloat_FromDouble((double)INFINITY); +} + +/* + +'C', 'L', 'I', 'F' 'X' +character string, logical, integer, floating point, complex + +*/ + +static PyObject *PyFITS_get_keytype(PyObject *self, PyObject *args) { + + int status = 0; + char *card = NULL; + char dtype[2] = {0}; + + if (!PyArg_ParseTuple(args, (char *)"s", &card)) { + return NULL; + } + + if (fits_get_keytype(card, dtype, &status)) { + set_ioerr_string_from_status(status, NULL); + return NULL; + } else { + return Py_BuildValue("s", dtype); + } +} +static PyObject *PyFITS_get_key_meta(PyObject *self, PyObject *args) { + + int status = 0; + char *card = NULL; + char dtype[2] = {0}; + int keyclass = 0; + + if (!PyArg_ParseTuple(args, (char *)"s", &card)) { + return NULL; + } + + keyclass = fits_get_keyclass(card); + + if (fits_get_keytype(card, dtype, &status)) { + set_ioerr_string_from_status(status, NULL); + return NULL; + } + + return Py_BuildValue("is", keyclass, dtype); +} + +/* + + note the special first four comment fields will not be called comment but + structural! That will cause an exception to be raised, so the card should + be checked before calling this function + +*/ + +static PyObject *PyFITS_parse_card(PyObject *self, PyObject *args) { + + int status = 0; + char name[FLEN_VALUE] = {0}; + char value[FLEN_VALUE] = {0}; + char comment[FLEN_COMMENT] = {0}; + int keylen = 0; + int keyclass = 0; + int is_undefined = 0; + + char *card = NULL; + char dtype[2] = {0}; + PyObject *output = NULL; + + if (!PyArg_ParseTuple(args, (char *)"s", &card)) { + goto bail; + } + + keyclass = fits_get_keyclass(card); + + // only proceed if not comment or history, but note the special first four + // comment fields will not be called comment but structural! That will + // cause an exception to be raised, so the card should be checked before + // calling this function + + if (keyclass != TYP_COMM_KEY && keyclass != TYP_CONT_KEY) { + + if (fits_get_keyname(card, name, &keylen, &status)) { + set_ioerr_string_from_status(status, NULL); + goto bail; + } + if (fits_parse_value(card, value, comment, &status)) { + set_ioerr_string_from_status(status, NULL); + goto bail; + } + if (fits_get_keytype(value, dtype, &status)) { + + if (status == VALUE_UNDEFINED) { + is_undefined = 1; + status = 0; + } else { + set_ioerr_string_from_status(status, NULL); + goto bail; + } + } + } + +bail: + if (status != 0) { + return NULL; + } + + if (is_undefined) { + output = Py_BuildValue("isss", keyclass, name, dtype, comment); + } else { + output = Py_BuildValue("issss", keyclass, name, value, dtype, comment); + } + return output; +} + +static PyMethodDef PyFITSObject_methods[] = { + {"filename", (PyCFunction)PyFITSObject_filename, METH_VARARGS, + "filename\n\nReturn the name of the file."}, + + {"where", (PyCFunction)PyFITSObject_where, METH_VARARGS, + "where\n\nReturn an index array where the input expression evaluates to " + "true."}, + + {"movabs_hdu", (PyCFunction)PyFITSObject_movabs_hdu, METH_VARARGS, + "movabs_hdu\n\nMove to the specified HDU."}, + {"movnam_hdu", (PyCFunction)PyFITSObject_movnam_hdu, METH_VARARGS, + "movnam_hdu\n\nMove to the specified HDU by name and return the hdu " + "number."}, + + {"get_hdu_name_version", (PyCFunction)PyFITSObject_get_hdu_name_version, + METH_VARARGS, "get_hdu_name_version\n\nReturn a tuple (extname,extvers)."}, + {"get_hdu_info", (PyCFunction)PyFITSObject_get_hdu_info, METH_VARARGS, + "get_hdu_info\n\nReturn a dict with info about the specified HDU."}, + {"read_raw", (PyCFunction)PyFITSObject_read_raw, METH_NOARGS, + "read_raw\n\nRead the entire raw contents of the FITS file, returning a " + "python string."}, + {"read_image", (PyCFunction)PyFITSObject_read_image, METH_VARARGS, + "read_image\n\nRead the entire n-dimensional image array. No checking of " + "array is done."}, + {"read_image_slice", (PyCFunction)PyFITSObject_read_image_slice, + METH_VARARGS, "read_image_slice\n\nRead an image slice."}, + {"read_column", (PyCFunction)PyFITSObject_read_column, METH_VARARGS, + "read_column\n\nRead the column into the input array. No checking of " + "array is done."}, + {"read_var_column_as_list", + (PyCFunction)PyFITSObject_read_var_column_as_list, METH_VARARGS, + "read_var_column_as_list\n\nRead the variable length column as a list of " + "arrays."}, + {"read_columns_as_rec", (PyCFunction)PyFITSObject_read_columns_as_rec, + METH_VARARGS, + "read_columns_as_rec\n\nRead the specified columns into the input rec " + "array. No checking of array is done."}, + {"read_columns_as_rec_byoffset", + (PyCFunction)PyFITSObject_read_columns_as_rec_byoffset, METH_VARARGS, + "read_columns_as_rec_byoffset\n\nRead the specified columns into the " + "input rec array at the specified offsets. No checking of array is " + "done."}, + {"read_rows_as_rec", (PyCFunction)PyFITSObject_read_rows_as_rec, + METH_VARARGS, + "read_rows_as_rec\n\nRead the subset of rows into the input rec array. " + "No checking of array is done."}, + {"read_as_rec", (PyCFunction)PyFITSObject_read_as_rec, METH_VARARGS, + "read_as_rec\n\nRead a set of rows into the input rec array. No " + "significant checking of array is done."}, + {"read_header", (PyCFunction)PyFITSObject_read_header, + METH_VARARGS | METH_VARARGS, + "read_header\n\nRead the entire header as a list of dictionaries."}, + + {"create_image_hdu", (PyCFunction)PyFITSObject_create_image_hdu, + METH_VARARGS | METH_KEYWORDS, + "create_image_hdu\n\nWrite the input image to a new extension."}, + {"create_table_hdu", (PyCFunction)PyFITSObject_create_table_hdu, + METH_VARARGS | METH_KEYWORDS, + "create_table_hdu\n\nCreate a new table with the input parameters."}, + {"insert_col", (PyCFunction)PyFITSObject_insert_col, + METH_VARARGS | METH_KEYWORDS, "insert_col\n\nInsert a new column."}, + + {"write_checksum", (PyCFunction)PyFITSObject_write_checksum, METH_VARARGS, + "write_checksum\n\nCompute and write the checksums into the header."}, + {"verify_checksum", (PyCFunction)PyFITSObject_verify_checksum, METH_VARARGS, + "verify_checksum\n\nReturn a dict with dataok and hduok."}, + + {"reshape_image", (PyCFunction)PyFITSObject_reshape_image, METH_VARARGS, + "reshape_image\n\nReshape the image."}, + {"write_image", (PyCFunction)PyFITSObject_write_image, METH_VARARGS, + "write_image\n\nWrite the input image to a new extension."}, + {"write_subset", (PyCFunction)PyFITSObject_write_subset, METH_VARARGS, + "write_subset\n\nWrite a rectangular subset to the image."}, + //{"write_column", (PyCFunction)PyFITSObject_write_column, + // METH_VARARGS | METH_KEYWORDS, "write_column\n\nWrite a column into the + // specified hdu."}, + {"write_columns", (PyCFunction)PyFITSObject_write_columns, + METH_VARARGS | METH_KEYWORDS, + "write_columns\n\nWrite columns into the specified hdu."}, + {"write_var_column", (PyCFunction)PyFITSObject_write_var_column, + METH_VARARGS | METH_KEYWORDS, + "write_var_column\n\nWrite a variable length column into the specified " + "hdu from an object array."}, + {"write_record", (PyCFunction)PyFITSObject_write_record, METH_VARARGS, + "write_record\n\nWrite a header card."}, + {"write_string_key", (PyCFunction)PyFITSObject_write_string_key, + METH_VARARGS, + "write_string_key\n\nWrite a string key into the specified HDU."}, + {"write_double_key", (PyCFunction)PyFITSObject_write_double_key, + METH_VARARGS, + "write_double_key\n\nWrite a double key into the specified HDU."}, + + {"write_long_long_key", (PyCFunction)PyFITSObject_write_long_long_key, + METH_VARARGS, + "write_long_long_key\n\nWrite a long long key into the specified HDU."}, + {"write_logical_key", (PyCFunction)PyFITSObject_write_logical_key, + METH_VARARGS, + "write_logical_key\n\nWrite a logical key into the specified HDU."}, + + {"write_comment", (PyCFunction)PyFITSObject_write_comment, METH_VARARGS, + "write_comment\n\nWrite a comment into the header of the specified HDU."}, + {"write_history", (PyCFunction)PyFITSObject_write_history, METH_VARARGS, + "write_history\n\nWrite history into the header of the specified HDU."}, + {"write_continue", (PyCFunction)PyFITSObject_write_continue, METH_VARARGS, + "write_continue\n\nWrite contineu into the header of the specified HDU."}, + + {"write_undefined_key", (PyCFunction)PyFITSObject_write_undefined_key, + METH_VARARGS, + "write_undefined_key\n\nWrite a key without a value field into the header " + "of the specified HDU."}, + + {"delete_key", (PyCFunction)PyFITSObject_delete_key, METH_VARARGS, + "delete_key\n\nDeleta a key from the header of the specified HDU."}, + + {"insert_rows", (PyCFunction)PyFITSObject_insert_rows, METH_VARARGS, + "Insert blank rows"}, + + {"delete_row_range", (PyCFunction)PyFITSObject_delete_row_range, + METH_VARARGS, "Delete a range of rows"}, + {"delete_rows", (PyCFunction)PyFITSObject_delete_rows, METH_VARARGS, + "Delete a set of rows"}, + + {"close", (PyCFunction)PyFITSObject_close, METH_VARARGS, + "close\n\nClose the fits file."}, + {NULL} /* Sentinel */ +}; + +static PyTypeObject PyFITSType = { +#if PY_MAJOR_VERSION >= 3 + PyVarObject_HEAD_INIT(NULL, 0) +#else + PyObject_HEAD_INIT(NULL) 0, /*ob_size*/ +#endif + "_fitsio.FITS", /*tp_name*/ + sizeof(struct PyFITSObject), /*tp_basicsize*/ + 0, /*tp_itemsize*/ + (destructor)PyFITSObject_dealloc, /*tp_dealloc*/ + 0, /*tp_print*/ + 0, /*tp_getattr*/ + 0, /*tp_setattr*/ + 0, /*tp_compare*/ + // 0, /*tp_repr*/ + (reprfunc)PyFITSObject_repr, /*tp_repr*/ + 0, /*tp_as_number*/ + 0, /*tp_as_sequence*/ + 0, /*tp_as_mapping*/ + 0, /*tp_hash */ + 0, /*tp_call*/ + 0, /*tp_str*/ + 0, /*tp_getattro*/ + 0, /*tp_setattro*/ + 0, /*tp_as_buffer*/ + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /*tp_flags*/ + "FITSIO Class", /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + PyFITSObject_methods, /* tp_methods */ + 0, /* tp_members */ + 0, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + // 0, /* tp_init */ + (initproc)PyFITSObject_init, /* tp_init */ + 0, /* tp_alloc */ + // PyFITSObject_new, /* tp_new */ + PyType_GenericNew, /* tp_new */ +}; + +static PyMethodDef fitstype_methods[] = { + {"cfitsio_version", (PyCFunction)PyFITS_cfitsio_version, METH_NOARGS, + "cfitsio_version\n\nReturn the cfitsio version."}, + {"cfitsio_is_bundled", (PyCFunction)PyFITS_cfitsio_is_bundled, METH_NOARGS, + "cfitsio_is_bundled\n\nReturn True if library was built with a bundled " + "copy of cfitsio."}, + {"cfitsio_has_bzip2_support", (PyCFunction)PyFITS_cfitsio_has_bzip2_support, + METH_NOARGS, + "cfitsio_has_bzip2_support\n\nReturn True if cfitsio has support for " + "bzip2."}, + {"cfitsio_has_curl_support", (PyCFunction)PyFITS_cfitsio_has_curl_support, + METH_NOARGS, + "cfitsio_has_curl_support\n\nReturn True if cfitsio has support for " + "curl."}, + {"cfitsio_null_value_for_nan", + (PyCFunction)PyFITS_cfitsio_null_value_for_nan, METH_NOARGS, + "cfitsio_null_value_for_nan\n\nReturn our default null value for " + "floats, which is INFINITY and/or np.inf"}, + {"parse_card", (PyCFunction)PyFITS_parse_card, METH_VARARGS, + "parse_card\n\nparse the card to get the key name, value (as a string), " + "data type and comment."}, + {"get_keytype", (PyCFunction)PyFITS_get_keytype, METH_VARARGS, + "get_keytype\n\nparse the card to get the key type."}, + {"get_key_meta", (PyCFunction)PyFITS_get_key_meta, METH_VARARGS, + "get_key_meta\n\nparse the card to get key metadata (keyclass,dtype)."}, + {NULL} /* Sentinel */ +}; + +#if PY_MAJOR_VERSION >= 3 +static struct PyModuleDef moduledef = { + PyModuleDef_HEAD_INIT, + "_fitsio_wrap", /* m_name */ + "Defines the FITS class and some methods", /* m_doc */ + -1, /* m_size */ + fitstype_methods, /* m_methods */ + NULL, /* m_reload */ + NULL, /* m_traverse */ + NULL, /* m_clear */ + NULL, /* m_free */ +}; +#endif + +#ifndef PyMODINIT_FUNC /* declarations for DLL import/export */ +#define PyMODINIT_FUNC void +#endif +PyMODINIT_FUNC +#if PY_MAJOR_VERSION >= 3 +PyInit__fitsio_wrap(void) +#else +init_fitsio_wrap(void) +#endif +{ + PyObject *m; + + PyFITSType.tp_new = PyType_GenericNew; + +#if PY_MAJOR_VERSION >= 3 + if (PyType_Ready(&PyFITSType) < 0) { + return NULL; + } + m = PyModule_Create(&moduledef); + if (m == NULL) { + return NULL; + } + +#else + if (PyType_Ready(&PyFITSType) < 0) { + return; + } + m = Py_InitModule3("_fitsio_wrap", fitstype_methods, + "Define FITS type and methods."); + if (m == NULL) { + return; + } +#endif + + Py_INCREF(&PyFITSType); + PyModule_AddObject(m, "FITS", (PyObject *)&PyFITSType); + + import_array(); +#if PY_MAJOR_VERSION >= 3 + return m; +#endif +} diff --git a/fitsio/fitslib.py b/fitsio/fitslib.py new file mode 100644 index 0000000..149eb46 --- /dev/null +++ b/fitsio/fitslib.py @@ -0,0 +1,2119 @@ +""" +fitslib, part of the fitsio package. + +See the main docs at https://github.com/esheldon/fitsio + + Copyright (C) 2011 Erin Sheldon, BNL. erin dot sheldon at gmail dot com + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +""" + +from __future__ import with_statement, print_function +import os +import numpy + +from . import _fitsio_wrap +from .util import ( + IS_PY3, + mks, + array_to_native, + isstring, + copy_if_needed, + _nonfinite_as_cfitsio_floating_null_value, +) +from .header import FITSHDR +from .hdu import ( + ANY_HDU, + IMAGE_HDU, + BINARY_TBL, + ASCII_TBL, + ImageHDU, + AsciiTableHDU, + TableHDU, + _table_npy2fits_form, + _npy2fits, + _hdu_type_map, +) + +from .fits_exceptions import FITSFormatError + +# for python3 compat +if IS_PY3: + xrange = range + + +READONLY = 0 +READWRITE = 1 + +# this constant is used to indicate +# that an option is not set in Python +# and instead the setting for that option +# is delegated to the C code in cfitsio +NOT_SET = "NOT_SET" + +NOCOMPRESS = 0 +RICE_1 = 11 +GZIP_1 = 21 +GZIP_2 = 22 +PLIO_1 = 31 +HCOMPRESS_1 = 41 + +NO_DITHER = -1 +SUBTRACTIVE_DITHER_1 = 1 +SUBTRACTIVE_DITHER_2 = 2 + + +def read( + filename, + ext=None, + extver=None, + columns=None, + rows=None, + header=False, + case_sensitive=False, + upper=False, + lower=False, + vstorage='fixed', + verbose=False, + trim_strings=False, + **keys, +): + """ + Convenience function to read data from the specified FITS HDU + + By default, all data are read. For tables, send columns= and rows= to + select subsets of the data. Table data are read into a recarray; use a + FITS object and read_column() to get a single column as an ordinary array. + For images, create a FITS object and use slice notation to read subsets. + + Under the hood, a FITS object is constructed and data are read using + an associated FITSHDU object. + + parameters + ---------- + filename: string + A filename. + ext: number or string, optional + The extension. Either the numerical extension from zero + or a string extension name. If not sent, data is read from + the first HDU that has data. + extver: integer, optional + FITS allows multiple extensions to have the same name (extname). These + extensions can optionally specify an EXTVER version number in the + header. Send extver= to select a particular version. If extver is not + sent, the first one will be selected. If ext is an integer, the extver + is ignored. + columns: list or array, optional + An optional set of columns to read from table HDUs. Default is to + read all. Can be string or number. + rows: optional + An optional list of rows to read from table HDUS. Default is to + read all. + header: bool, optional + If True, read the FITS header and return a tuple (data,header) + Default is False. + case_sensitive: bool, optional + Match column names and extension names with case-sensitivity. Default + is False. + lower: bool, optional + If True, force all columns names to lower case in output. Default is + False. + upper: bool, optional + If True, force all columns names to upper case in output. Default is + False. + vstorage: string, optional + Set the default method to store variable length columns. Can be + 'fixed' or 'object'. See docs on fitsio.FITS for details. Default is + 'fixed'. + trim_strings: bool, optional + If True, trim trailing spaces from strings. Will over-ride the + trim_strings= keyword from constructor. + verbose: bool, optional + If True, print more info when doing various FITS operations. + """ + + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + kwargs = { + 'lower': lower, + 'upper': upper, + 'vstorage': vstorage, + 'case_sensitive': case_sensitive, + 'verbose': verbose, + 'trim_strings': trim_strings, + } + + read_kwargs = {} + if columns is not None: + read_kwargs['columns'] = columns + if rows is not None: + read_kwargs['rows'] = rows + + with FITS(filename, **kwargs) as fits: + if ext is None: + for i in xrange(len(fits)): + if fits[i].has_data(): + ext = i + break + if ext is None: + raise IOError("No extensions have data") + + item = _make_item(ext, extver=extver) + + data = fits[item].read(**read_kwargs) + if header: + h = fits[item].read_header() + return data, h + else: + return data + + +def read_header(filename, ext=0, extver=None, case_sensitive=False, **keys): + """ + Convenience function to read the header from the specified FITS HDU + + The FITSHDR allows access to the values and comments by name and + number. + + parameters + ---------- + filename: string + A filename. + ext: number or string, optional + The extension. Either the numerical extension from zero + or a string extension name. Default read primary header. + extver: integer, optional + FITS allows multiple extensions to have the same name (extname). These + extensions can optionally specify an EXTVER version number in the + header. Send extver= to select a particular version. If extver is not + sent, the first one will be selected. If ext is an integer, the extver + is ignored. + case_sensitive: bool, optional + Match extension names with case-sensitivity. Default is False. + """ + + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + filename = extract_filename(filename) + + dont_create = 0 + try: + hdunum = ext + 1 + except TypeError: + hdunum = None + + _fits = _fitsio_wrap.FITS(filename, READONLY, dont_create) + + if hdunum is None: + extname = mks(ext) + if extver is None: + extver_num = 0 + else: + extver_num = extver + + if not case_sensitive: + # the builtin movnam_hdu is not case sensitive + hdunum = _fits.movnam_hdu(ANY_HDU, extname, extver_num) + else: + # for case sensitivity we'll need to run through + # all the hdus + found = False + current_ext = 0 + while True: + hdunum = current_ext + 1 + try: + hdu_type = _fits.movabs_hdu(hdunum) # noqa - not used + name, vers = _fits.get_hdu_name_version(hdunum) + if name == extname: + if extver is None: + # take the first match + found = True + break + else: + if extver_num == vers: + found = True + break + except OSError: + break + + current_ext += 1 + + if not found: + raise IOError( + 'hdu not found: %s (extver %s)' % (extname, extver) + ) + + return FITSHDR(_fits.read_header(hdunum)) + + +def read_scamp_head(fname, header=None): + """ + read a SCAMP .head file as a fits header FITSHDR object + + parameters + ---------- + fname: string + The path to the SCAMP .head file + + header: FITSHDR, optional + Optionally combine the header with the input one. The input can + be any object convertable to a FITSHDR object + + returns + ------- + header: FITSHDR + A fits header object of type FITSHDR + """ + + with open(fname) as fobj: + lines = fobj.readlines() + + lines = [line.strip() for line in lines if line[0:3] != 'END'] + + # if header is None an empty FITSHDR is created + hdr = FITSHDR(header) + + for line in lines: + hdr.add_record(line) + + return hdr + + +def _make_item(ext, extver=None): + if extver is not None: + # e + item = (ext, extver) + else: + item = ext + + return item + + +class _DocStringFormatter(dict): + """A class to manager docstring snippets. + + This is a simpler version of the _SnippetManager + from proplot/ultraplot + """ + + def __call__(self, func_or_meth): + import inspect + + func_or_meth.__doc__ = inspect.getdoc(func_or_meth) + if func_or_meth.__doc__: + func_or_meth.__doc__ %= self + + return func_or_meth + + def __setitem__(self, key, value): + super().__setitem__(key, value.strip("\n")) + + +_doc_string_formatter = _DocStringFormatter() +_doc_string_formatter["compression_docs"] = """\ +compress: string, optional + A string representing the compression algorithm for images. + Default of fitsio.NOT_SET defers the setting to the default + of the cfitsio library (no compression) or to the value set + in the FITS file extended filename syntax (e.g., + `myfile.fits[compress G]`). + For no compression, pass None, fitsio.NOCOMPRESS, or 0. + For compression, can be one of + 'RICE' + 'GZIP' + 'GZIP_2' + 'PLIO' (no unsigned or negative integers) + 'HCOMPRESS' + (case-insensitive). See the cfitsio manual for details. +tile_dims: tuple of ints, optional + The size of the tiles used to compress images, specified in + Fortran/column-major order (e.g., `(Y_SIZE, X_SIZE)`). Default of + fitsio.NOT_SET defers the setting to the cfitsio/fpack (row-by-row) + or to the value set in the FITS file extended filename syntax (e.g., + `myfile.fits[compress G 100,100]`). The value None behaves the same as + fitsio.NOT_SET +qlevel: float, optional + Quantization level for floating point data. Lower generally result in + more compression, we recommend one reads the FITS standard or cfitsio + manual to fully understand the effects of quantization. None or 0 + means no quantization, and for gzip also implies lossless. Default of + fitsio.NOT_SET defers to the cfitsio/fpack default (usually 4.0) or + to the value set in the FITS file extended filename syntax (e.g., + `myfile.fits[compress G; q 10.0]`). +qmethod: string or int + The quantization method as string or integer. + 'NO_DITHER' or fitsio.NO_DITHER (-1) + No dithering is performed + 'SUBTRACTIVE_DITHER_1' or fitsio.SUBTRACTIVE_DITHER_1 (1) + Standard dithering + 'SUBTRACTIVE_DITHER_2' or fitsio.SUBTRACTIVE_DITHER_2 (2) + Preserves zeros + Default of fitsio.NOT_SET defers to the cfitsio/fpack default ( + 'SUBTRACTIVE_DITHER_1') or to the value set in the FITS file + extended filename syntax (e.g.,. `myfile.fits[compress R; qz]`). +dither_seed: int or None, optional + Seed for the subtractive dither. Seeding makes the lossy compression + reproducible. Allowed values are + fitsio.NOT_SET + defer the setting to the cfitsio/fpack library default + (system clock) + None or 0 or 'clock': + do not set the seed explicitly, use the system clock + negative or 'checksum': + set the seed based on the data checksum + 1-10_000: + use the input seed +hcomp_scale: float, optional + Scale value for HCOMPRESS, 0.0 means lossless compression. Default + of fitsio.NOT_SET defers to the cfitsio/fpack default (1.0) or + to the value set in the FITS file extended filename syntax (e.g., + `myfile.fits[compress H 10,10; s 10]`). +hcomp_smooth: bool, optional + If True, apply smoothing when decompressing, otherwise if False do not. + Default of fitsio.NOT_SET defers to the cfitsio/fpack default (False) or + to the value set in the FITS file extended filename syntax (e.g., + `myfile.fits[compress HS 10,10; s 10]`). + +**If the FITS file uses the extended filename syntax to set any compression +paraneters (e.g. `myfile.fits[compress R]`), then the those parameters +are treated as immutable defaults. If you set any of the Python keyword +compression parameters (i.e., compress, tile_dims, qlevel, qmethod, +hcomp_scale, hcomp_smooth), then the code will raise a ValueError. However, +the dither_seed can be set since it is not possible to set it via the +extended filename syntax.** +""" + + +@_doc_string_formatter +def write( + filename, + data, + extname=None, + extver=None, + header=None, + clobber=False, + ignore_empty=False, + units=None, + table_type='binary', + names=None, + write_bitcols=False, + compress=NOT_SET, + tile_dims=NOT_SET, + qlevel=NOT_SET, + qmethod=NOT_SET, + dither_seed=NOT_SET, + hcomp_scale=NOT_SET, + hcomp_smooth=NOT_SET, + **keys, +): + """ + Convenience function to create a new HDU and write the data. + + Under the hood, a FITS object is constructed. If you want to append rows + to an existing HDU, or modify data in an HDU, please construct a FITS + object. + + parameters + ---------- + filename: string + A filename. + data: numpy.ndarray or recarray + Either a normal n-dimensional array or a recarray. Images are written + to a new IMAGE_HDU and recarrays are written to BINARY_TBl or + ASCII_TBL hdus. + extname: string, optional + An optional name for the new header unit. + extver: integer, optional + FITS allows multiple extensions to have the same name (extname). + These extensions can optionally specify an EXTVER version number in + the header. Send extver= to set a particular version, which will + be represented in the header with keyname EXTVER. The extver must + be an integer > 0. If extver is not sent, the first one will be + selected. If ext is an integer, the extver is ignored. + header: FITSHDR, list, dict, optional + A set of header keys to write. The keys are written before the data + is written to the table, preventing a resizing of the table area. + + Can be one of these: + - FITSHDR object + - list of dictionaries containing 'name','value' and optionally + a 'comment' field; the order is preserved. + - a dictionary of keyword-value pairs; no comments are written + in this case, and the order is arbitrary. + Note required keywords such as NAXIS, XTENSION, etc are cleaed out. + clobber: bool, optional + If True, overwrite any existing file. Default is to append + a new extension on existing files. + ignore_empty: bool, optional + Default False. Unless set to True, only allow + empty HDUs in the zero extension. + + table-only keywords + ------------------- + units: list + A list of strings representing units for each column. + table_type: string, optional + Either 'binary' or 'ascii', default 'binary' + Matching is case-insensitive + write_bitcols: bool, optional + Write boolean arrays in the FITS bitcols format, default False + names: list, optional + If data is a list of arrays, you must send `names` as a list + of names or column numbers. + + image-only keywords + ------------------- + %(compression_docs)s + """ + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + kwargs = {'clobber': clobber, 'ignore_empty': ignore_empty} + with FITS(filename, 'rw', **kwargs) as fits: + fits.write( + data, + table_type=table_type, + units=units, + extname=extname, + extver=extver, + header=header, + names=names, + write_bitcols=write_bitcols, + compress=compress, + tile_dims=tile_dims, + qlevel=qlevel, + qmethod=qmethod, + dither_seed=dither_seed, + hcomp_scale=hcomp_scale, + hcomp_smooth=hcomp_smooth, + ) + + +class FITS(object): + """ + A class to read and write FITS images and tables. + + This class uses the cfitsio library for almost all relevant work. + + parameters + ---------- + filename: string + The filename to open. + mode: int/string, optional + The mode, either a string or integer. + For reading only + 'r' or 0 + For reading and writing + 'rw' or 1 + You can also use fitsio.READONLY and fitsio.READWRITE. + + Default is 'r' + clobber: bool, optional + If the mode is READWRITE, and clobber=True, then remove any existing + file before opening. + case_sensitive: bool, optional + Match column names and extension names with case-sensitivity. Default + is False. + lower: bool, optional + If True, force all columns names to lower case in output + upper: bool, optional + If True, force all columns names to upper case in output + vstorage: string, optional + A string describing how, by default, to store variable length columns + in the output array. This can be over-ridden when reading by using the + using vstorage keyword to the individual read methods. The options are + + 'fixed': Use a fixed length field in the array, with + dimensions equal to the max possible size for column. + Arrays are padded with zeros. + 'object': Use an object for the field in the array. + Each element will then be an array of the right type, + but only using the memory needed to hold that element. + + Default is 'fixed'. The rationale is that this is the option + of 'least surprise' + iter_row_buffer: integer + Number of rows to buffer when iterating over table HDUs. + Default is 1. + ignore_empty: bool, optional + Default False. Unless set to True, only allow + empty HDUs in the zero extension. + verbose: bool, optional + If True, print more info when doing various FITS operations. + + See the docs at https://github.com/esheldon/fitsio + """ + + def __init__( + self, + filename, + mode='r', + lower=False, + upper=False, + trim_strings=False, + vstorage='fixed', + case_sensitive=False, + iter_row_buffer=1, + write_bitcols=False, + ignore_empty=False, + verbose=False, + clobber=False, + **keys, + ): + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + self.lower = lower + self.upper = upper + self.trim_strings = trim_strings + self.vstorage = vstorage + self.case_sensitive = case_sensitive + self.iter_row_buffer = iter_row_buffer + self.write_bitcols = write_bitcols + filename = extract_filename(filename) + self._filename = filename + + # self.mode=keys.get('mode','r') + self.mode = mode + self.ignore_empty = ignore_empty + + self.verbose = verbose + + if self.mode not in _int_modemap: + raise IOError( + "mode should be one of 'r', 'rw', READONLY,READWRITE" + ) + + self.charmode = _char_modemap[self.mode] + self.intmode = _int_modemap[self.mode] + + # Will not test existence when reading, let cfitsio + # do the test and report an error. This allows opening + # urls etc. + create = 0 + if self.mode in [READWRITE, 'rw']: + if clobber: + create = 1 + if filename[0] != '!': + filename = '!' + filename + else: + if os.path.exists(filename): + create = 0 + else: + create = 1 + + self._did_create = create == 1 + self._FITS = _fitsio_wrap.FITS(filename, self.intmode, create) + + def close(self): + """ + Close the fits file and set relevant metadata to None + """ + if hasattr(self, '_FITS'): + if self._FITS is not None: + self._FITS.close() + self._FITS = None + self._filename = None + self.mode = None + self.charmode = None + self.intmode = None + self.hdu_list = None + self.hdu_map = None + + def movabs_ext(self, ext): + """ + Move to the indicated zero-offset extension. + + In general, it is not necessary to use this method explicitly. + """ + return self.movabs_hdu(ext + 1) + + def movabs_hdu(self, hdunum): + """ + Move to the indicated one-offset hdu number. + + In general, it is not necessary to use this method explicitly. + """ + + format_err = False + + try: + hdu_type = self._FITS.movabs_hdu(hdunum) + except IOError as err: + # to support python 2 we can't use exception chaining. + # do this to avoid "During handling of the above exception, another + # exception occurred:" + serr = str(err) + if 'first keyword not XTENSION' in serr: + format_err = True + else: + raise + + if format_err: + raise FITSFormatError(serr) + + return hdu_type + + def movnam_ext(self, extname, hdutype=ANY_HDU, extver=0): + """ + Move to the indicated extension by name + + In general, it is not necessary to use this method explicitly. + + returns the zero-offset extension number + """ + extname = mks(extname) + hdu = self._FITS.movnam_hdu(hdutype, extname, extver) + return hdu - 1 + + def movnam_hdu(self, extname, hdutype=ANY_HDU, extver=0): + """ + Move to the indicated HDU by name + + In general, it is not necessary to use this method explicitly. + + returns the one-offset extension number + """ + format_err = False + + extname = mks(extname) + try: + hdu = self._FITS.movnam_hdu(hdutype, extname, extver) + except IOError as err: + # to support python 2 we can't use exception chaining. + # do this to avoid "During handling of the above exception, another + # exception occurred:" + serr = str(err) + if 'first keyword not XTENSION' in serr: + format_err = True + else: + raise + + if format_err: + raise FITSFormatError(serr) + + return hdu + + def reopen(self): + """ + close and reopen the fits file with the same mode + """ + # We cannot open mem:// memory files as existing files + # (i.e., last argument of _fitsio_wrap.FITS equal to 0). + # If we open in mode 1, we will delete all of the existing data + # in the mem:// file. So we skip the close+reopen cycle for + # mem:// files. We always update the hdu list and this appears + # to be important. + if not self._filename.startswith("mem://"): + self._FITS.close() + del self._FITS + self._FITS = _fitsio_wrap.FITS(self._filename, self.intmode, 0) + self.update_hdu_list() + + @_doc_string_formatter + def write( + self, + data, + units=None, + extname=None, + extver=None, + compress=NOT_SET, + tile_dims=NOT_SET, + qlevel=NOT_SET, + qmethod=NOT_SET, + dither_seed=NOT_SET, + hcomp_scale=NOT_SET, + hcomp_smooth=NOT_SET, + header=None, + names=None, + table_type='binary', + write_bitcols=False, + **keys, + ): + """ + Write the data to a new HDU. + + This method is a wrapper. If this is an IMAGE_HDU, write_image is + called, otherwise write_table is called. + + parameters + ---------- + data: ndarray + An n-dimensional image or an array with fields. + extname: string, optional + An optional extension name. + extver: integer, optional + FITS allows multiple extensions to have the same name (extname). + These extensions can optionally specify an EXTVER version number in + the header. Send extver= to set a particular version, which will + be represented in the header with keyname EXTVER. The extver must + be an integer > 0. If extver is not sent, the first one will be + selected. If ext is an integer, the extver is ignored. + header: FITSHDR, list, dict, optional + A set of header keys to write. Can be one of these: + - FITSHDR object + - list of dictionaries containing 'name','value' and optionally + a 'comment' field; the order is preserved. + - a dictionary of keyword-value pairs; no comments are written + in this case, and the order is arbitrary. + Note required keywords such as NAXIS, XTENSION, etc are cleaed out. + + image-only keywords + ------------------- + %(compression_docs)s + + table-only keywords + ------------------- + units: list/dec, optional: + A list of strings with units for each column. + table_type: string, optional + Either 'binary' or 'ascii', default 'binary' + Matching is case-insensitive + write_bitcols: bool, optional + Write boolean arrays in the FITS bitcols format, default False + names: list, optional + If data is a list of arrays, you must send `names` as a list + of names or column numbers. + + restrictions + ------------ + The File must be opened READWRITE + """ + + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + isimage = False + if data is None: + isimage = True + elif isinstance(data, numpy.ndarray): + if data.dtype.fields == None: # noqa - probably should be is None + isimage = True + + if isimage: + self.write_image( + data, + extname=extname, + extver=extver, + compress=compress, + tile_dims=tile_dims, + qlevel=qlevel, + qmethod=qmethod, + dither_seed=dither_seed, + hcomp_scale=hcomp_scale, + hcomp_smooth=hcomp_smooth, + header=header, + ) + else: + self.write_table( + data, + units=units, + extname=extname, + extver=extver, + header=header, + names=names, + table_type=table_type, + write_bitcols=write_bitcols, + ) + + @_doc_string_formatter + def write_image( + self, + img, + extname=None, + extver=None, + compress=NOT_SET, + tile_dims=NOT_SET, + qlevel=NOT_SET, + qmethod=NOT_SET, + dither_seed=NOT_SET, + hcomp_scale=NOT_SET, + hcomp_smooth=NOT_SET, + header=None, + ): + """ + Create a new image extension and write the data. + + parameters + ---------- + img: ndarray + An n-dimensional image. + extname: string, optional + An optional extension name. + extver: integer, optional + FITS allows multiple extensions to have the same name (extname). + These extensions can optionally specify an EXTVER version number in + the header. Send extver= to set a particular version, which will + be represented in the header with keyname EXTVER. The extver must + be an integer > 0. If extver is not sent, the first one will be + selected. If ext is an integer, the extver is ignored. + header: FITSHDR, list, dict, optional + A set of header keys to write. Can be one of these: + - FITSHDR object + - list of dictionaries containing 'name','value' and optionally + a 'comment' field; the order is preserved. + - a dictionary of keyword-value pairs; no comments are written + in this case, and the order is arbitrary. + Note required keywords such as NAXIS, XTENSION, etc are cleaed out. + %(compression_docs)s + + restrictions + ------------ + The File must be opened READWRITE + """ + + self.create_image_hdu( + img, + header=header, + extname=extname, + extver=extver, + compress=compress, + tile_dims=tile_dims, + qlevel=qlevel, + qmethod=qmethod, + dither_seed=dither_seed, + hcomp_scale=hcomp_scale, + hcomp_smooth=hcomp_smooth, + ) + + if header is not None: + self[-1].write_keys(header) + + # if img is not None: + # self[-1].write(img) + + @_doc_string_formatter + def create_image_hdu( + self, + img=None, + dims=None, + dtype=None, + extname=None, + extver=None, + compress=NOT_SET, + tile_dims=NOT_SET, + qlevel=NOT_SET, + qmethod=NOT_SET, + dither_seed=NOT_SET, + hcomp_scale=NOT_SET, + hcomp_smooth=NOT_SET, + header=None, + ): + """ + Create a new, empty image HDU and reload the hdu list. Either + create from an input image or from input dims and dtype + + fits.create_image_hdu(image, ...) + fits.create_image_hdu(dims=dims, dtype=dtype) + + If an image is sent, the data are also written. + + You can write data into the new extension using + fits[extension].write(image) + + Alternatively you can skip calling this function and instead just use + + fits.write(image) + or + fits.write_image(image) + + which will create the new image extension for you with the appropriate + structure, and write the data. + + parameters + ---------- + img: ndarray, optional + An image with which to determine the properties of the HDU. The + data will be written. + dims: sequence, optional + A sequence describing the dimensions of the image to be created + on disk. You must also send a dtype= + dtype: numpy data type + When sending dims= also send the data type. Can be of the + various numpy data type declaration styles, e.g. 'f8', + numpy.float64. + extname: string, optional + An optional extension name. + extver: integer, optional + FITS allows multiple extensions to have the same name (extname). + These extensions can optionally specify an EXTVER version number in + the header. Send extver= to set a particular version, which will + be represented in the header with keyname EXTVER. The extver must + be an integer > 0. If extver is not sent, the first one will be + selected. If ext is an integer, the extver is ignored. + header: FITSHDR, list, dict, optional + This is only used to determine how many slots to reserve for + header keywords + %(compression_docs)s + + restrictions + ------------ + The File must be opened READWRITE + """ + + if (img is not None) or (img is None and dims is None): + from_image = True + elif dims is not None: + from_image = False + + if from_image: + img2send = img + if img is not None: + dims = img.shape + dtstr = img.dtype.descr[0][1][1:] + if img.size == 0: + raise ValueError("data must have at least 1 row") + + # data must be c-contiguous and native byte order + if not img.flags['C_CONTIGUOUS']: + # this always makes a copy + img2send = numpy.ascontiguousarray(img) + img2send = array_to_native(img2send, inplace=True) + else: + img2send = array_to_native(img, inplace=False) + + if IS_PY3 and img2send.dtype.char == 'U': + # for python3, we convert unicode to ascii + # this will error if the character is not in ascii + img2send = img2send.astype('S', copy=copy_if_needed) + + else: + self._ensure_empty_image_ok() + compress = None + tile_dims = None + + # we get dims from the input image + dims2send = None + else: + # img was None and dims was sent + if dtype is None: + raise ValueError("send dtype= with dims=") + + # this must work! + dtype = numpy.dtype(dtype) + dtstr = dtype.descr[0][1][1:] + # use the example image to build the type in C + img2send = numpy.zeros(1, dtype=dtype) + + # sending an array simplifies access + dims2send = numpy.array(dims, dtype='i8', ndmin=1) + + if img2send is not None: + if img2send.dtype.fields is not None: + raise ValueError( + "got record data type, expected regular ndarray" + ) + + if extname is None: + # will be ignored + extname = "" + else: + if not isstring(extname): + raise ValueError("extension name must be a string") + extname = mks(extname) + + if extname is not None and extver is not None: + extver = check_extver(extver) + + if extver is None: + # will be ignored + extver = 0 + + # if the file is using the extended filename syntax for + # compression, then we ignore any input compression params + # and raise if they are not fitsio.NOT_SET + # we do allow the dither_seed since there is no way to set + # this via the extended filename syntax + if "[compress" in self._filename.lower(): + if ( + compress != NOT_SET + or (not (isinstance(tile_dims, str) and tile_dims == NOT_SET)) + or qlevel != NOT_SET + or qmethod != NOT_SET + or hcomp_scale != NOT_SET + or hcomp_smooth != NOT_SET + ): + raise ValueError( + "You cannot override the compression parameters " + "from Python for " + "FITS files that use the extend filename syntax " + "(e.g., `myfile.fits[compress]`) for compression." + ) + + # For FITS file using the extend filename syntax for + # compression, we do not allow overrides from Python. + # The value None is equivalent to "not set" at the + # C level and will ensure no overrides are done. + comptype = None + qmethod = None + tile_dims = None + qlevel = None + hcs = None + hcomp_scale = None + else: + comptype = get_compress_type(compress) + if img2send is not None: + check_comptype_img(comptype, dtstr) + + qmethod = get_qmethod(qmethod) + tile_dims = get_tile_dims(tile_dims, dims) + + if qlevel == NOT_SET: + # in this case, we pass None since in the C layer, + # the value None means not set. + qlevel = None + elif qlevel is None: + # in the Python layer qlevel being None means no quantization. + # thus we pass 0.0 since + # it is the sentinel value for "no quantization" in cfitsio + qlevel = 0.0 + else: + qlevel = float(qlevel) + + if hcomp_smooth == NOT_SET: + hcs = None + else: + if hcomp_smooth: + hcs = 1 + else: + hcs = 0 + + if hcomp_scale == NOT_SET: + hcomp_scale = None + + # we always allow dither seed to be set + dither_seed = get_dither_seed(dither_seed) + + if header is not None: + nkeys = len(header) + else: + nkeys = 0 + + if comptype != NOT_SET or "[compress" in self._filename.lower(): + hdu_is_compressed = True + else: + hdu_is_compressed = False + + with _nonfinite_as_cfitsio_floating_null_value( + img2send, hdu_is_compressed + ) as img2send_any_nan: + img2send, any_nan = img2send_any_nan + self._FITS.create_image_hdu( + img2send, + nkeys, + dims=dims2send, + comptype=comptype, + tile_dims=tile_dims, + qlevel=qlevel, + qmethod=qmethod, + dither_seed=dither_seed, + hcomp_scale=hcomp_scale, + hcomp_smooth=hcs, + extname=extname, + extver=extver, + any_nan=1 if any_nan else 0, + ) + + self.update_hdu_list(rebuild=False) + + def _ensure_empty_image_ok(self): + """ + If ignore_empty was not set to True, we only allow empty HDU for first + HDU and if there is no data there already + """ + if self.ignore_empty: + return + + if len(self) > 1: + raise RuntimeError( + "Cannot write None image at extension %d" % len(self) + ) + if 'ndims' in self[0]._info: + raise RuntimeError( + "Can only write None images to extension zero, " + "which already exists" + ) + + def write_table( + self, + data, + table_type='binary', + names=None, + formats=None, + units=None, + extname=None, + extver=None, + header=None, + write_bitcols=False, + ): + """ + Create a new table extension and write the data. + + The table definition is taken from the fields in the input array. If + you want to append new rows to the table, access the HDU directly and + use the write() function, e.g. + + fits[extension].append(data) + + parameters + ---------- + data: recarray + A numpy array with fields. The table definition will be + determined from this array. + table_type: string, optional + Either 'binary' or 'ascii', default 'binary' + Matching is case-insensitive + extname: string, optional + An optional string for the extension name. + extver: integer, optional + FITS allows multiple extensions to have the same name (extname). + These extensions can optionally specify an EXTVER version number in + the header. Send extver= to set a particular version, which will + be represented in the header with keyname EXTVER. The extver must + be an integer > 0. If extver is not sent, the first one will be + selected. If ext is an integer, the extver is ignored. + units: list/dec, optional: + A list of strings with units for each column. + header: FITSHDR, list, dict, optional + A set of header keys to write. The keys are written before the data + is written to the table, preventing a resizing of the table area. + + Can be one of these: + - FITSHDR object + - list of dictionaries containing 'name','value' and optionally + a 'comment' field; the order is preserved. + - a dictionary of keyword-value pairs; no comments are written + in this case, and the order is arbitrary. + Note required keywords such as NAXIS, XTENSION, etc are cleaed out. + write_bitcols: boolean, optional + Write boolean arrays in the FITS bitcols format, default False + + restrictions + ------------ + The File must be opened READWRITE + """ + + """ + if data.dtype.fields == None: + raise ValueError("data must have fields") + if data.size == 0: + raise ValueError("data must have at least 1 row") + """ + + self.create_table_hdu( + data=data, + header=header, + names=names, + units=units, + extname=extname, + extver=extver, + table_type=table_type, + write_bitcols=write_bitcols, + ) + + if header is not None: + self[-1].write_keys(header) + + self[-1].write(data, names=names) + + def read_raw(self): + """ + Reads the raw FITS file contents, returning a Python string. + """ + return self._FITS.read_raw() + + def create_table_hdu( + self, + data=None, + dtype=None, + header=None, + names=None, + formats=None, + units=None, + dims=None, + extname=None, + extver=None, + table_type='binary', + write_bitcols=False, + ): + """ + Create a new, empty table extension and reload the hdu list. + + There are three ways to do it: + 1) send a numpy dtype, from which the formats in the fits file will + be determined. + 2) Send an array in data= keyword. this is required if you have + object fields for writing to variable length columns. + 3) send the names,formats and dims yourself + + You can then write data into the new extension using + fits[extension].write(array) + If you want to write to a single column + fits[extension].write_column(array) + But be careful as the other columns will be left zeroed. + + Often you will instead just use write_table to do this all + atomically. + + fits.write_table(recarray) + + write_table will create the new table extension for you with the + appropriate fields. + + parameters + ---------- + dtype: numpy dtype or descriptor, optional + If you have an array with fields, you can just send arr.dtype. You + can also use a list of tuples, e.g. [('x','f8'),('index','i4')] or + a dictionary representation. + data: a numpy array with fields, optional + or a dictionary + + An array or dict from which to determine the table definition. You + must use this instead of sending a descriptor if you have object + array fields, as this is the only way to determine the type and max + size. + + names: list of strings, optional + The list of field names + formats: list of strings, optional + The TFORM format strings for each field. + dims: list of strings, optional + An optional list of dimension strings for each field. Should + match the repeat count for the formats fields. Be careful of + the order since FITS is more like fortran. See the descr2tabledef + function. + + table_type: string, optional + Either 'binary' or 'ascii', default 'binary' + Matching is case-insensitive + units: list of strings, optional + An optional list of unit strings for each field. + extname: string, optional + An optional extension name. + extver: integer, optional + FITS allows multiple extensions to have the same name (extname). + These extensions can optionally specify an EXTVER version number in + the header. Send extver= to set a particular version, which will + be represented in the header with keyname EXTVER. The extver must + be an integer > 0. If extver is not sent, the first one will be + selected. If ext is an integer, the extver is ignored. + write_bitcols: bool, optional + Write boolean arrays in the FITS bitcols format, default False + + header: FITSHDR, list, dict, optional + This is only used to determine how many slots to reserve for + header keywords + + + restrictions + ------------ + The File must be opened READWRITE + """ + + # record this for the TableHDU object + write_bitcols = self.write_bitcols or write_bitcols + + # can leave as turn + table_type_int = _extract_table_type(table_type) + + if data is not None: + if isinstance(data, numpy.ndarray): + names, formats, dims = array2tabledef( + data, table_type=table_type, write_bitcols=write_bitcols + ) + elif isinstance(data, (list, dict)): + names, formats, dims = collection2tabledef( + data, + names=names, + table_type=table_type, + write_bitcols=write_bitcols, + ) + else: + raise ValueError( + "data must be an ndarray with fields or a dict" + ) + elif dtype is not None: + dtype = numpy.dtype(dtype) + names, formats, dims = descr2tabledef( + dtype.descr, + write_bitcols=write_bitcols, + table_type=table_type, + ) + else: + if names is None or formats is None: + raise ValueError( + "send either dtype=, data=, or names= and formats=" + ) + + if not isinstance(names, list) or not isinstance(formats, list): + raise ValueError("names and formats should be lists") + if len(names) != len(formats): + raise ValueError("names and formats must be same length") + + if dims is not None: + if not isinstance(dims, list): + raise ValueError("dims should be a list") + if len(dims) != len(names): + raise ValueError("names and dims must be same length") + + if units is not None: + if not isinstance(units, list): + raise ValueError("units should be a list") + if len(units) != len(names): + raise ValueError("names and units must be same length") + + if extname is None: + # will be ignored + extname = "" + else: + if not isstring(extname): + raise ValueError("extension name must be a string") + extname = mks(extname) + + if extname is not None and extver is not None: + extver = check_extver(extver) + if extver is None: + # will be ignored + extver = 0 + if extname is None: + # will be ignored + extname = "" + + if header is not None: + nkeys = len(header) + else: + nkeys = 0 + + # note we can create extname in the c code for tables, but not images + self._FITS.create_table_hdu( + table_type_int, + nkeys, + names, + formats, + tunit=units, + tdim=dims, + extname=extname, + extver=extver, + ) + + # don't rebuild the whole list unless this is the first hdu + # to be created + self.update_hdu_list(rebuild=False) + + def update_hdu_list(self, rebuild=True): + """ + Force an update of the entire HDU list + + Normally you don't need to call this method directly + + if rebuild is false or the hdu_list is not yet set, the list is + rebuilt from scratch + """ + if not hasattr(self, 'hdu_list'): + rebuild = True + + if rebuild: + self.hdu_list = [] + self.hdu_map = {} + + # we don't know how many hdus there are, so iterate + # until we can't open any more + ext_start = 0 + else: + # start from last + ext_start = len(self) + + ext = ext_start + while True: + try: + self._append_hdu_info(ext) + except IOError: + break + except RuntimeError: + break + + ext = ext + 1 + + def _append_hdu_info(self, ext): + """ + internal routine + + append info for indiciated extension + """ + + # raised IOError if not found + hdu_type = self.movabs_ext(ext) + + if hdu_type == IMAGE_HDU: + hdu = ImageHDU(self._FITS, ext) + elif hdu_type == BINARY_TBL: + hdu = TableHDU( + self._FITS, + ext, + lower=self.lower, + upper=self.upper, + trim_strings=self.trim_strings, + vstorage=self.vstorage, + case_sensitive=self.case_sensitive, + iter_row_buffer=self.iter_row_buffer, + write_bitcols=self.write_bitcols, + ) + elif hdu_type == ASCII_TBL: + hdu = AsciiTableHDU( + self._FITS, + ext, + lower=self.lower, + upper=self.upper, + trim_strings=self.trim_strings, + vstorage=self.vstorage, + case_sensitive=self.case_sensitive, + iter_row_buffer=self.iter_row_buffer, + write_bitcols=self.write_bitcols, + ) + else: + mess = "extension %s is of unknown type %s this is probably a bug" + mess = mess % (ext, hdu_type) + raise IOError(mess) + + self.hdu_list.append(hdu) + self.hdu_map[ext] = hdu + + extname = hdu.get_extname() + if not self.case_sensitive: + extname = extname.lower() + if extname != '': + # this will guarantee we default to *first* version, + # if version is not requested, using __getitem__ + if extname not in self.hdu_map: + self.hdu_map[extname] = hdu + + ver = hdu.get_extver() + if ver > 0: + key = '%s-%s' % (extname, ver) + self.hdu_map[key] = hdu + + def __iter__(self): + """ + begin iteration over HDUs + """ + if not hasattr(self, 'hdu_list'): + self.update_hdu_list() + self._iter_index = 0 + return self + + def next(self): + """ + Move to the next iteration + """ + if self._iter_index == len(self.hdu_list): + raise StopIteration + hdu = self.hdu_list[self._iter_index] + self._iter_index += 1 + return hdu + + __next__ = next + + def __len__(self): + """ + get the number of extensions + """ + if not hasattr(self, 'hdu_list'): + self.update_hdu_list() + return len(self.hdu_list) + + def _extract_item(self, item): + """ + utility function to extract an "item", meaning + a extension number,name plus version. + """ + ver = 0 + if isinstance(item, tuple): + ver_sent = True + nitem = len(item) + if nitem == 1: + ext = item[0] + elif nitem == 2: + ext, ver = item + else: + ver_sent = False + ext = item + return ext, ver, ver_sent + + def __getitem__(self, item): + """ + Get an hdu by number, name, and possibly version + """ + if not hasattr(self, 'hdu_list'): + if self._did_create: + # we created the file and haven't written anything yet + raise ValueError("Requested hdu '%s' not present" % item) + + self.update_hdu_list() + + if len(self) == 0: + raise ValueError("Requested hdu '%s' not present" % item) + + ext, ver, ver_sent = self._extract_item(item) + + try: + # if it is an int + hdu = self.hdu_list[ext] + except Exception: + # might be a string + ext = mks(ext) + if not self.case_sensitive: + mess = '(case insensitive)' + ext = ext.lower() + else: + mess = '(case sensitive)' + + if ver > 0: + key = '%s-%s' % (ext, ver) + if key not in self.hdu_map: + raise IOError( + "extension not found: %s, " + "version %s %s" % (ext, ver, mess) + ) + hdu = self.hdu_map[key] + else: + if ext not in self.hdu_map: + raise IOError("extension not found: %s %s" % (ext, mess)) + hdu = self.hdu_map[ext] + + return hdu + + def __contains__(self, item): + """ + tell whether specified extension exists, possibly + with version sent as well + """ + try: + hdu = self[item] # noqa + return True + except Exception: + return False + + def __repr__(self): + """ + Text representation of some fits file metadata + """ + spacing = ' ' * 2 + rep = [''] + rep.append("%sfile: %s" % (spacing, self._filename)) + rep.append("%smode: %s" % (spacing, _modeprint_map[self.intmode])) + + rep.append('%sextnum %-15s %s' % (spacing, "hdutype", "hduname[v]")) + + if not hasattr(self, 'hdu_list'): + if not self._did_create: + # we expect some stuff + self.update_hdu_list() + + for i, hdu in enumerate(self.hdu_list): + t = hdu._info['hdutype'] + name = hdu.get_extname() + if name != '': + ver = hdu.get_extver() + if ver != 0: + name = '%s[%s]' % (name, ver) + + rep.append( + "%s%-6d %-15s %s" % (spacing, i, _hdu_type_map[t], name) + ) + + rep = '\n'.join(rep) + return rep + + def __enter__(self): + return self + + def __exit__(self, exception_type, exception_value, traceback): + self.close() + + +def check_extver(extver): + if extver is None: + return 0 + extver = int(extver) + if extver <= 0: + raise ValueError("extver must be > 0") + return extver + + +def extract_filename(filename): + filename = mks(filename) + filename = filename.strip() + if filename[0] == "!": + filename = filename[1:] + filename = os.path.expandvars(filename) + filename = os.path.expanduser(filename) + return filename + + +def array2tabledef(data, table_type='binary', write_bitcols=False): + """ + Similar to descr2tabledef but if there are object columns a type + and max length will be extracted and used for the tabledef + """ + is_ascii = table_type == 'ascii' + + if data.dtype.fields is None: + raise ValueError("data must have fields") + names = [] + names_nocase = {} + formats = [] + dims = [] + + descr = data.dtype.descr + for d in descr: + # these have the form ' 1: + send_dt = list(dt) + [this_data.shape[1:]] + _, form, dim = _npy2fits( + send_dt, table_type=table_type, write_bitcols=write_bitcols + ) + + formats.append(form) + dims.append(dim) + + return names, formats, dims + + +def descr2tabledef(descr, table_type='binary', write_bitcols=False): + """ + Create a FITS table def from the input numpy descriptor. + + parameters + ---------- + descr: list + A numpy recarray type descriptor array.dtype.descr + + returns + ------- + names, formats, dims: tuple of lists + These are the ttyp, tform and tdim header entries + for each field. dim entries may be None + """ + names = [] + formats = [] + dims = [] + + for d in descr: + """ + npy_dtype = d[1][1:] + if is_ascii and npy_dtype in ['u1','i1']: + raise ValueError("1-byte integers are not supported for " + "ascii tables") + """ + + if d[1][1] == 'O': + raise ValueError( + 'cannot automatically declare a var column without ' + 'some data to determine max len' + ) + + name, form, dim = _npy2fits( + d, table_type=table_type, write_bitcols=write_bitcols + ) + + if name == '': + raise ValueError("field name is an empty string") + + """ + if is_ascii: + if dim is not None: + raise ValueError("array columns are not supported " + "for ascii tables") + """ + + names.append(name) + formats.append(form) + dims.append(dim) + + return names, formats, dims + + +def npy_obj2fits(data, name=None): + # this will be a variable length column 1Pt(len) where t is the + # type and len is max length. Each element must be convertible to + # the same type as the first + + if name is None: + d = data.dtype.descr + first = data[0] + else: + d = data[name].dtype.descr # noqa - not used + first = data[name][0] + + # note numpy._string is an instance of str in python2, bytes + # in python3 + if isinstance(first, str) or (IS_PY3 and isinstance(first, bytes)): + if IS_PY3: + if isinstance(first, str): + fits_dtype = _table_npy2fits_form['U'] + else: + fits_dtype = _table_npy2fits_form['S'] + else: + fits_dtype = _table_npy2fits_form['S'] + else: + arr0 = numpy.array(first, copy=copy_if_needed) + dtype0 = arr0.dtype + npy_dtype = dtype0.descr[0][1][1:] + if npy_dtype[0] == 'S' or npy_dtype[0] == 'U': + raise ValueError( + "Field '%s' is an arrays of strings, this is " + "not allowed in variable length columns" % name + ) + if npy_dtype not in _table_npy2fits_form: + raise ValueError( + "Field '%s' has unsupported type '%s'" % (name, npy_dtype) + ) + fits_dtype = _table_npy2fits_form[npy_dtype] + + # Q uses 64-bit addressing, should try at some point but the cfitsio manual + # says it is experimental + # form = '1Q%s' % fits_dtype + form = '1P%s' % fits_dtype + dim = None + + return form, dim + + +def get_tile_dims(tile_dims, imshape): + """ + Just make sure the tile dims has the appropriate number of dimensions + """ + if tile_dims is None or ( + isinstance(tile_dims, str) and tile_dims == NOT_SET + ): + td = None + else: + td = numpy.array(tile_dims, dtype='i8') + nd = len(imshape) + if td.size != nd: + msg = "expected tile_dims to have %d dims, got %d" % (td.size, nd) + raise ValueError(msg) + + return td + + +def get_compress_type(compress): + if compress == NOT_SET: + return None + + if compress is not None: + compress = str(compress).upper() + if compress not in _compress_map: + raise ValueError( + "compress must be one of %s" % list(_compress_map.keys()) + ) + return _compress_map[compress] + + +def get_qmethod(qmethod): + if qmethod == NOT_SET: + return None + + if qmethod not in _qmethod_map: + if isinstance(qmethod, str): + qmethod = qmethod.upper() + elif isinstance(qmethod, bytes): + # in py27, bytes are str, so we can safely assume + # py3 here + qmethod = str(qmethod, 'ascii').upper() + + if qmethod not in _qmethod_map: + raise ValueError( + "qmethod must be one of %s" % list(_qmethod_map.keys()) + ) + + return _qmethod_map[qmethod] + + +def get_dither_seed(dither_seed): + """ + Convert a seed value or indicator to the approprate integer value for + cfitsio + + Parameters + ---------- + dither_seed: number or string + Seed for the subtractive dither. Seeding makes the lossy compression + reproducible. Allowed values are + None or 0 or 'clock': + Return 0, do not set the seed explicitly, use the system clock + negative or 'checksum': + Return -1, means Set the seed based on the data checksum + 1-10_000: + use the input seed + """ + if dither_seed == NOT_SET: + return None + + if isinstance(dither_seed, bytes): + dither_seed = str(dither_seed, 'utf-8') + + if isinstance(dither_seed, str): + dlow = dither_seed.lower() + if dlow == 'clock': + seed_out = 0 + elif dlow == 'checksum': + seed_out = -1 + else: + raise ValueError(f'Bad dither_seed {dither_seed}') + elif dither_seed is None: + seed_out = 0 + else: + # must fit in an int + seed_out = numpy.int32(dither_seed) + + if seed_out > 10_000: + raise ValueError( + f'Got dither_seed {seed_out}, expected avalue <= 10_000' + ) + + return seed_out + + +def check_comptype_img(comptype, dtype_str): + if comptype == NOCOMPRESS: + return + + # if dtype_str == 'i8': + # no i8 allowed for tile-compressed images + # raise ValueError("8-byte integers not supported when " + # "using tile compression") + + if comptype == PLIO_1: + # no unsigned u4/u8 for plio + if dtype_str == 'u4' or dtype_str == 'u8': + raise ValueError( + "Unsigned 4/8-byte integers currently not " + "allowed when writing using PLIO " + "tile compression" + ) + + +def _extract_table_type(type): + """ + Get the numerical table type + """ + if isinstance(type, str): + type = type.lower() + if type[0:7] == 'binary': + table_type = BINARY_TBL + elif type[0:6] == 'ascii': + table_type = ASCII_TBL + else: + raise ValueError( + "table type string should begin with 'binary' or 'ascii' " + "(case insensitive)" + ) + else: + type = int(type) + if type not in [BINARY_TBL, ASCII_TBL]: + raise ValueError( + "table type num should be BINARY_TBL (%d) or " + "ASCII_TBL (%d)" % (BINARY_TBL, ASCII_TBL) + ) + table_type = type + + return table_type + + +# The _compress_map and _qmethod_map only handle +# python-level translations of things like None to their +# C-level meanings. At the C level, we use None to indicate +# not set, as opposed to the values here. +_compress_map = { + None: NOCOMPRESS, + 'RICE': RICE_1, + 'RICE_1': RICE_1, + 'GZIP': GZIP_1, + 'GZIP_1': GZIP_1, + 'GZIP_2': GZIP_2, + 'PLIO': PLIO_1, + 'PLIO_1': PLIO_1, + 'HCOMPRESS': HCOMPRESS_1, + 'HCOMPRESS_1': HCOMPRESS_1, + # In get_compress_type(), we convert the key to str before searching - + # so the keys here must be strings also! + str(NOCOMPRESS): NOCOMPRESS, + str(RICE_1): RICE_1, + str(GZIP_1): GZIP_1, + str(GZIP_2): GZIP_2, + str(PLIO_1): PLIO_1, + str(HCOMPRESS_1): HCOMPRESS_1, +} + +_qmethod_map = { + None: NO_DITHER, + 'NO_DITHER': NO_DITHER, + 'SUBTRACTIVE_DITHER_1': SUBTRACTIVE_DITHER_1, + 'SUBTRACTIVE_DITHER_2': SUBTRACTIVE_DITHER_2, + NO_DITHER: NO_DITHER, + SUBTRACTIVE_DITHER_1: SUBTRACTIVE_DITHER_1, + SUBTRACTIVE_DITHER_2: SUBTRACTIVE_DITHER_2, +} + +_modeprint_map = { + 'r': 'READONLY', + 'rw': 'READWRITE', + 0: 'READONLY', + 1: 'READWRITE', +} +_char_modemap = {'r': 'r', 'rw': 'rw', READONLY: 'r', READWRITE: 'rw'} +_int_modemap = { + 'r': READONLY, + 'rw': READWRITE, + READONLY: READONLY, + READWRITE: READWRITE, +} diff --git a/fitsio/hdu/__init__.py b/fitsio/hdu/__init__.py new file mode 100644 index 0000000..0c6e3ed --- /dev/null +++ b/fitsio/hdu/__init__.py @@ -0,0 +1,14 @@ +from .base import ( # noqa + ANY_HDU, + BINARY_TBL, + ASCII_TBL, + IMAGE_HDU, + _hdu_type_map, +) +from .image import ImageHDU # noqa +from .table import ( # noqa + TableHDU, + AsciiTableHDU, + _table_npy2fits_form, + _npy2fits, +) diff --git a/fitsio/hdu/base.py b/fitsio/hdu/base.py new file mode 100644 index 0000000..f4086a4 --- /dev/null +++ b/fitsio/hdu/base.py @@ -0,0 +1,442 @@ +import re +import copy +import warnings + +from ..util import _stypes, _itypes, _ftypes, FITSRuntimeWarning +from ..header import FITSHDR + +INVALID_HDR_CHARS_RE = re.compile(r"(\?|\*|#)+") +INVALID_HDR_CHARS = {"?", "*", "#"} +ANY_HDU = -1 +IMAGE_HDU = 0 +ASCII_TBL = 1 +BINARY_TBL = 2 + +_hdu_type_map = { + IMAGE_HDU: 'IMAGE_HDU', + ASCII_TBL: 'ASCII_TBL', + BINARY_TBL: 'BINARY_TBL', + 'IMAGE_HDU': IMAGE_HDU, + 'ASCII_TBL': ASCII_TBL, + 'BINARY_TBL': BINARY_TBL, +} + + +class HDUBase(object): + """ + A representation of a FITS HDU + + parameters + ---------- + fits: FITS object + An instance of a _fistio_wrap.FITS object. This is the low-level + python object, not the FITS object defined above. + ext: integer + The extension number. + """ + + def __init__(self, fits, ext, **keys): + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + self._FITS = fits + self._ext = ext + self._ignore_scaling = False + + # init info cache to none + self._cached_info = None + self._filename = self._FITS.filename() + + @property + def _info(self): + if self._cached_info is None: + self._update_info() + return self._cached_info + + def _update_info(self): + """ + Update metadata for this HDU + """ + try: + self._FITS.movabs_hdu(self._ext + 1) + except IOError: + raise RuntimeError("no such hdu") + + self._cached_info = self._FITS.get_hdu_info( + self._ext + 1, self._ignore_scaling + ) + + @property + def ignore_scaling(self): + """ + :return: Flag to indicate whether scaling (BZERO/BSCALE) values should + be ignored. + """ + return self._ignore_scaling + + @ignore_scaling.setter + def ignore_scaling(self, ignore_scaling_flag): + """ + Set the flag to ignore scaling. + """ + old_val = self._ignore_scaling + self._ignore_scaling = ignore_scaling_flag + + # if needed invalidate the info cache so it gets + # updated the next time we access it + if old_val != self._ignore_scaling: + self._cached_info = None + + def get_extnum(self): + """ + Get the extension number + """ + return self._ext + + def get_extname(self): + """ + Get the name for this extension, can be an empty string + """ + name = self._info['extname'] + if name.strip() == '': + name = self._info['hduname'] + return name.strip() + + def get_extver(self): + """ + Get the version for this extension. + + Used when a name is given to multiple extensions + """ + ver = self._info['extver'] + if ver == 0: + ver = self._info['hduver'] + return ver + + def get_exttype(self, num=False): + """ + Get the extension type + + By default the result is a string that mirrors + the enumerated type names in cfitsio + 'IMAGE_HDU', 'ASCII_TBL', 'BINARY_TBL' + which have numeric values + 0 1 2 + send num=True to get the numbers. The values + fitsio.IMAGE_HDU .ASCII_TBL, and .BINARY_TBL + are available for comparison + + parameters + ---------- + num: bool, optional + Return the numeric values. + """ + if num: + return self._info['hdutype'] + else: + name = _hdu_type_map[self._info['hdutype']] + return name + + def get_offsets(self): + """ + returns + ------- + a dictionary with these entries + + header_start: + byte offset from beginning of the file to the start + of the header + data_start: + byte offset from beginning of the file to the start + of the data section + data_end: + byte offset from beginning of the file to the end + of the data section + + Note these are also in the information dictionary, which + you can access with get_info() + """ + return dict( + header_start=self._info['header_start'], + data_start=self._info['data_start'], + data_end=self._info['data_end'], + ) + + def get_info(self): + """ + Get a copy of the internal dictionary holding extension information + """ + return copy.deepcopy(self._info) + + def get_filename(self): + """ + Get a copy of the filename for this fits file + """ + return copy.copy(self._filename) + + def write_checksum(self): + """ + Write the checksum into the header for this HDU. + + Computes the checksum for the HDU, both the data portion alone (DATASUM + keyword) and the checksum complement for the entire HDU (CHECKSUM). + + returns + ------- + A dict with keys 'datasum' and 'hdusum' + """ + ret = self._FITS.write_checksum(self._ext + 1) + self._cached_info = None # invalidate info cache + return ret + + def verify_checksum(self): + """ + Verify the checksum in the header for this HDU. + """ + res = self._FITS.verify_checksum(self._ext + 1) + if res['dataok'] != 1: + raise ValueError("data checksum failed") + if res['hduok'] != 1: + raise ValueError("hdu checksum failed") + + def write_comment(self, comment): + """ + Write a comment into the header + """ + self._FITS.write_comment(self._ext + 1, str(comment)) + self._cached_info = None # invalidate info cache + + def write_history(self, history): + """ + Write history text into the header + """ + self._FITS.write_history(self._ext + 1, str(history)) + self._cached_info = None # invalidate info cache + + def _write_continue(self, value): + """ + Write history text into the header + """ + self._FITS.write_continue(self._ext + 1, str(value)) + self._cached_info = None # invalidate info cache + + def write_key(self, name, value, comment=""): + """ + Write the input value to the header + + parameters + ---------- + name: string + Name of keyword to write/update + value: scalar + Value to write, can be string float or integer type, + including numpy scalar types. + comment: string, optional + An optional comment to write for this key + + Notes + ----- + Write COMMENT and HISTORY using the write_comment and write_history + methods + """ + + if name is None: + # we write a blank keyword and the rest is a comment + # string + + if not isinstance(comment, _stypes): + raise ValueError( + 'when writing blank key the value must be a string' + ) + + # this might be longer than 80 but that's ok, the routine + # will take care of it + # card = ' ' + str(comment) + card = ' ' + str(comment) + self._FITS.write_record( + self._ext + 1, + card, + ) + + elif value is None: + self._FITS.write_undefined_key( + self._ext + 1, str(name), str(comment) + ) + + elif isinstance(value, bool): + if value: + v = 1 + else: + v = 0 + self._FITS.write_logical_key( + self._ext + 1, str(name), v, str(comment) + ) + elif isinstance(value, _stypes): + self._FITS.write_string_key( + self._ext + 1, str(name), str(value), str(comment) + ) + elif isinstance(value, _ftypes): + self._FITS.write_double_key( + self._ext + 1, str(name), float(value), str(comment) + ) + elif isinstance(value, _itypes): + self._FITS.write_long_long_key( + self._ext + 1, str(name), int(value), str(comment) + ) + elif isinstance(value, (tuple, list)): + vl = [str(el) for el in value] + sval = ','.join(vl) + self._FITS.write_string_key( + self._ext + 1, str(name), sval, str(comment) + ) + else: + sval = str(value) + mess = ( + "warning, keyword '%s' has non-standard " + "value type %s, " + "Converting to string: '%s'" + ) + warnings.warn(mess % (name, type(value), sval), FITSRuntimeWarning) + self._FITS.write_string_key( + self._ext + 1, str(name), sval, str(comment) + ) + + self._cached_info = None # invalidate info cache + + def write_keys(self, records_in, clean=True): + """ + Write the keywords to the header. + + parameters + ---------- + records: FITSHDR or list or dict + Can be one of these: + - FITSHDR object + - list of dictionaries containing 'name','value' and optionally + a 'comment' field; the order is preserved. + - a dictionary of keyword-value pairs; no comments are written + in this case, and the order is arbitrary. + clean: boolean + If True, trim out the standard fits header keywords that are + created on HDU creation (e.g., EXTEND, SIMPLE, STTYPE, TFORM, + TDIM, XTENSION, BITPIX, NAXIS, etc.) from the input records + before they are written to the current fits file. + + Notes + ----- + Input keys named COMMENT and HISTORY are written using the + write_comment and write_history methods. + """ + + if isinstance(records_in, FITSHDR): + hdr = records_in + else: + hdr = FITSHDR(records_in) + + if clean: + is_table = hasattr(self, '_table_type_str') + # is_table = isinstance(self, TableHDU) + hdr.clean(is_table=is_table) + + for r in hdr.records(): + name = r['name'] + if name is not None: + name = name.upper() + + if INVALID_HDR_CHARS_RE.search(name): + raise RuntimeError( + "header key '%s' has invalid characters! " + "Characters in %s are not allowed!" + % (name, INVALID_HDR_CHARS) + ) + + value = r['value'] + + if name == 'COMMENT': + self.write_comment(value) + elif name == 'HISTORY': + self.write_history(value) + elif name == 'CONTINUE': + self._write_continue(value) + else: + comment = r.get('comment', '') + self.write_key(name, value, comment=comment) + + self._cached_info = None # invalidate info cache + + def read_header(self): + """ + Read the header as a FITSHDR + + The FITSHDR allows access to the values and comments by name and + number. + """ + # note converting strings + return FITSHDR(self.read_header_list()) + + def read_header_list(self): + """ + Read the header as a list of dictionaries. + + You will usually use read_header instead, which just sends the output + of this functioin to the constructor of a FITSHDR, which allows access + to the values and comments by name and number. + + Each dictionary is + 'name': the keyword name + 'value': the value field as a string + 'comment': the comment field as a string. + """ + return self._FITS.read_header(self._ext + 1) + + def delete_key(self, name): + """ + Delete the key from the header. + + parameters + ---------- + name: string + Name of keyword to delete. + """ + self.delete_keys([name]) + + def delete_keys(self, names): + """ + Delete the keys from the header. + + parameters + ---------- + names: iterable of keys + Names of keywords to delete. + """ + for name in names: + self._FITS.delete_key(self._ext + 1, str(name)) + self._cached_info = None # invalidate info cache + + def _get_repr_list(self): + """ + Get some representation data common to all HDU types + """ + spacing = ' ' * 2 + text = [''] + text.append("%sfile: %s" % (spacing, self._filename)) + text.append("%sextension: %d" % (spacing, self._info['hdunum'] - 1)) + text.append( + "%stype: %s" % (spacing, _hdu_type_map[self._info['hdutype']]) + ) + + extname = self.get_extname() + if extname != "": + text.append("%sextname: %s" % (spacing, extname)) + extver = self.get_extver() + if extver != 0: + text.append("%sextver: %s" % (spacing, extver)) + + return text, spacing diff --git a/fitsio/hdu/image.py b/fitsio/hdu/image.py new file mode 100644 index 0000000..63db0f4 --- /dev/null +++ b/fitsio/hdu/image.py @@ -0,0 +1,550 @@ +""" +image HDU classes for fitslib, part of the fitsio package. + +See the main docs at https://github.com/esheldon/fitsio + + Copyright (C) 2011 Erin Sheldon, BNL. erin dot sheldon at gmail dot com + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +""" + +from __future__ import with_statement, print_function + +import numpy + +from math import floor +from .base import HDUBase, IMAGE_HDU +from ..util import ( + IS_PY3, + array_to_native, + copy_if_needed, + _nonfinite_as_cfitsio_floating_null_value, +) + +# for python3 compat +if IS_PY3: + xrange = range + + +class ImageHDU(HDUBase): + def _update_info(self): + """ + Call parent method and make sure this is in fact a + image HDU. Set dims in C order + """ + super(ImageHDU, self)._update_info() + + if self._info['hdutype'] != IMAGE_HDU: + mess = "Extension %s is not a Image HDU" % self.ext + raise ValueError(mess) + + # convert to c order + if 'dims' in self._info: + self._info['dims'] = list(reversed(self._info['dims'])) + + def has_data(self): + """ + Determine if this HDU has any data + + For images, check that the dimensions are not zero. + + For tables, check that the row count is not zero + """ + ndims = self._info.get('ndims', 0) + if ndims == 0: + return False + else: + return True + + def is_compressed(self): + """ + returns true of this extension is compressed + """ + return self._info['is_compressed_image'] == 1 + + def get_comptype(self): + """ + Get the compression type. + + None if the image is not compressed. + """ + return self._info['comptype'] + + def get_dims(self): + """ + get the shape of the image. Returns () for empty + """ + if self._info['ndims'] != 0: + dims = self._info['dims'] + else: + dims = () + + return dims + + def reshape(self, dims): + """ + reshape an existing image to the requested dimensions + + If the new shape is bigger than the current shape, + the existing values in the image are "wrapped" around in C + order, via the process of + + 1. flattening the image in C order + 2. appending zeros to the image so that it matches the new + total size + 3. reshaping the image to the new dimensions + + If the new shape is smaller than the current image, the current + image is flattened, trunctaed to the new total length, and then + reshaped to the new shape. + + parameters + ---------- + dims: sequence + Any sequence convertible to i8 + """ + + adims = numpy.array(dims, ndmin=1, dtype='i8') + # we have to reverse the dimensions here since cfitsio + # uses fortran order + self._FITS.reshape_image(self._ext + 1, adims[::-1]) + self._cached_info = None # invalidate info cache + + def write(self, img, start=0, **keys): + """ + Write the image into this HDU + + If data already exist in this HDU, they will be overwritten. If the + image to write is larger than the image on disk, or if the start + position is such that the write would extend beyond the existing + dimensions, the on-disk image is expanded. + + parameters + ---------- + img: ndarray + A simple numpy ndarray + start: integer or sequence + Where to start writing data. Can be an integer offset + into the entire array, or a sequence determining where + in N-dimensional space to start. + """ + + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + if img.dtype.fields is not None: + raise ValueError("got recarray, expected regular ndarray") + if img.size == 0: + raise ValueError("data must have at least 1 row") + + # data must be c-contiguous and native byte order + if not img.flags['C_CONTIGUOUS']: + # this always makes a copy + img_send = numpy.ascontiguousarray(img) + img_send = array_to_native(img_send, inplace=True) + else: + img_send = array_to_native(img, inplace=False) + + if IS_PY3 and img_send.dtype.char == 'U': + # for python3, we convert unicode to ascii + # this will error if the character is not in ascii + img_send = img_send.astype('S', copy=copy_if_needed) + + # see if we need to resize the image + if self.has_data(): + self._expand_if_needed(self.get_dims(), img.shape, start) + dims = self.get_dims() + + if numpy.isscalar(start): + start = numpy.unravel_index(start, dims) + + if all(od == nd for od, nd in zip(dims, img.shape)) and all( + st == 0 for st in start + ): + # we are replacing the whole image, so no need to + # write a subset + write_subset = False + else: + write_subset = True + else: + write_subset = False + + with _nonfinite_as_cfitsio_floating_null_value( + img_send, self.is_compressed() + ) as img_send_any_nan: + img_send, any_nan = img_send_any_nan + if not write_subset: + # write in image at start in a single pass + offset = 0 + self._FITS.write_image( + self._ext + 1, + img_send, + offset + 1, + 1 if any_nan else 0, + ) + else: + if not any_nan and not self.is_compressed(): + firstpixel = numpy.array(start, ndmin=1, dtype='i8') + # lastpixel is the index of the lastpixel so subtract 1 + lastpixel = ( + firstpixel + + numpy.array(img_send.shape, ndmin=1, dtype='i8') + - 1 + ) + + # we have to reverse the dimensions here since cfitsio + # uses fortran order and offset by 1 for fortan indexing + firstpixel = firstpixel[::-1] + 1 + lastpixel = lastpixel[::-1] + 1 + + self._FITS.write_subset( + self._ext + 1, img_send, firstpixel, lastpixel + ) + else: + # the C API doesn't support nan handling w/ rectangular + # subsets, so emulate in python + # go "row by row" but in more than two dimensions + ndims = len(dims) + for index in numpy.ndindex(*(img_send.shape[:-1])): + new_start = [ + start[i] + index[i] for i in range(ndims - 1) + ] + new_start += [start[-1]] + offset = _convert_full_start_to_offset(dims, new_start) + img_slice = tuple( + [slice(ns, ns + 1) for ns in index] + ) + (slice(None),) + self._FITS.write_image( + self._ext + 1, + img_send[img_slice], + offset + 1, + 1 if any_nan else 0, + ) + + self._cached_info = None # invalidate info cache + + def read(self, **keys): + """ + Read the image. + + If the HDU is an IMAGE_HDU, read the corresponding image. Compression + and scaling are dealt with properly. + """ + + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + if not self.has_data(): + return None + + dtype, shape = self._get_dtype_and_shape() + array = numpy.zeros(shape, dtype=dtype) + self._FITS.read_image(self._ext + 1, array) + return array + + def _get_dtype_and_shape(self): + """ + Get the numpy dtype and shape for image + """ + npy_dtype = self._get_image_numpy_dtype() + + if self._info['ndims'] != 0: + shape = self._info['dims'] + else: + raise IOError("no image present in HDU") + + return npy_dtype, shape + + def _get_image_numpy_dtype(self): + """ + Get the numpy dtype for the image + """ + try: + ftype = self._info['img_equiv_type'] + npy_type = _image_bitpix2npy[ftype] + except KeyError: + raise KeyError("unsupported fits data type: %d" % ftype) + + return npy_type + + def __getitem__(self, arg): + """ + Get data from an image using python [] slice notation. + + e.g., [2:25, 4:45]. + """ + return self._read_image_slice(arg) + + def _read_image_slice(self, arg): + """ + workhorse to read a slice + """ + if 'ndims' not in self._info: + raise ValueError("Attempt to slice empty extension") + + if isinstance(arg, slice): + # one-dimensional, e.g. 2:20 + return self._read_image_slice((arg,)) + + if not isinstance(arg, tuple): + raise ValueError( + "arguments must be slices, one for each " + "dimension, e.g. [2:5] or [2:5,8:25] etc." + ) + + # should be a tuple of slices, one for each dimension + # e.g. [2:3, 8:100] + nd = len(arg) + if nd != self._info['ndims']: + raise ValueError( + "Got slice dimensions %d, " + "expected %d" % (nd, self._info['ndims']) + ) + + targ = arg + arg = [] + for a in targ: + if isinstance(a, slice): + arg.append(a) + elif isinstance(a, int): + arg.append(slice(a, a + 1, 1)) + else: + raise ValueError("arguments must be slices, e.g. 2:12") + + dims = self._info['dims'] + arrdims = [] + first = [] + last = [] + steps = [] + npy_dtype = self._get_image_numpy_dtype() + + # check the args and reverse dimensions since + # fits is backwards from numpy + dim = 0 + for slc in arg: + start = slc.start + stop = slc.stop + step = slc.step + + if start is None: + start = 0 + if stop is None: + stop = dims[dim] + if step is None: + # Ensure sane defaults. + if start <= stop: + step = 1 + else: + step = -1 + + # Sanity checks for proper syntax. + if ( + (step > 0 and stop < start) + or (step < 0 and start < stop) + or (start == stop) + ): + return numpy.empty(0, dtype=npy_dtype) + + if start < 0: + start = dims[dim] + start + if start < 0: + raise IndexError("Index out of bounds") + + if stop < 0: + stop = dims[dim] + start + 1 + + # move to 1-offset + start = start + 1 + + if stop > dims[dim]: + stop = dims[dim] + + if stop < start: + # A little black magic here. The stop is offset by 2 to + # accommodate the 1-offset of CFITSIO, and to move past the end + # pixel to get the complete set after it is flipped along the + # axis. Maybe there is a clearer way to accomplish what this + # offset is glossing over. + # @at88mph 2019.10.10 + stop = stop + 2 + + first.append(start) + last.append(stop) + + # Negative step values are not used in CFITSIO as the dimension is + # already properly calcualted. + # @at88mph 2019.10.21 + steps.append(abs(step)) + arrdims.append(int(floor((stop - start) / step)) + 1) + + dim += 1 + + first.reverse() + last.reverse() + steps.reverse() + first = numpy.array(first, dtype='i8') + last = numpy.array(last, dtype='i8') + steps = numpy.array(steps, dtype='i8') + + array = numpy.zeros(arrdims, dtype=npy_dtype) + self._FITS.read_image_slice( + self._ext + 1, first, last, steps, self._ignore_scaling, array + ) + return array + + def _expand_if_needed(self, dims, write_dims, start): + """ + expand the on-disk image if the indended write will extend + beyond the existing dimensions + """ + ndim = len(dims) + idim = len(write_dims) + + if idim != ndim: + raise ValueError( + "When expanding " + "an existing image while writing, the input image " + "must have the same number of dimensions " + "as the original. " + "Got %d instead of %d" % (idim, ndim) + ) + + if numpy.isscalar(start): + if len(dims) > 1: + try: + _start = numpy.unravel_index(start, dims) + except Exception: + # the unravel_index call fails when start is beyond + # end of the existing array. + # this means we are expanding the image and so we should + # error + raise ValueError( + "When expanding " + "an existing image while writing, the start keyword " + "must have the same number of dimensions " + "as the image or be exactly 0, got %s " % start + ) + else: + _start = [start] + else: + _start = start + + new_dims = [] + for i in xrange(ndim): + required_dim = _start[i] + write_dims[i] + + if required_dim < dims[i]: + # careful not to shrink the image! + dimsize = dims[i] + else: + dimsize = required_dim + + new_dims.append(dimsize) + + if any(nd != od for nd, od in zip(new_dims, dims)): + if numpy.isscalar(start) and len(dims) > 1: + if start != 0: + raise ValueError( + "When expanding " + "an existing image while writing, the start keyword " + "must have the same number of dimensions " + "as the image or be exactly 0, got %s " % start + ) + self.reshape(new_dims) + + def __repr__(self): + """ + Representation for ImageHDU + """ + text, spacing = self._get_repr_list() + text.append("%simage info:" % spacing) + cspacing = ' ' * 4 + + # need this check for when we haven't written data yet + if 'ndims' in self._info: + if self._info['comptype'] is not None: + text.append( + "%scompression: %s" % (cspacing, self._info['comptype']) + ) + + if self._info['ndims'] != 0: + dimstr = [str(d) for d in self._info['dims']] + dimstr = ",".join(dimstr) + else: + dimstr = '' + + dt = _image_bitpix2npy[self._info['img_equiv_type']] + text.append("%sdata type: %s" % (cspacing, dt)) + text.append("%sdims: [%s]" % (cspacing, dimstr)) + + text = '\n'.join(text) + return text + + +def _convert_full_start_to_offset(dims, start): + # convert to scalar offset + # note we use the on-disk data type to get itemsize + ndim = len(dims) + + # convert sequence to pixel start + if len(start) != ndim: + m = "start has len %d, which does not match requested dims %d" + raise ValueError(m % (len(start), ndim)) + + # MRB: I changed this to use the numpy util below. + # I have left the old code here for posterity. + # I checked that they give the same answer. + # # this is really strides / itemsize + # strides = [1] + # for i in xrange(1, ndim): + # strides.append(strides[i - 1] * dims[ndim - i]) + + # strides.reverse() + # s = start + # start_index = sum([s[i] * strides[i] for i in xrange(ndim)]) + + # return start_index + + return numpy.ravel_multi_index(start, dims) + + +# remember, you should be using the equivalent image type for this +_image_bitpix2npy = { + 8: 'u1', + 10: 'i1', + 16: 'i2', + 20: 'u2', + 32: 'i4', + 40: 'u4', + 64: 'i8', + 80: 'u8', + -32: 'f4', + -64: 'f8', +} diff --git a/fitsio/hdu/table.py b/fitsio/hdu/table.py new file mode 100644 index 0000000..7ccd887 --- /dev/null +++ b/fitsio/hdu/table.py @@ -0,0 +1,2845 @@ +""" +image HDU classes for fitslib, part of the fitsio package. + +See the main docs at https://github.com/esheldon/fitsio + + Copyright (C) 2011 Erin Sheldon, BNL. erin dot sheldon at gmail dot com + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +""" + +from __future__ import with_statement, print_function +import copy +import warnings +from functools import reduce + +import numpy as np + +from ..util import ( + IS_PY3, + isstring, + isinteger, + is_object, + fields_are_object, + array_to_native, + array_to_native_c, + FITSRuntimeWarning, + mks, + copy_if_needed, +) +from .base import HDUBase, ASCII_TBL, IMAGE_HDU, _hdu_type_map + +# for python3 compat +if IS_PY3: + xrange = range + + +class TableHDU(HDUBase): + """ + A table HDU + + parameters + ---------- + fits: FITS object + An instance of a _fistio_wrap.FITS object. This is the low-level + python object, not the FITS object defined above. + ext: integer + The extension number. + lower: bool, optional + If True, force all columns names to lower case in output + upper: bool, optional + If True, force all columns names to upper case in output + trim_strings: bool, optional + If True, trim trailing spaces from strings. Default is False. + vstorage: string, optional + Set the default method to store variable length columns. Can be + 'fixed' or 'object'. See docs on fitsio.FITS for details. + case_sensitive: bool, optional + Match column names and extension names with case-sensitivity. Default + is False. + iter_row_buffer: integer + Number of rows to buffer when iterating over table HDUs. + Default is 1. + write_bitcols: bool, optional + If True, write logicals a a bit column. Default is False. + """ + + def __init__( + self, + fits, + ext, + lower=False, + upper=False, + trim_strings=False, + vstorage='fixed', + case_sensitive=False, + iter_row_buffer=1, + write_bitcols=False, + **keys, + ): + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + # NOTE: The defaults of False above cannot be changed since they + # are or'ed with the method defaults below. + super(TableHDU, self).__init__(fits, ext) + + self.lower = lower + self.upper = upper + self.trim_strings = trim_strings + + self._vstorage = vstorage + self.case_sensitive = case_sensitive + self._iter_row_buffer = iter_row_buffer + self.write_bitcols = write_bitcols + + if self._info['hdutype'] == ASCII_TBL: + self._table_type_str = 'ascii' + else: + self._table_type_str = 'binary' + + def get_nrows(self): + """ + Get number of rows in the table. + """ + nrows = self._info.get('nrows', None) + if nrows is None: + raise ValueError("nrows not in info table; this is a bug") + return nrows + + def get_colnames(self): + """ + Get a copy of the column names for a table HDU + """ + return copy.copy(self._info["colnames"]) + + def get_colname(self, colnum): + """ + Get the name associated with the given column number + + parameters + ---------- + colnum: integer + The number for the column, zero offset + """ + if colnum < 0 or colnum > (self._info["ncol"] - 1): + raise ValueError( + "colnum out of range [0,%s-1]" % self._info["ncol"] + ) + return self._info["colnames"][colnum] + + def get_vstorage(self): + """ + Get a string representing the storage method for variable length + columns + """ + return copy.copy(self._vstorage) + + def has_data(self): + """ + Determine if this HDU has any data + + Check that the row count is not zero + """ + if self._info['nrows'] > 0: + return True + else: + return False + + def where(self, expression, firstrow=None, lastrow=None): + """ + Return the indices where the expression evaluates to true. + + parameters + ---------- + expression: string + A fits row selection expression. E.g. + "x > 3 && y < 5" + firstrow, lastrow : int + Range of rows for evaluation. This follows the Python list + slice convention that the last element is not included. + """ + if firstrow is None: + firstrow = 0 + elif firstrow < 0: + raise ValueError('firstrow cannot be negative') + if lastrow is None: + lastrow = self._info['nrows'] + elif lastrow < firstrow: + raise ValueError('lastrow cannot be less than firstrow') + elif lastrow > self._info['nrows']: + raise ValueError('lastrow cannot be greater than nrows') + nrows = lastrow - firstrow + return self._FITS.where(self._ext + 1, expression, firstrow + 1, nrows) + + def write( + self, data, firstrow=0, columns=None, names=None, slow=False, **keys + ): + """ + Write data into this HDU + + parameters + ---------- + data: ndarray or list of ndarray + A numerical python array. Should be an ordinary array for image + HDUs, should have fields for tables. To write an ordinary array to + a column in a table HDU, use write_column. If data already exists + in this HDU, it will be overwritten. See the append(() method to + append new rows to a table HDU. + firstrow: integer, optional + At which row you should begin writing to tables. Be sure you know + what you are doing! For appending see the append() method. + Default 0. + columns: list, optional + If data is a list of arrays, you must send columns as a list + of names or column numbers. You can also use the `names` keyword + argument. + names: list, optional + If data is a list of arrays, you must send columns as a list + of names or column numbers. You can also use the `columns` keyword + argument. + slow: bool, optional + If True, use a slower method to write one column at a time. Useful + for debugging. + """ + + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + isrec = False + if isinstance(data, (list, dict)): + if isinstance(data, list): + data_list = data + if columns is not None: + columns_all = columns + elif names is not None: + columns_all = names + else: + raise ValueError( + "you must send `columns` or `names` " + "with a list of arrays" + ) + else: + columns_all = list(data.keys()) + data_list = [data[n] for n in columns_all] + + colnums_all = [self._extract_colnum(c) for c in columns_all] + names = [self.get_colname(c) for c in colnums_all] + + isobj = np.zeros(len(data_list), dtype=bool) + for i in xrange(len(data_list)): + isobj[i] = is_object(data_list[i]) + + else: + if data.dtype.fields is None: + raise ValueError( + "You are writing to a table, so I expected " + "an array with fields as input. If you want " + "to write a simple array, you should use " + "write_column to write to a single column, " + "or instead write to an image hdu" + ) + + if data.shape == (): + raise ValueError("cannot write data with shape ()") + + isrec = True + names = data.dtype.names + # only write object types (variable-length columns) after + # writing the main table + isobj = fields_are_object(data) + + data_list = [] + colnums_all = [] + for i, name in enumerate(names): + colnum = self._extract_colnum(name) + data_list.append(data[name]) + colnums_all.append(colnum) + + if slow: + for i, name in enumerate(names): + if not isobj[i]: + self.write_column(name, data_list[i], firstrow=firstrow) + else: + nonobj_colnums = [] + nonobj_arrays = [] + for i in xrange(len(data_list)): + if not isobj[i]: + nonobj_colnums.append(colnums_all[i]) + if isrec: + # this still leaves possibility of f-order sub-arrays.. + colref = array_to_native(data_list[i], inplace=False) + else: + colref = array_to_native_c(data_list[i], inplace=False) + + if IS_PY3 and colref.dtype.char == 'U': + # for python3, we convert unicode to ascii + # this will error if the character is not in ascii + colref = colref.astype('S', copy=copy_if_needed) + + nonobj_arrays.append(colref) + + for tcolnum, tdata in zip(nonobj_colnums, nonobj_arrays): + self._verify_column_data(tcolnum, tdata) + + if len(nonobj_arrays) > 0: + self._FITS.write_columns( + self._ext + 1, + nonobj_colnums, + nonobj_arrays, + firstrow=firstrow + 1, + write_bitcols=self.write_bitcols, + ) + + # writing the object arrays always occurs the same way + # need to make sure this works for array fields + for i, name in enumerate(names): + if isobj[i]: + self.write_var_column(name, data_list[i], firstrow=firstrow) + + self._cached_info = None # invalidate info cache + + def write_column(self, column, data, firstrow=0, **keys): + """ + Write data to a column in this HDU + + This HDU must be a table HDU. + + parameters + ---------- + column: scalar string/integer + The column in which to write. Can be the name or number (0 offset) + data: ndarray + Numerical python array to write. This should match the + shape of the column. You are probably better using + fits.write_table() to be sure. + firstrow: integer, optional + At which row you should begin writing. Be sure you know what you + are doing! For appending see the append() method. Default 0. + """ + + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + colnum = self._extract_colnum(column) + + # need it to be contiguous and native byte order. For now, make a + # copy. but we may be able to avoid this with some care. + + if not data.flags['C_CONTIGUOUS']: + # this always makes a copy + data_send = np.ascontiguousarray(data) + # this is a copy, we can make sure it is native + # and modify in place if needed + data_send = array_to_native(data_send, inplace=True) + else: + # we can avoid the copy with a try-finally block and + # some logic + data_send = array_to_native(data, inplace=False) + + if IS_PY3 and data_send.dtype.char == 'U': + # for python3, we convert unicode to ascii + # this will error if the character is not in ascii + data_send = data_send.astype('S', copy=copy_if_needed) + + self._verify_column_data(colnum, data_send) + + self._FITS.write_columns( + self._ext + 1, + [colnum], + [data_send], + firstrow=firstrow + 1, + write_bitcols=self.write_bitcols, + ) + + del data_send + self._cached_info = None # invalidate info cache + + def _verify_column_data(self, colnum, data): + """ + verify the input data is of the correct type and shape + """ + this_dt = data.dtype.descr[0] + + if len(data.shape) > 2: + this_shape = data.shape[1:] + elif len(data.shape) == 2 and data.shape[1] > 1: + this_shape = data.shape[1:] + else: + this_shape = () + + this_npy_type = this_dt[1][1:] + + npy_type, isvar, istbit = self._get_tbl_numpy_dtype(colnum) + info = self._info['colinfo'][colnum] + + if npy_type[0] in ['>', '<', '|']: + npy_type = npy_type[1:] + + col_name = info['name'] + col_tdim = info['tdim'] + col_shape = _tdim2shape( + col_tdim, col_name, is_string=(npy_type[0] == 'S') + ) + + if col_shape is None: + if this_shape == (): + this_shape = None + + if col_shape is not None and not isinstance(col_shape, tuple): + col_shape = (col_shape,) + + # this mismatch is OK + if npy_type == 'i1' and this_npy_type == 'b1': + this_npy_type = 'i1' + + if isinstance(self, AsciiTableHDU): + # we don't enforce types exact for ascii + if npy_type == 'i8' and this_npy_type in ['i2', 'i4']: + this_npy_type = 'i8' + elif npy_type == 'f8' and this_npy_type == 'f4': + this_npy_type = 'f8' + + if this_npy_type != npy_type: + raise ValueError( + "bad input data for column '%s': " + "expected '%s', got '%s'" % (col_name, npy_type, this_npy_type) + ) + + if this_shape != col_shape: + raise ValueError( + "bad input shape for column '%s': " + "expected '%s', got '%s'" % (col_name, col_shape, this_shape) + ) + + def write_var_column(self, column, data, firstrow=0, **keys): + """ + Write data to a variable-length column in this HDU + + This HDU must be a table HDU. + + parameters + ---------- + column: scalar string/integer + The column in which to write. Can be the name or number (0 offset) + column: ndarray + Numerical python array to write. This must be an object array. + firstrow: integer, optional + At which row you should begin writing. Be sure you know what you + are doing! For appending see the append() method. Default 0. + """ + + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + if not is_object(data): + raise ValueError( + "Only object fields can be written to variable-length arrays" + ) + colnum = self._extract_colnum(column) + + self._FITS.write_var_column( + self._ext + 1, colnum + 1, data, firstrow=firstrow + 1 + ) + self._cached_info = None # invalidate info cache + + def insert_column( + self, name, data, colnum=None, write_bitcols=None, **keys + ): + """ + Insert a new column. + + parameters + ---------- + name: string + The column name + data: + The data to write into the new column. + colnum: int, optional + The column number for the new column, zero-offset. Default + is to add the new column after the existing ones. + write_bitcols: bool, optional + If set, write logical as bit cols. This can over-ride the + internal class setting. Default of None respects the inner + class setting. + + Notes + ----- + This method is used un-modified by ascii tables as well. + """ + + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + if write_bitcols is None: + write_bitcols = self.write_bitcols + + if name in self._info["colnames"]: + raise ValueError("column '%s' already exists" % name) + + if IS_PY3 and data.dtype.char == 'U': + # fast dtype conversion using an empty array + # we could hack at the actual text description, but using + # the numpy API is probably safer + # this also avoids doing a dtype conversion on every array + # element which could b expensive + descr = np.empty(1).astype(data.dtype).astype('S').dtype.descr + else: + descr = data.dtype.descr + + if len(descr) > 1: + raise ValueError( + "you can only insert a single column, requested: %s" % descr + ) + + this_descr = descr[0] + this_descr = [name, this_descr[1]] + if len(data.shape) > 1: + this_descr += [data.shape[1:]] + this_descr = tuple(this_descr) + + name, fmt, dims = _npy2fits( + this_descr, + table_type=self._table_type_str, + write_bitcols=write_bitcols, + ) + if dims is not None: + dims = [dims] + + if colnum is None: + new_colnum = len(self._info['colinfo']) + 1 + else: + new_colnum = colnum + 1 + + self._FITS.insert_col(self._ext + 1, new_colnum, name, fmt, tdim=dims) + + self._cached_info = None # invalidate info cache + + self.write_column(name, data) + + def append(self, data, columns=None, names=None, **keys): + """ + Append new rows to a table HDU + + parameters + ---------- + data: ndarray or list of arrays + A numerical python array with fields (recarray) or a list of + arrays. Should have the same fields as the existing table. If only + a subset of the table columns are present, the other columns are + filled with zeros. + columns: list, optional + If data is a list of arrays, you must send columns as a list + of names or column numbers. You can also use the `names` keyword + argument. + names: list, optional + If data is a list of arrays, you must send columns as a list + of names or column numbers. You can also use the `columns` keyword + argument. + """ + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + firstrow = self._info['nrows'] + self.write(data, firstrow=firstrow, columns=columns, names=names) + + def delete_rows(self, rows): + """ + Delete rows from the table + + parameters + ---------- + rows: sequence or slice + The exact rows to delete as a sequence, or a slice. + + examples + -------- + # delete a range of rows + with fitsio.FITS(fname,'rw') as fits: + fits['mytable'].delete_rows(slice(3,20)) + + # delete specific rows + with fitsio.FITS(fname,'rw') as fits: + rows2delete = [3,88,76] + fits['mytable'].delete_rows(rows2delete) + """ + + if rows is None: + return + + # extract and convert to 1-offset for C routine + if isinstance(rows, slice): + rows = self._process_slice(rows) + if rows.step is not None and rows.step != 1: + rows = np.arange( + rows.start + 1, + rows.stop + 1, + rows.step, + ) + else: + # rows must be 1-offset + rows = slice(rows.start + 1, rows.stop + 1) + else: + rows, sortind = self._extract_rows(rows, sort=True) + # rows must be 1-offset + rows += 1 + + if isinstance(rows, slice): + self._FITS.delete_row_range(self._ext + 1, rows.start, rows.stop) + else: + if rows.size == 0: + return + + self._FITS.delete_rows(self._ext + 1, rows) + + self._cached_info = None # invalidate info cache + + def resize(self, nrows, front=False): + """ + Resize the table to the given size, removing or adding rows as + necessary. Note if expanding the table at the end, it is more + efficient to use the append function than resizing and then + writing. + + New added rows are zerod, except for 'i1', 'u2' and 'u4' data types + which get -128,32768,2147483648 respectively + + parameters + ---------- + nrows: int + new size of table + front: bool, optional + If True, add or remove rows from the front. Default + is False + """ + + nrows_current = self.get_nrows() + if nrows == nrows_current: + return + + if nrows < nrows_current: + rowdiff = nrows_current - nrows + if front: + # delete from the front + start = 0 + stop = rowdiff + else: + # delete from the back + start = nrows + stop = nrows_current + + self.delete_rows(slice(start, stop)) + else: + rowdiff = nrows - nrows_current + if front: + # in this case zero is what we want, since the code inserts + firstrow = 0 + else: + firstrow = nrows_current + self._FITS.insert_rows(self._ext + 1, firstrow, rowdiff) + + self._cached_info = None # invalidate info cache + + def read( + self, + columns=None, + rows=None, + vstorage=None, + upper=False, + lower=False, + trim_strings=False, + **keys, + ): + """ + Read data from this HDU + + By default, all data are read. You can set the `columns` and/or + `rows` keywords to read subsets of the data. + + Table data is read into a numpy recarray. To get a single column as + a numpy.ndarray, use the `read_column` method. + + Slice notation is also supported for `TableHDU` types. + + >>> fits = fitsio.FITS(filename) + >>> fits[ext][:] + >>> fits[ext][2:5] + >>> fits[ext][200:235:2] + >>> fits[ext][rows] + >>> fits[ext][cols][rows] + + parameters + ---------- + columns: optional + An optional set of columns to read from table HDUs. Default is to + read all. Can be string or number. If a sequence, a recarray + is always returned. If a scalar, an ordinary array is returned. + rows: optional + An optional list of rows to read from table HDUS. Default is to + read all. + vstorage: string, optional + Over-ride the default method to store variable length columns. Can + be 'fixed' or 'object'. See docs on fitsio.FITS for details. + lower: bool, optional + If True, force all columns names to lower case in output. Will over + ride the lower= keyword from construction. + upper: bool, optional + If True, force all columns names to upper case in output. Will over + ride the lower= keyword from construction. + trim_strings: bool, optional + If True, trim trailing spaces from strings. Will over-ride the + trim_strings= keyword from constructor. + """ + + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + if columns is not None: + data = self.read_columns( + columns, + rows=rows, + vstorage=vstorage, + upper=upper, + lower=lower, + trim_strings=trim_strings, + ) + elif rows is not None: + # combinations of row and column subsets are covered by + # read_columns so we pass colnums=None here to get all columns + data = self.read_rows( + rows, + vstorage=vstorage, + upper=upper, + lower=lower, + trim_strings=trim_strings, + ) + else: + data = self._read_all( + vstorage=vstorage, + upper=upper, + lower=lower, + trim_strings=trim_strings, + ) + + return data + + def _read_all( + self, + vstorage=None, + upper=False, + lower=False, + trim_strings=False, + colnums=None, + **keys, + ): + """ + Read all data in the HDU. + + parameters + ---------- + vstorage: string, optional + Over-ride the default method to store variable length columns. Can + be 'fixed' or 'object'. See docs on fitsio.FITS for details. + lower: bool, optional + If True, force all columns names to lower case in output. Will over + ride the lower= keyword from construction. + upper: bool, optional + If True, force all columns names to upper case in output. Will over + ride the lower= keyword from construction. + trim_strings: bool, optional + If True, trim trailing spaces from strings. Will over-ride the + trim_strings= keyword from constructor. + colnums: integer array, optional + The column numbers, 0 offset + """ + + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + dtype, offsets, isvar = self.get_rec_dtype( + colnums=colnums, vstorage=vstorage + ) + + (w,) = np.where(isvar == True) # noqa + has_tbit = self._check_tbit() + + if w.size > 0: + if vstorage is None: + _vstorage = self._vstorage + else: + _vstorage = vstorage + colnums = self._extract_colnums() + rows = None + sortind = None + array = self._read_rec_with_var( + colnums, + rows, + sortind, + dtype, + offsets, + isvar, + _vstorage, + ) + elif has_tbit: + # drop down to read_columns since we can't stuff into a + # contiguous array + colnums = self._extract_colnums() + array = self.read_columns( + colnums, + rows=None, + vstorage=vstorage, + upper=upper, + lower=lower, + trim_strings=trim_strings, + ) + else: + firstrow = 1 # noqa - not used? + nrows = self._info['nrows'] + array = np.zeros(nrows, dtype=dtype) + + self._FITS.read_as_rec(self._ext + 1, 1, nrows, array) + + array = self._maybe_decode_fits_ascii_strings_to_unicode_py3(array) + + for colnum, name in enumerate(array.dtype.names): + self._rescale_and_convert_field_inplace( + array, + name, + self._info['colinfo'][colnum]['tscale'], + self._info['colinfo'][colnum]['tzero'], + ) + + if self.lower or lower: + _names_to_lower_if_recarray(array) + elif self.upper or upper: + _names_to_upper_if_recarray(array) + + self._maybe_trim_strings(array, trim_strings=trim_strings) + return array + + def read_column( + self, + col, + rows=None, + vstorage=None, + upper=False, + lower=False, + trim_strings=False, + **keys, + ): + """ + Read the specified column + + Alternatively, you can use slice notation + + >>> fits=fitsio.FITS(filename) + >>> fits[ext][colname][:] + >>> fits[ext][colname][2:5] + >>> fits[ext][colname][200:235:2] + >>> fits[ext][colname][rows] + + Note, if reading multiple columns, it is more efficient to use + read(columns=) or slice notation with a list of column names. + + parameters + ---------- + col: string/int, required + The column name or number. + rows: optional + An optional set of row numbers to read. + vstorage: string, optional + Over-ride the default method to store variable length columns. Can + be 'fixed' or 'object'. See docs on fitsio.FITS for details. + lower: bool, optional + If True, force all columns names to lower case in output. Will over + ride the lower= keyword from construction. + upper: bool, optional + If True, force all columns names to upper case in output. Will over + ride the lower= keyword from construction. + trim_strings: bool, optional + If True, trim trailing spaces from strings. Will over-ride the + trim_strings= keyword from constructor. + """ + + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + res = self.read_columns( + [col], + rows=rows, + vstorage=vstorage, + upper=upper, + lower=lower, + trim_strings=trim_strings, + ) + colname = res.dtype.names[0] + data = res[colname] + + self._maybe_trim_strings(data, trim_strings=trim_strings) + return data + + def read_rows( + self, + rows, + vstorage=None, + upper=False, + lower=False, + trim_strings=False, + **keys, + ): + """ + Read the specified rows. + + parameters + ---------- + rows: list,array + A list or array of row indices. + vstorage: string, optional + Over-ride the default method to store variable length columns. Can + be 'fixed' or 'object'. See docs on fitsio.FITS for details. + lower: bool, optional + If True, force all columns names to lower case in output. Will over + ride the lower= keyword from construction. + upper: bool, optional + If True, force all columns names to upper case in output. Will over + ride the lower= keyword from construction. + trim_strings: bool, optional + If True, trim trailing spaces from strings. Will over-ride the + trim_strings= keyword from constructor. + """ + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + if rows is None: + # we actually want all rows! + return self._read_all() + + if self._info['hdutype'] == ASCII_TBL: + return self.read( + rows=rows, + vstorage=vstorage, + upper=upper, + lower=lower, + trim_strings=trim_strings, + ) + + rows, sortind = self._extract_rows(rows) + dtype, offsets, isvar = self.get_rec_dtype(vstorage=vstorage) + + (w,) = np.where(isvar == True) # noqa + has_tbit = self._check_tbit() + + if w.size > 0: + if vstorage is None: + _vstorage = self._vstorage + else: + _vstorage = vstorage + colnums = self._extract_colnums() + return self._read_rec_with_var( + colnums, + rows, + sortind, + dtype, + offsets, + isvar, + _vstorage, + ) + elif has_tbit: + # drop down to read_columns since we can't stuff into a + # contiguous array + colnums = self._extract_colnums() + array = self.read_columns( + colnums, + rows=rows, + vstorage=vstorage, + upper=upper, + lower=lower, + trim_strings=trim_strings, + ) + else: + array = np.zeros(rows.size, dtype=dtype) + self._FITS.read_rows_as_rec(self._ext + 1, array, rows, sortind) + + array = self._maybe_decode_fits_ascii_strings_to_unicode_py3(array) + + for colnum, name in enumerate(array.dtype.names): + self._rescale_and_convert_field_inplace( + array, + name, + self._info['colinfo'][colnum]['tscale'], + self._info['colinfo'][colnum]['tzero'], + ) + + if self.lower or lower: + _names_to_lower_if_recarray(array) + elif self.upper or upper: + _names_to_upper_if_recarray(array) + + self._maybe_trim_strings(array, trim_strings=trim_strings) + + return array + + def read_columns( + self, + columns, + rows=None, + vstorage=None, + upper=False, + lower=False, + trim_strings=False, + **keys, + ): + """ + read a subset of columns from this binary table HDU + + By default, all rows are read. Send rows= to select subsets of the + data. Table data are read into a recarray for multiple columns, + plain array for a single column. + + parameters + ---------- + columns: list/array + An optional set of columns to read from table HDUs. Can be string + or number. If a sequence, a recarray is always returned. If a + scalar, an ordinary array is returned. + rows: list/array, optional + An optional list of rows to read from table HDUS. Default is to + read all. + vstorage: string, optional + Over-ride the default method to store variable length columns. Can + be 'fixed' or 'object'. See docs on fitsio.FITS for details. + lower: bool, optional + If True, force all columns names to lower case in output. Will over + ride the lower= keyword from construction. + upper: bool, optional + If True, force all columns names to upper case in output. Will over + ride the lower= keyword from construction. + trim_strings: bool, optional + If True, trim trailing spaces from strings. Will over-ride the + trim_strings= keyword from constructor. + """ + + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + if self._info['hdutype'] == ASCII_TBL: + return self.read( + columns=columns, + rows=rows, + vstorage=vstorage, + upper=upper, + lower=lower, + trim_strings=trim_strings, + ) + + # if columns is None, returns all. Guaranteed to be unique and sorted + colnums = self._extract_colnums(columns) + if isinstance(colnums, int): + # scalar sent, don't read as a recarray + return self.read_column( + columns, + rows=rows, + vstorage=vstorage, + upper=upper, + lower=lower, + trim_strings=trim_strings, + ) + + # if rows is None still returns None, and is correctly interpreted + # by the reader to mean all + rows, sortind = self._extract_rows(rows) + + # this is the full dtype for all columns + dtype, offsets, isvar = self.get_rec_dtype( + colnums=colnums, vstorage=vstorage + ) + + (w,) = np.where(isvar == True) # noqa + if w.size > 0: + if vstorage is None: + _vstorage = self._vstorage + else: + _vstorage = vstorage + array = self._read_rec_with_var( + colnums, + rows, + sortind, + dtype, + offsets, + isvar, + _vstorage, + ) + else: + if rows is None: + nrows = self._info['nrows'] + else: + nrows = rows.size + + array = np.zeros(nrows, dtype=dtype) + + colnumsp = colnums[:].copy() + colnumsp[:] += 1 + self._FITS.read_columns_as_rec( + self._ext + 1, colnumsp, array, rows, sortind + ) + + array = self._maybe_decode_fits_ascii_strings_to_unicode_py3(array) + + for i in xrange(colnums.size): + colnum = int(colnums[i]) + name = array.dtype.names[i] + self._rescale_and_convert_field_inplace( + array, + name, + self._info['colinfo'][colnum]['tscale'], + self._info['colinfo'][colnum]['tzero'], + ) + + if self._check_tbit(colnums=colnums): + array = self._fix_tbit_dtype(array, colnums) + + if self.lower or lower: + _names_to_lower_if_recarray(array) + elif self.upper or upper: + _names_to_upper_if_recarray(array) + + self._maybe_trim_strings(array, trim_strings=trim_strings) + + return array + + def read_slice( + self, + firstrow, + lastrow, + step=1, + vstorage=None, + lower=False, + upper=False, + trim_strings=False, + **keys, + ): + """ + Read the specified row slice from a table. + + Read all rows between firstrow and lastrow (non-inclusive, as per + python slice notation). Note you must use slice notation for + images, e.g. f[ext][20:30, 40:50] + + parameters + ---------- + firstrow: integer + The first row to read + lastrow: integer + The last row to read, non-inclusive. This follows the python list + slice convention that one does not include the last element. + step: integer, optional + Step between rows, default 1. e.g., if step is 2, skip every other + row. + vstorage: string, optional + Over-ride the default method to store variable length columns. Can + be 'fixed' or 'object'. See docs on fitsio.FITS for details. + lower: bool, optional + If True, force all columns names to lower case in output. Will over + ride the lower= keyword from construction. + upper: bool, optional + If True, force all columns names to upper case in output. Will over + ride the lower= keyword from construction. + trim_strings: bool, optional + If True, trim trailing spaces from strings. Will over-ride the + trim_strings= keyword from constructor. + """ + + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + if self._info['hdutype'] == ASCII_TBL: + rows = np.arange(firstrow, lastrow, step, dtype='i8') + return self.read_ascii( + rows=rows, + vstorage=vstorage, + upper=upper, + lower=lower, + trim_strings=trim_strings, + ) + + if self._info['hdutype'] == IMAGE_HDU: + raise ValueError("slices currently only supported for tables") + + maxrow = self._info['nrows'] + if firstrow < 0 or lastrow > maxrow: + raise ValueError( + "slice must specify a sub-range of [%d,%d]" % (0, maxrow) + ) + + dtype, offsets, isvar = self.get_rec_dtype(vstorage=vstorage) + + (w,) = np.where(isvar == True) # noqa + has_tbit = self._check_tbit() + + if w.size > 0: + if vstorage is None: + _vstorage = self._vstorage + else: + _vstorage = vstorage + rows = np.arange(firstrow, lastrow, step, dtype='i8') + sortind = np.arange(rows.size, dtype='i8') + colnums = self._extract_colnums() + array = self._read_rec_with_var( + colnums, rows, sortind, dtype, offsets, isvar, _vstorage + ) + elif has_tbit: + # drop down to read_columns since we can't stuff into a + # contiguous array + colnums = self._extract_colnums() + rows = np.arange(firstrow, lastrow, step, dtype='i8') + array = self.read_columns( + colnums, + rows=rows, + vstorage=vstorage, + upper=upper, + lower=lower, + trim_strings=trim_strings, + ) + else: + if step != 1: + rows = np.arange(firstrow, lastrow, step, dtype='i8') + array = self.read(rows=rows) + else: + # no +1 because lastrow is non-inclusive + nrows = lastrow - firstrow + array = np.zeros(nrows, dtype=dtype) + + # only first needs to be +1. This is becuase the c code is + # inclusive + self._FITS.read_as_rec( + self._ext + 1, firstrow + 1, lastrow, array + ) + + array = self._maybe_decode_fits_ascii_strings_to_unicode_py3( + array + ) + + for colnum, name in enumerate(array.dtype.names): + self._rescale_and_convert_field_inplace( + array, + name, + self._info['colinfo'][colnum]['tscale'], + self._info['colinfo'][colnum]['tzero'], + ) + + if self.lower or lower: + _names_to_lower_if_recarray(array) + elif self.upper or upper: + _names_to_upper_if_recarray(array) + + self._maybe_trim_strings(array, trim_strings=trim_strings) + + return array + + def get_rec_dtype(self, colnums=None, vstorage=None, **keys): + """ + Get the dtype for the specified columns + + parameters + ---------- + colnums: integer array, optional + The column numbers, 0 offset + vstorage: string, optional + See docs in read_columns + """ + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + if vstorage is None: + _vstorage = self._vstorage + else: + _vstorage = vstorage + + if colnums is None: + colnums = self._extract_colnums() + + descr = [] + isvararray = np.zeros(len(colnums), dtype=bool) + for i, colnum in enumerate(colnums): + dt, isvar = self.get_rec_column_descr(colnum, _vstorage) + descr.append(dt) + isvararray[i] = isvar + dtype = np.dtype(descr) + + offsets = np.zeros(len(colnums), dtype='i8') + for i, n in enumerate(dtype.names): + offsets[i] = dtype.fields[n][1] + return dtype, offsets, isvararray + + def _check_tbit(self, colnums=None, **keys): + """ + Check if one of the columns is a TBIT column + + parameters + ---------- + colnums: integer array, optional + """ + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + if colnums is None: + colnums = self._extract_colnums() + + has_tbit = False + for i, colnum in enumerate(colnums): + npy_type, isvar, istbit = self._get_tbl_numpy_dtype(colnum) + if istbit: + has_tbit = True + break + + return has_tbit + + def _fix_tbit_dtype(self, array, colnums): + """ + If necessary, patch up the TBIT to convert to bool array + + parameters + ---------- + array: record array + colnums: column numbers for lookup + """ + descr = array.dtype.descr + for i, colnum in enumerate(colnums): + npy_type, isvar, istbit = self._get_tbl_numpy_dtype(colnum) + if istbit: + coldescr = list(descr[i]) + coldescr[1] = '?' + descr[i] = tuple(coldescr) + + return array.view(descr) + + def _get_simple_dtype_and_shape(self, colnum, rows=None): + """ + When reading a single column, we want the basic data + type and the shape of the array. + + for scalar columns, shape is just nrows, otherwise + it is (nrows, dim1, dim2) + + Note if rows= is sent and only a single row is requested, + the shape will be (dim2,dim2) + """ + + # basic datatype + npy_type, isvar, istbit = self._get_tbl_numpy_dtype(colnum) + info = self._info['colinfo'][colnum] + name = info['name'] + + if rows is None: + nrows = self._info['nrows'] + else: + nrows = rows.size + + shape = None + tdim = info['tdim'] + + shape = _tdim2shape(tdim, name, is_string=(npy_type[0] == 'S')) + if shape is not None: + if nrows > 1: + if not isinstance(shape, tuple): + # vector + shape = (nrows, shape) + else: + # multi-dimensional + shape = tuple([nrows] + list(shape)) + else: + # scalar + shape = nrows + return npy_type, shape + + def get_rec_column_descr(self, colnum, vstorage): + """ + Get a descriptor entry for the specified column. + + parameters + ---------- + colnum: integer + The column number, 0 offset + vstorage: string + See docs in read_columns + """ + npy_type, isvar, istbit = self._get_tbl_numpy_dtype(colnum) + name = self._info['colinfo'][colnum]['name'] + + if isvar: + if vstorage == 'object': + descr = (name, 'O') + else: + tform = self._info['colinfo'][colnum]['tform'] + max_size = _extract_vararray_max(tform) + + if max_size <= 0: + name = self._info['colinfo'][colnum]['name'] + mess = 'Will read as an object field' + if max_size < 0: + mess = "Column '%s': No maximum size: '%s'. %s" + mess = mess % (name, tform, mess) + warnings.warn(mess, FITSRuntimeWarning) + else: + mess = "Column '%s': Max size is zero: '%s'. %s" + mess = mess % (name, tform, mess) + warnings.warn(mess, FITSRuntimeWarning) + + # we are forced to read this as an object array + return self.get_rec_column_descr(colnum, 'object') + + if npy_type[0] == 'S': + # variable length string columns cannot + # themselves be arrays I don't think + npy_type = 'S%d' % max_size + descr = (name, npy_type) + elif npy_type[0] == 'U': + # variable length string columns cannot + # themselves be arrays I don't think + npy_type = 'U%d' % max_size + descr = (name, npy_type) + else: + descr = (name, npy_type, max_size) + else: + tdim = self._info['colinfo'][colnum]['tdim'] + shape = _tdim2shape( + tdim, + name, + is_string=(npy_type[0] == 'S' or npy_type[0] == 'U'), + ) + if shape is not None: + descr = (name, npy_type, shape) + else: + descr = (name, npy_type) + return descr, isvar + + def _read_rec_with_var( + self, colnums, rows, sortind, dtype, offsets, isvar, vstorage + ): + """ + Read columns from a table into a rec array, including variable length + columns. This is special because, for efficiency, it involves reading + from the main table as normal but skipping the columns in the array + that are variable. Then reading the variable length columns, with + accounting for strides appropriately. + + row and column numbers should be checked before calling this function + """ + + colnumsp = colnums + 1 + if rows is None: + nrows = self._info['nrows'] + else: + nrows = rows.size + array = np.zeros(nrows, dtype=dtype) + + # read from the main table first + (wnotvar,) = np.where(isvar == False) # noqa + if wnotvar.size > 0: + # this will be contiguous (not true for slices) + thesecol = colnumsp[wnotvar] + theseoff = offsets[wnotvar] + self._FITS.read_columns_as_rec_byoffset( + self._ext + 1, + thesecol, + theseoff, + array, + rows, + sortind, + ) + for i in xrange(thesecol.size): + name = array.dtype.names[wnotvar[i]] + colnum = thesecol[i] - 1 + self._rescale_and_convert_field_inplace( + array, + name, + self._info['colinfo'][colnum]['tscale'], + self._info['colinfo'][colnum]['tzero'], + ) + + array = self._maybe_decode_fits_ascii_strings_to_unicode_py3(array) + + # now read the variable length arrays we may be able to speed this up + # by storing directly instead of reading first into a list + (wvar,) = np.where(isvar == True) # noqa + if wvar.size > 0: + # this will be contiguous (not true for slices) + thesecol = colnumsp[wvar] + for i in xrange(thesecol.size): + colnump = thesecol[i] + name = array.dtype.names[wvar[i]] + dlist = self._FITS.read_var_column_as_list( + self._ext + 1, + colnump, + rows, + sortind, + ) + + if isinstance(dlist[0], str) or ( + IS_PY3 and isinstance(dlist[0], bytes) + ): + is_string = True + else: + is_string = False + + if array[name].dtype.descr[0][1][1] == 'O': + # storing in object array + # get references to each, no copy made + for irow, item in enumerate(dlist): + if sortind is not None: + irow = sortind[irow] + if IS_PY3 and isinstance(item, bytes): + item = item.decode('ascii') + array[name][irow] = item + else: + for irow, item in enumerate(dlist): + if sortind is not None: + irow = sortind[irow] + if IS_PY3 and isinstance(item, bytes): + item = item.decode('ascii') + + if is_string: + array[name][irow] = item + else: + ncopy = len(item) + + if IS_PY3: + ts = array[name].dtype.descr[0][1][1] + if ts != 'S' and ts != 'U': + array[name][irow][0:ncopy] = item[:] + else: + array[name][irow] = item + else: + array[name][irow][0:ncopy] = item[:] + + return array + + def _extract_rows(self, rows, sort=False): + """ + Extract an array of rows from an input scalar or sequence + """ + if rows is not None: + rows = np.array(rows, ndmin=1, copy=copy_if_needed, dtype='i8') + if sort: + rows = np.unique(rows) + return rows, None + + # returns unique, sorted. Force i8 for 32-bit systems + sortind = np.array(rows.argsort(), dtype='i8', copy=copy_if_needed) + + maxrow = self._info['nrows'] - 1 + if rows.size > 0: + firstrow = rows[sortind[0]] + lastrow = rows[sortind[-1]] + + if len(rows) > 0 and (firstrow < 0 or lastrow > maxrow): + raise ValueError("rows must be in [%d,%d]" % (0, maxrow)) + else: + sortind = None + + return rows, sortind + + def _process_slice(self, arg): + """ + process the input slice for use calling the C code + """ + start = arg.start + stop = arg.stop + step = arg.step + + nrows = self._info['nrows'] + if step is None: + step = 1 + if start is None: + start = 0 + if stop is None: + stop = nrows + + if start < 0: + start = nrows + start + if start < 0: + raise IndexError("Index out of bounds") + + if stop < 0: + stop = nrows + start + 1 + + if stop < start: + # will return an empty struct + stop = start + + if stop > nrows: + stop = nrows + return slice(start, stop, step) + + def _slice2rows(self, start, stop, step=None): + """ + Convert a slice to an explicit array of rows + """ + nrows = self._info['nrows'] + if start is None: + start = 0 + if stop is None: + stop = nrows + if step is None: + step = 1 + + tstart = self._fix_range(start) + tstop = self._fix_range(stop) + if tstart == 0 and tstop == nrows and step is None: + # this is faster: if all fields are also requested, then a + # single fread will be done + return None + if stop < start: + raise ValueError("start is greater than stop in slice") + return np.arange(tstart, tstop, step, dtype='i8') + + def _fix_range(self, num, isslice=True): + """ + Ensure the input is within range. + + If el=True, then don't treat as a slice element + """ + + nrows = self._info['nrows'] + if isslice: + # include the end + if num < 0: + num = nrows + (1 + num) + elif num > nrows: + num = nrows + else: + # single element + if num < 0: + num = nrows + num + elif num > (nrows - 1): + num = nrows - 1 + + return num + + def _rescale_and_convert_field_inplace(self, array, name, scale, zero): + """ + Apply fits scalings. Also, convert bool to proper + numpy boolean values + """ + self._rescale_array(array[name], scale, zero) + if array[name].dtype == bool: + array[name] = self._convert_bool_array(array[name]) + return array + + def _rescale_and_convert(self, array, scale, zero, name=None): + """ + Apply fits scalings. Also, convert bool to proper + numpy boolean values + """ + self._rescale_array(array, scale, zero) + if array.dtype == bool: + array = self._convert_bool_array(array) + + return array + + def _rescale_array(self, array, scale, zero): + """ + Scale the input array + """ + if scale != 1.0: + sval = np.array(scale, dtype=array.dtype) + array *= sval + if zero != 0.0: + zval = np.array(zero, dtype=array.dtype) + array += zval + + def _maybe_trim_strings(self, array, trim_strings=False, **keys): + """ + if requested, trim trailing white space from + all string fields in the input array + """ + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + if self.trim_strings or trim_strings: + _trim_strings(array) + + def _maybe_decode_fits_ascii_strings_to_unicode_py3(self, array): + if IS_PY3: + do_conversion = False + new_dt = [] + for _dt in array.dtype.descr: + if 'S' in _dt[1]: + do_conversion = True + if len(_dt) == 3: + new_dt.append( + ( + _dt[0], + _dt[1].replace('S', 'U').replace('|', ''), + _dt[2], + ) + ) + else: + new_dt.append( + (_dt[0], _dt[1].replace('S', 'U').replace('|', '')) + ) + else: + new_dt.append(_dt) + if do_conversion: + array = array.astype(new_dt, copy=copy_if_needed) + return array + + def _convert_bool_array(self, array): + """ + cfitsio reads as characters 'T' and 'F' -- convert to real boolean + If input is a fits bool, convert to numpy boolean + """ + + output = (array.view(np.int8) == ord('T')).astype(bool) + return output + + def _get_tbl_numpy_dtype(self, colnum, include_endianness=True): + """ + Get numpy type for the input column + """ + table_type = self._info['hdutype'] + table_type_string = _hdu_type_map[table_type] + try: + ftype = self._info['colinfo'][colnum]['eqtype'] + if table_type == ASCII_TBL: + npy_type = _table_fits2npy_ascii[abs(ftype)] + else: + npy_type = _table_fits2npy[abs(ftype)] + except KeyError: + raise KeyError( + "unsupported %s fits data " + "type: %d" % (table_type_string, ftype) + ) + + istbit = False + if ftype == 1: + istbit = True + + isvar = False + if ftype < 0: + isvar = True + if include_endianness: + # if binary we will read the big endian bytes directly, + # if ascii we read into native byte order + if table_type == ASCII_TBL: + addstr = '' + else: + addstr = '>' + if npy_type not in ['u1', 'i1', 'S', 'U']: + npy_type = addstr + npy_type + + if npy_type == 'S': + width = self._info['colinfo'][colnum]['width'] + npy_type = 'S%d' % width + elif npy_type == 'U': + width = self._info['colinfo'][colnum]['width'] + npy_type = 'U%d' % width + + return npy_type, isvar, istbit + + def _process_args_as_rows_or_columns(self, arg, unpack=False): + """ + We must be able to interpret the args as as either a column name or + row number, or sequences thereof. Numpy arrays and slices are also + fine. + + Examples: + 'field' + 35 + [35,55,86] + ['f1',f2',...] + Can also be tuples or arrays. + """ + + flags = set() + # + if isinstance(arg, (tuple, list, np.ndarray)): + # a sequence was entered + if isstring(arg[0]): + result = arg + else: + result = arg + flags.add('isrows') + elif isstring(arg): + # a single string was entered + result = arg + elif isinstance(arg, slice): + if unpack: + flags.add('isrows') + result = self._slice2rows(arg.start, arg.stop, arg.step) + else: + flags.add('isrows') + flags.add('isslice') + result = self._process_slice(arg) + else: + # a single object was entered. + # Probably should apply some more checking on this + result = arg + flags.add('isrows') + if np.ndim(arg) == 0: + flags.add('isscalar') + + return result, flags + + def _read_var_column(self, colnum, rows, sortind, vstorage): + """ + + first read as a list of arrays, then copy into either a fixed length + array or an array of objects, depending on vstorage. + + """ + + if IS_PY3: + stype = bytes + else: + stype = str + + dlist = self._FITS.read_var_column_as_list( + self._ext + 1, + colnum + 1, + rows, + sortind, + ) + + if vstorage == 'fixed': + tform = self._info['colinfo'][colnum]['tform'] + max_size = _extract_vararray_max(tform) + + if max_size <= 0: + name = self._info['colinfo'][colnum]['name'] + mess = 'Will read as an object field' + if max_size < 0: + mess = "Column '%s': No maximum size: '%s'. %s" + mess = mess % (name, tform, mess) + warnings.warn(mess, FITSRuntimeWarning) + else: + mess = "Column '%s': Max size is zero: '%s'. %s" + mess = mess % (name, tform, mess) + warnings.warn(mess, FITSRuntimeWarning) + + # we are forced to read this as an object array + return self._read_var_column(colnum, rows, 'object') + + if isinstance(dlist[0], stype): + descr = 'S%d' % max_size + array = np.fromiter(dlist, descr) + if IS_PY3: + array = array.astype('U', copy=copy_if_needed) + else: + descr = dlist[0].dtype.str + array = np.zeros((len(dlist), max_size), dtype=descr) + + for irow, item in enumerate(dlist): + if sortind is not None: + irow = sortind[irow] + ncopy = len(item) + array[irow, 0:ncopy] = item[:] + else: + array = np.zeros(len(dlist), dtype='O') + for irow, item in enumerate(dlist): + if sortind is not None: + irow = sortind[irow] + + if IS_PY3 and isinstance(item, bytes): + item = item.decode('ascii') + array[irow] = item + + return array + + def _extract_colnums(self, columns=None): + """ + Extract an array of columns from the input + """ + if columns is None: + return np.arange(self._info["ncol"], dtype='i8') + + if not isinstance(columns, (tuple, list, np.ndarray)): + # is a scalar + return self._extract_colnum(columns) + + colnums = np.zeros(len(columns), dtype='i8') + for i in xrange(colnums.size): + colnums[i] = self._extract_colnum(columns[i]) + + # returns unique sorted + colnums = np.unique(colnums) + return colnums + + def _extract_colnum(self, col): + """ + Get the column number for the input column + """ + if isinteger(col): + colnum = col + + if (colnum < 0) or (colnum > (self._info["ncol"] - 1)): + raise ValueError( + "column number should be in [0,%d]" + % (self._info["ncol"] - 1) + ) + else: + colstr = mks(col) + try: + if self.case_sensitive: + mess = "column name '%s' not found (case sensitive)" % col + colnum = self._info["colnames"].index(colstr) + else: + mess = ( + "column name '%s' not found (case insensitive)" % col + ) + colnum = self._info["colnames_lower"].index(colstr.lower()) + except ValueError: + raise ValueError(mess) + return int(colnum) + + def _update_info(self): + """ + Call parent method and make sure this is in fact a + table HDU. Set some convenience data. + """ + super(TableHDU, self)._update_info() + if self._info['hdutype'] == IMAGE_HDU: + mess = "Extension %s is not a Table HDU" % self.ext + raise ValueError(mess) + if 'colinfo' in self._info: + self._info["colnames"] = [i['name'] for i in self._info['colinfo']] + self._info["colnames_lower"] = [ + i['name'].lower() for i in self._info['colinfo'] + ] + self._info["ncol"] = len(self._info["colnames"]) + + def __getitem__(self, arg): + """ + Get data from a table using python [] notation. + + You can use [] to extract column and row subsets, or read everything. + The notation is essentially the same as numpy [] notation, except that + a sequence of column names may also be given. Examples reading from + "filename", extension "ext" + + fits=fitsio.FITS(filename) + fits[ext][:] + fits[ext][2] # returns a scalar + fits[ext][2:5] + fits[ext][200:235:2] + fits[ext][rows] + fits[ext][cols][rows] + + Note data are only read once the rows are specified. + + Note you can only read variable length arrays the default way, + using this function, so set it as you want on construction. + + This function is used for ascii tables as well + """ + + res, flags = self._process_args_as_rows_or_columns(arg) + + if 'isrows' in flags: + # rows were entered: read all columns + if 'isslice' in flags: + array = self.read_slice(res.start, res.stop, res.step) + else: + # will also get here if slice is entered but this + # is an ascii table + array = self.read(rows=res) + else: + return TableColumnSubset(self, res) + + if self.lower: + _names_to_lower_if_recarray(array) + elif self.upper: + _names_to_upper_if_recarray(array) + + self._maybe_trim_strings(array) + + if 'isscalar' in flags: + assert array.shape[0] == 1 + array = array[0] + return array + + def __iter__(self): + """ + Get an iterator for a table + + e.g. + f=fitsio.FITS(fname) + hdu1 = f[1] + for row in hdu1: + ... + """ + + # always start with first row + self._iter_row = 0 + + # for iterating we must assume the number of rows will not change + self._iter_nrows = self.get_nrows() + + self._buffer_iter_rows(0) + return self + + def next(self): + """ + get the next row when iterating + + e.g. + f=fitsio.FITS(fname) + hdu1 = f[1] + for row in hdu1: + ... + + By default read one row at a time. Send iter_row_buffer to get a more + efficient buffering. + """ + return self._get_next_buffered_row() + + __next__ = next + + def _get_next_buffered_row(self): + """ + Get the next row for iteration. + """ + if self._iter_row == self._iter_nrows: + raise StopIteration + + if self._row_buffer_index >= self._iter_row_buffer: + self._buffer_iter_rows(self._iter_row) + + data = self._row_buffer[self._row_buffer_index] + self._iter_row += 1 + self._row_buffer_index += 1 + return data + + def _buffer_iter_rows(self, start): + """ + Read in the buffer for iteration + """ + self._row_buffer = self[start : start + self._iter_row_buffer] + + # start back at the front of the buffer + self._row_buffer_index = 0 + + def __repr__(self): + """ + textual representation for some metadata + """ + text, spacing = self._get_repr_list() + + text.append('%srows: %d' % (spacing, self._info['nrows'])) + text.append('%scolumn info:' % spacing) + + cspacing = ' ' * 4 + nspace = 4 + nname = 15 + ntype = 6 + format = cspacing + "%-" + str(nname) + "s %" + str(ntype) + "s %s" + pformat = ( + cspacing + + "%-" + + str(nname) + + "s\n %" + + str(nspace + nname + ntype) + + "s %s" + ) + + for colnum, c in enumerate(self._info['colinfo']): + if len(c['name']) > nname: + f = pformat + else: + f = format + + dt, isvar, istbit = self._get_tbl_numpy_dtype( + colnum, include_endianness=False + ) + if isvar: + tform = self._info['colinfo'][colnum]['tform'] + if dt[0] == 'S': + dt = 'S0' + dimstr = 'vstring[%d]' % _extract_vararray_max(tform) + else: + dimstr = 'varray[%s]' % _extract_vararray_max(tform) + else: + if dt[0] == 'S': + is_string = True + else: + is_string = False + dimstr = _get_col_dimstr(c['tdim'], is_string=is_string) + + s = f % (c['name'], dt, dimstr) + text.append(s) + + text = '\n'.join(text) + return text + + +class AsciiTableHDU(TableHDU): + def read( + self, + rows=None, + columns=None, + vstorage=None, + upper=False, + lower=False, + trim_strings=False, + **keys, + ): + """ + read a data from an ascii table HDU + + By default, all rows are read. Send rows= to select subsets of the + data. Table data are read into a recarray for multiple columns, + plain array for a single column. + + parameters + ---------- + columns: list/array + An optional set of columns to read from table HDUs. Can be string + or number. If a sequence, a recarray is always returned. If a + scalar, an ordinary array is returned. + rows: list/array, optional + An optional list of rows to read from table HDUS. Default is to + read all. + vstorage: string, optional + Over-ride the default method to store variable length columns. Can + be 'fixed' or 'object'. See docs on fitsio.FITS for details. + lower: bool, optional + If True, force all columns names to lower case in output. Will over + ride the lower= keyword from construction. + upper: bool, optional + If True, force all columns names to upper case in output. Will over + ride the lower= keyword from construction. + trim_strings: bool, optional + If True, trim trailing spaces from strings. Will over-ride the + trim_strings= keyword from constructor. + """ + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + # if columns is None, returns all. Guaranteed to be unique and sorted + colnums = self._extract_colnums(columns) + if isinstance(colnums, int): + # scalar sent, don't read as a recarray + return self.read_column( + columns, + rows=rows, + vstorage=vstorage, + upper=upper, + lower=lower, + trim_strings=trim_strings, + ) + + rows, sortind = self._extract_rows(rows) + if rows is None: + nrows = self._info['nrows'] + else: + nrows = rows.size + + # if rows is None still returns None, and is correctly interpreted + # by the reader to mean all + rows, sortind = self._extract_rows(rows) + + # this is the full dtype for all columns + dtype, offsets, isvar = self.get_rec_dtype( + colnums=colnums, vstorage=vstorage + ) + array = np.zeros(nrows, dtype=dtype) + + # note reading into existing data + (wnotvar,) = np.where(isvar == False) # noqa + if wnotvar.size > 0: + for i in wnotvar: + colnum = colnums[i] + name = array.dtype.names[i] + a = array[name].copy() + self._FITS.read_column( + self._ext + 1, colnum + 1, a, rows, sortind + ) + array[name] = a + del a + + array = self._maybe_decode_fits_ascii_strings_to_unicode_py3(array) + + (wvar,) = np.where(isvar == True) # noqa + if wvar.size > 0: + for i in wvar: + colnum = colnums[i] + name = array.dtype.names[i] + dlist = self._FITS.read_var_column_as_list( + self._ext + 1, + colnum + 1, + rows, + sortind, + ) + if isinstance(dlist[0], str) or ( + IS_PY3 and isinstance(dlist[0], bytes) + ): + is_string = True + else: + is_string = False + + if array[name].dtype.descr[0][1][1] == 'O': + # storing in object array + # get references to each, no copy made + for irow, item in enumerate(dlist): + if sortind is not None: + irow = sortind[irow] + if IS_PY3 and isinstance(item, bytes): + item = item.decode('ascii') + array[name][irow] = item + else: + for irow, item in enumerate(dlist): + if sortind is not None: + irow = sortind[irow] + if IS_PY3 and isinstance(item, bytes): + item = item.decode('ascii') + if is_string: + array[name][irow] = item + else: + ncopy = len(item) + array[name][irow][0:ncopy] = item[:] + + if self.lower or lower: + _names_to_lower_if_recarray(array) + elif self.upper or upper: + _names_to_upper_if_recarray(array) + + self._maybe_trim_strings(array, trim_strings=trim_strings) + + return array + + read_ascii = read + + +class TableColumnSubset(object): + """ + + A class representing a subset of the the columns on disk. When called + with .read() or [ rows ] the data are read from disk. + + Useful because subsets can be passed around to functions, or chained + with a row selection. + + This class is returned when using [ ] notation to specify fields in a + TableHDU class + + fits = fitsio.FITS(fname) + colsub = fits[ext][field_list] + + returns a TableColumnSubset object. To read rows: + + data = fits[ext][field_list][row_list] + + colsub = fits[ext][field_list] + data = colsub[row_list] + data = colsub.read(rows=row_list) + + to read all, use .read() with no args or [:] + """ + + def __init__(self, fitshdu, columns): + """ + Input is the FITS instance and a list of column names. + """ + + self.columns = columns + if isstring(columns) or isinteger(columns): + # this is to check if it exists + self.colnums = [fitshdu._extract_colnum(columns)] + + self.is_scalar = True + self.columns_list = [columns] + else: + # this is to check if it exists + self.colnums = fitshdu._extract_colnums(columns) + + self.is_scalar = False + self.columns_list = columns + + self.fitshdu = fitshdu + + def read( + self, + columns=None, + rows=None, + vstorage=None, + lower=False, + upper=False, + trim_strings=False, + **keys, + ): + """ + Read the data from disk and return as a numpy array + + parameters + ---------- + columns: list/array, optional + An optional set of columns to read from table HDUs. Can be string + or number. If a sequence, a recarray is always returned. If a + scalar, an ordinary array is returned. + rows: optional + An optional list of rows to read from table HDUS. Default is to + read all. + vstorage: string, optional + Over-ride the default method to store variable length columns. Can + be 'fixed' or 'object'. See docs on fitsio.FITS for details. + lower: bool, optional + If True, force all columns names to lower case in output. Will over + ride the lower= keyword from construction. + upper: bool, optional + If True, force all columns names to upper case in output. Will over + ride the lower= keyword from construction. + trim_strings: bool, optional + If True, trim trailing spaces from strings. Will over-ride the + trim_strings= keyword from constructor. + """ + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + if self.is_scalar: + data = self.fitshdu.read_column( + self.columns, + rows=rows, + vstorage=vstorage, + lower=lower, + upper=upper, + trim_strings=trim_strings, + ) + else: + if columns is None: + c = self.columns + else: + c = columns + data = self.fitshdu.read( + columns=c, + rows=rows, + vstorage=vstorage, + lower=lower, + upper=upper, + trim_strings=trim_strings, + ) + + return data + + def __getitem__(self, arg): + """ + If columns are sent, then the columns will just get reset and + we'll return a new object + + If rows are sent, they are read and the result returned. + """ + + # we have to unpack the rows if we are reading a subset + # of the columns because our slice operator only works + # on whole rows. We could allow rows= keyword to + # be a slice... + + res, flags = self.fitshdu._process_args_as_rows_or_columns( + arg, unpack=True + ) + if 'isrows' in flags: + # rows was entered: read all current column subset + array = self.read(rows=res) + if 'isscalar' in flags: + assert array.shape[0] == 1 + array = array[0] + return array + else: + # columns was entered. Return a subset objects + return TableColumnSubset(self.fitshdu, columns=res) + + def __repr__(self): + """ + Representation for TableColumnSubset + """ + spacing = ' ' * 2 + cspacing = ' ' * 4 + + hdu = self.fitshdu + info = self.fitshdu._info + colinfo = info['colinfo'] + + text = [] + text.append("%sfile: %s" % (spacing, hdu._filename)) + text.append("%sextension: %d" % (spacing, info['hdunum'] - 1)) + text.append("%stype: %s" % (spacing, _hdu_type_map[info['hdutype']])) + text.append('%srows: %d' % (spacing, info['nrows'])) + text.append("%scolumn subset:" % spacing) + + cspacing = ' ' * 4 + nspace = 4 + nname = 15 + ntype = 6 + format = cspacing + "%-" + str(nname) + "s %" + str(ntype) + "s %s" + pformat = ( + cspacing + + "%-" + + str(nname) + + "s\n %" + + str(nspace + nname + ntype) + + "s %s" + ) + + for colnum in self.colnums: + cinfo = colinfo[colnum] + + if len(cinfo['name']) > nname: + f = pformat + else: + f = format + + dt, isvar, istbit = hdu._get_tbl_numpy_dtype( + colnum, include_endianness=False + ) + if isvar: + tform = cinfo['tform'] + if dt[0] == 'S': + dt = 'S0' + dimstr = 'vstring[%d]' % _extract_vararray_max(tform) + else: + dimstr = 'varray[%s]' % _extract_vararray_max(tform) + else: + dimstr = _get_col_dimstr(cinfo['tdim']) + + s = f % (cinfo['name'], dt, dimstr) + text.append(s) + + s = "\n".join(text) + return s + + +def _tdim2shape(tdim, name, is_string=False): + shape = None + if tdim is None: + raise ValueError("field '%s' has malformed TDIM" % name) + + if len(tdim) > 1 or tdim[0] > 1: + if is_string: + shape = list(reversed(tdim[1:])) + else: + shape = list(reversed(tdim)) + + if len(shape) == 1: + shape = shape[0] + else: + shape = tuple(shape) + + return shape + + +def _names_to_lower_if_recarray(data): + if data.dtype.names is not None: + data.dtype.names = [n.lower() for n in data.dtype.names] + + +def _names_to_upper_if_recarray(data): + if data.dtype.names is not None: + data.dtype.names = [n.upper() for n in data.dtype.names] + + +def _trim_strings(data): + names = data.dtype.names + if names is not None: + # run through each field separately + for n in names: + if data[n].dtype.descr[0][1][1] in ['S', 'U']: + data[n] = np.char.rstrip(data[n]) + else: + if data.dtype.descr[0][1][1] in ['S', 'U']: + data[:] = np.char.rstrip(data[:]) + + +def _extract_vararray_max(tform): + """ + Extract number from PX(number) + """ + first = tform.find('(') + last = tform.rfind(')') + + if first == -1 or last == -1: + # no max length specified + return -1 + + maxnum = int(tform[first + 1 : last]) + return maxnum + + +def _get_col_dimstr(tdim, is_string=False): + """ + not for variable length + """ + dimstr = '' + if tdim is None: + dimstr = 'array[bad TDIM]' + else: + if is_string: + if len(tdim) > 1: + dimstr = [str(d) for d in tdim[1:]] + else: + if len(tdim) > 1 or tdim[0] > 1: + dimstr = [str(d) for d in tdim] + if dimstr != '': + dimstr = ','.join(dimstr) + dimstr = 'array[%s]' % dimstr + + return dimstr + + +# no support yet for complex +# all strings are read as bytes for python3 and then decoded to unicode +_table_fits2npy = { + 1: 'i1', + 11: 'u1', + 12: 'i1', + # logical. Note pyfits uses this for i1, + # cfitsio casts to char* + 14: 'b1', + 16: 'S', + 20: 'u2', + 21: 'i2', + 30: 'u4', # 30=TUINT + 31: 'i4', # 31=TINT + 40: 'u4', # 40=TULONG + 41: 'i4', # 41=TLONG + 42: 'f4', + 80: 'u8', # 80=TULONGLON + 81: 'i8', + 82: 'f8', + 83: 'c8', # TCOMPLEX + 163: 'c16', +} # TDBLCOMPLEX + +# cfitsio returns only types f8, i4 and strings for column types. in order to +# avoid data loss, we always use i8 for integer types +# all strings are read as bytes for python3 and then decoded to unicode +_table_fits2npy_ascii = { + 16: 'S', + 31: 'i8', # listed as TINT, reading as i8 + 41: 'i8', # listed as TLONG, reading as i8 + 81: 'i8', + 21: 'i4', # listed as TSHORT, reading as i4 + 42: 'f8', # listed as TFLOAT, reading as f8 + 82: 'f8', +} + +# for TFORM +_table_npy2fits_form = { + 'b1': 'L', + 'u1': 'B', + 'i1': 'S', # gets converted to unsigned + 'S': 'A', + 'U': 'A', + 'u2': 'U', # gets converted to signed + 'i2': 'I', + 'u4': 'V', # gets converted to signed + 'i4': 'J', + 'i8': 'K', + 'u8': 'W', + 'f4': 'E', + 'f8': 'D', + 'c8': 'C', + 'c16': 'M', +} + +# from mrdfits; note G gets turned into E +# types= ['A', 'I', 'L', 'B', 'F', 'D', 'C', 'M', 'K'] +# formats=['A1', 'I6', 'I10', 'I4', 'G15.9','G23.17', 'G15.9', 'G23.17', +# 'I20'] + +_table_npy2fits_form_ascii = { + 'S': 'A1', # Need to add max here + 'U': 'A1', # Need to add max here + 'i2': 'I7', # I + 'i4': 'I12', # ?? + # 'i8':'I21', # K # i8 aren't supported + # 'f4':'E15.7', # F + # F We must write as f8 since we can only + # read as f8 + 'f4': 'E26.17', + # D 25.16 looks right, but this is recommended + 'f8': 'E26.17', +} + + +def _npy2fits(d, table_type='binary', write_bitcols=False): + """ + d is the full element from the descr + """ + npy_dtype = d[1][1:] + if npy_dtype[0] == 'S' or npy_dtype[0] == 'U': + name, form, dim = _npy_string2fits(d, table_type=table_type) + else: + name, form, dim = _npy_num2fits( + d, table_type=table_type, write_bitcols=write_bitcols + ) + + return name, form, dim + + +def _npy_num2fits(d, table_type='binary', write_bitcols=False): + """ + d is the full element from the descr + + For vector,array columns the form is the total counts + followed by the code. + + For array columns with dimension greater than 1, the dim is set to + (dim1, dim2, ...) + So it is treated like an extra dimension + + """ + + dim = None + + name = d[0] + + npy_dtype = d[1][1:] + if npy_dtype[0] == 'S' or npy_dtype[0] == 'U': + raise ValueError("got S or U type: use _npy_string2fits") + + if table_type == 'binary': + if npy_dtype not in _table_npy2fits_form: + raise ValueError( + "unsupported type '%s' for binary tables" % npy_dtype + ) + else: + if npy_dtype not in _table_npy2fits_form_ascii: + raise ValueError( + "unsupported type '%s' for ascii tables" % npy_dtype + ) + + if table_type == 'binary': + form = _table_npy2fits_form[npy_dtype] + else: + form = _table_npy2fits_form_ascii[npy_dtype] + + # now the dimensions + if len(d) > 2: + if table_type == 'ascii': + raise ValueError( + "Ascii table columns must be scalar, got %s" % str(d) + ) + + if write_bitcols and npy_dtype == 'b1': + # multi-dimensional boolean + form = 'X' + + # Note, depending on numpy version, even 1-d can be a tuple + if isinstance(d[2], tuple): + count = reduce(lambda x, y: x * y, d[2]) + form = '%d%s' % (count, form) + + if len(d[2]) > 1: + # this is multi-dimensional array column. the form + # should be total elements followed by A + dim = list(reversed(d[2])) + dim = [str(e) for e in dim] + dim = '(' + ','.join(dim) + ')' + else: + # this is a vector (1d array) column + count = d[2] + form = '%d%s' % (count, form) + + return name, form, dim + + +def _npy_string2fits(d, table_type='binary'): + """ + d is the full element from the descr + + form for strings is the total number of bytes followed by A. Thus + for vector or array columns it is the size of the string times the + total number of elements in the array. + + Then the dim is set to + (sizeofeachstring, dim1, dim2, ...) + So it is treated like an extra dimension + + """ + + dim = None + + name = d[0] + + npy_dtype = d[1][1:] + if npy_dtype[0] != 'S' and npy_dtype[0] != 'U': + raise ValueError("expected S or U type, got %s" % npy_dtype[0]) + + # get the size of each string + string_size_str = npy_dtype[1:] + string_size = int(string_size_str) + + if string_size <= 0: + raise ValueError( + 'string sizes must be > 0, got %s for field %s' % (npy_dtype, name) + ) + + # now the dimensions + if len(d) == 2: + if table_type == 'ascii': + form = 'A' + string_size_str + else: + form = string_size_str + 'A' + else: + if table_type == 'ascii': + raise ValueError( + "Ascii table columns must be scalar, got %s" % str(d) + ) + if isinstance(d[2], tuple): + # this is an array column. the form + # should be total elements followed by A + # count = 1 + # count = [count*el for el in d[2]] + count = reduce(lambda x, y: x * y, d[2]) + count = string_size * count + form = '%dA' % count + + if len(d[2]) == 1 and d[2][0] == 1: + # string vec length 1 are written as scalars + pass + else: + dim = list(reversed(d[2])) + # dim = d[2] + dim = [string_size_str] + [str(e) for e in dim] + dim = '(' + ','.join(dim) + ')' + else: + # this is a vector (1d array) column + count = string_size * d[2] + form = '%dA' % count + + # will have to do tests to see if this is the right order + dim = [string_size_str, str(d[2])] + dim = '(' + ','.join(dim) + ')' + + return name, form, dim diff --git a/fitsio/header.py b/fitsio/header.py new file mode 100644 index 0000000..cd9275f --- /dev/null +++ b/fitsio/header.py @@ -0,0 +1,781 @@ +""" +header classes for fitslib, part of the fitsio package. + +See the main docs at https://github.com/esheldon/fitsio + + Copyright (C) 2011 Erin Sheldon, BNL. erin dot sheldon at gmail dot com + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +""" + +from __future__ import with_statement, print_function +import warnings + +from . import _fitsio_wrap +from .util import isstring, FITSRuntimeWarning, IS_PY3 + +# for python3 compat +if IS_PY3: + xrange = range + +TYP_STRUC_KEY = 10 +TYP_CMPRS_KEY = 20 +TYP_SCAL_KEY = 30 +TYP_NULL_KEY = 40 +TYP_DIM_KEY = 50 +TYP_RANG_KEY = 60 +TYP_UNIT_KEY = 70 +TYP_DISP_KEY = 80 +TYP_HDUID_KEY = 90 +TYP_CKSUM_KEY = 100 +TYP_WCS_KEY = 110 +TYP_REFSYS_KEY = 120 +TYP_COMM_KEY = 130 +TYP_CONT_KEY = 140 +TYP_USER_KEY = 150 + + +class FITSHDR(object): + """ + A class representing a FITS header. + + parameters + ---------- + record_list: optional + A list of dicts, or dict, or another FITSHDR + - list of dictionaries containing 'name','value' and optionally + a 'comment' field; the order is preserved. + - a dictionary of keyword-value pairs; no comments are written + in this case, and the order is arbitrary. + - another FITSHDR object; the order is preserved. + + examples: + + hdr=FITSHDR() + + # set a simple value + hdr['blah'] = 35 + + # set from a dict to include a comment. + rec={'name':'fromdict', 'value':3, 'comment':'my comment'} + hdr.add_record(rec) + + # can do the same with a full FITSRecord + rec=FITSRecord( {'name':'temp', 'value':35, 'comment':'temp in C'} ) + hdr.add_record(rec) + + # in the above, the record is replaced if one with the same name + # exists, except for COMMENT and HISTORY, which can exist as + # duplicates + + # print the header + print(hdr) + + # print a single record + print(hdr['fromdict']) + + + # can also set from a card + hdr.add_record('test = 77') + # using a FITSRecord object (internally uses FITSCard) + card=FITSRecord('test = 77') + hdr.add_record(card) + + # can also construct with a record list + recs=[{'name':'test', 'value':35, 'comment':'a comment'}, + {'name':'blah', 'value':'some string'}] + hdr=FITSHDR(recs) + + # if you have no comments, you can construct with a simple dict + recs={'day':'saturday', + 'telescope':'blanco'} + hdr=FITSHDR(recs) + + """ + + def __init__(self, record_list=None): + self._record_list = [] + self._record_map = {} + self._index_map = {} + + if isinstance(record_list, FITSHDR): + for r in record_list.records(): + self.add_record(r) + elif isinstance(record_list, dict): + for k in record_list: + r = {'name': k, 'value': record_list[k]} + self.add_record(r) + elif isinstance(record_list, list): + for r in record_list: + self.add_record(r) + elif record_list is not None: + raise ValueError("expected a dict or list of dicts or FITSHDR") + + def add_record(self, record_in): + """ + Add a new record. Strip quotes from around strings. + + This will over-write if the key already exists, except + for COMMENT and HISTORY fields + + parameters + ----------- + record: + The record, either a dict or a header card string + or a FITSRecord or FITSCard + """ + if ( + isinstance(record_in, dict) + and 'name' in record_in + and 'value' in record_in + ): + record = {} + record.update(record_in) + else: + record = FITSRecord(record_in) + + # only append when this name already exists if it is + # a comment or history field, otherwise simply over-write + key = record['name'] + if key is not None: + key = key.upper() + + key_exists = key in self._record_map + + if not key_exists or key in ('COMMENT', 'HISTORY', 'CONTINUE', None): + # append new record + self._record_list.append(record) + index = len(self._record_list) - 1 + self._index_map[key] = index + else: + # over-write existing + index = self._index_map[key] + self._record_list[index] = record + + self._record_map[key] = record + + def _add_to_map(self, record): + key = record['name'].upper() + self._record_map[key] = record + + def get_comment(self, item): + """ + Get the comment for the requested entry + """ + key = item.upper() + if key not in self._record_map: + raise KeyError("unknown record: %s" % key) + + if 'comment' not in self._record_map[key]: + return None + else: + return self._record_map[key]['comment'] + + def records(self): + """ + Return the list of full records as a list of dictionaries. + """ + return self._record_list + + def keys(self): + """ + Return a copy of the current key list. + """ + return [e['name'] for e in self._record_list] + + def delete(self, name): + """ + Delete the specified entry if it exists. + """ + if isinstance(name, (list, tuple)): + for xx in name: + self.delete(xx) + else: + if name in self._record_map: + del self._record_map[name] + # Store current index value + cur_index = self._index_map[name] + # Delete in index map + del self._index_map[name] + self._record_list = [ + r for r in self._record_list if r['name'] != name + ] + + # Change index map for superior indexes, only + for k, v in self._index_map.items(): + if v > cur_index: + self._index_map[k] = v - 1 + + def clean(self, is_table=False): + """ + Remove reserved keywords from the header. + + These are keywords that the fits writer must write in order + to maintain consistency between header and data. + + keywords + -------- + is_table: bool, optional + Set True if this is a table, so extra keywords will be cleaned + """ + + rmnames = [ + 'SIMPLE', + 'EXTEND', + 'XTENSION', + 'BITPIX', + 'PCOUNT', + 'GCOUNT', + 'THEAP', + 'EXTNAME', + # 'BLANK', + 'ZQUANTIZ', + 'ZDITHER0', + 'ZIMAGE', + 'ZCMPTYPE', + 'ZSIMPLE', + 'ZTENSION', + 'ZPCOUNT', + 'ZGCOUNT', + 'ZBITPIX', + 'ZEXTEND', + # 'FZTILELN','FZALGOR', + 'CHECKSUM', + 'DATASUM', + ] + + if is_table: + # these are not allowed in tables + rmnames += [ + 'BUNIT', + 'BSCALE', + 'BZERO', + ] + + self.delete(rmnames) + + r = self._record_map.get('NAXIS', None) + if r is not None: + naxis = int(r['value']) + self.delete('NAXIS') + + rmnames = ['NAXIS%d' % i for i in xrange(1, naxis + 1)] + self.delete(rmnames) + + r = self._record_map.get('ZNAXIS', None) + self.delete('ZNAXIS') + if r is not None: + znaxis = int(r['value']) + + rmnames = ['ZNAXIS%d' % i for i in xrange(1, znaxis + 1)] + self.delete(rmnames) + rmnames = ['ZTILE%d' % i for i in xrange(1, znaxis + 1)] + self.delete(rmnames) + rmnames = ['ZNAME%d' % i for i in xrange(1, znaxis + 1)] + self.delete(rmnames) + rmnames = ['ZVAL%d' % i for i in xrange(1, znaxis + 1)] + self.delete(rmnames) + + r = self._record_map.get('TFIELDS', None) + if r is not None: + tfields = int(r['value']) + self.delete('TFIELDS') + + if tfields > 0: + nbase = [ + 'TFORM', + 'TTYPE', + 'TDIM', + 'TUNIT', + 'TSCAL', + 'TZERO', + 'TNULL', + 'TDISP', + 'TDMIN', + 'TDMAX', + 'TDESC', + 'TROTA', + 'TRPIX', + 'TRVAL', + 'TDELT', + 'TCUNI', + # 'FZALG' + ] + for i in xrange(1, tfields + 1): + names = ['%s%d' % (n, i) for n in nbase] + self.delete(names) + + def get(self, item, default_value=None): + """ + Get the requested header entry by keyword name + """ + + found, name = self._contains_and_name(item) + if found: + return self._record_map[name]['value'] + else: + return default_value + + def __len__(self): + return len(self._record_list) + + def __contains__(self, item): + found, _ = self._contains_and_name(item) + return found + + def _contains_and_name(self, item): + if isinstance(item, FITSRecord): + name = item['name'] + elif isinstance(item, dict): + name = item.get('name', None) + if name is None: + raise ValueError("dict record must have 'name' field") + else: + name = item + + found = False + if name is None: + if None in self._record_map: + found = True + else: + name = name.upper() + if name in self._record_map: + found = True + elif name[0:8] == 'HIERARCH': + if len(name) > 9: + name = name[9:] + if name in self._record_map: + found = True + + return found, name + + def __setitem__(self, item, value): + if isinstance(value, (dict, FITSRecord)): + if item.upper() != value['name'].upper(): + raise ValueError( + "when setting using a FITSRecord, the " + "name field must match" + ) + rec = value + else: + rec = {'name': item, 'value': value} + + try: + # the entry may already exist; if so, preserve the comment + comment = self.get_comment(item) + rec['comment'] = comment + except KeyError: + pass + + self.add_record(rec) + + def __getitem__(self, item): + if item not in self: + raise KeyError("unknown record: %s" % item) + + return self.get(item) + + def __iter__(self): + self._current = 0 + return self + + def next(self): + """ + for iteration over the header entries + """ + if self._current < len(self._record_list): + rec = self._record_list[self._current] + key = rec['name'] + self._current += 1 + return key + else: + raise StopIteration + + __next__ = next + + def _record2card(self, record): + """ + when we add new records they don't have a card, + this sort of fakes it up similar to what cfitsio + does, just for display purposes. e.g. + + DBL = 23.299843 + LNG = 3423432 + KEYSNC = 'hello ' + KEYSC = 'hello ' / a comment for string + KEYDC = 3.14159265358979 / a comment for pi + KEYLC = 323423432 / a comment for long + + basically, + - 8 chars, left aligned, for the keyword name + - a space + - 20 chars for value, left aligned for strings, right aligned for + numbers + - if there is a comment, one space followed by / then another space + then the comment out to 80 chars + + """ + name = record['name'] + value = record['value'] + comment = record.get('comment', '') + + v_isstring = isstring(value) + + if name is None: + card = ' %s' % comment + elif name == 'COMMENT': + card = 'COMMENT %s' % comment + elif name == 'CONTINUE': + card = 'CONTINUE %s' % value + elif name == 'HISTORY': + card = 'HISTORY %s' % value + else: + if len(name) > 8: + card = 'HIERARCH %s= ' % name + else: + card = '%-8s= ' % name[0:8] + + # these may be string representations of data, or actual strings + if v_isstring: + value = str(value) + if len(value) > 0: + if value[0] != "'": + # this is a string representing a string header field + # make it look like it will look in the header + value = "'" + value + "'" + vstr = '%-20s' % value + else: + vstr = "%20s" % value + else: + vstr = "''" + else: + if value is True: + value = 'T' + elif value is False: + value = 'F' + + # upper for things like 1.0E20 rather than 1.0e20 + vstr = ('%20s' % value).upper() + + card += vstr + + if 'comment' in record: + card += ' / %s' % record['comment'] + + if v_isstring and len(card) > 80: + card = card[0:79] + "'" + else: + card = card[0:80] + + return card + + def __repr__(self): + rep = [''] + for r in self._record_list: + card = self._record2card(r) + # if 'card_string' not in r: + # card = self._record2card(r) + # else: + # card = r['card_string'] + + rep.append(card) + return '\n'.join(rep) + + +class FITSRecord(dict): + """ + Class to represent a FITS header record + + parameters + ---------- + record: string or dict + If a string, it should represent a FITS header card + + If a dict it should have 'name' and 'value' fields. + Can have a 'comment' field. + + examples + -------- + + # from a dict. Can include a comment + rec=FITSRecord( {'name':'temp', 'value':35, 'comment':'temperature in C'} ) + + # from a card + card=FITSRecord('test = 77 / My comment') + + """ + + def __init__(self, record): + self.set_record(record) + + def set_record(self, record, **keys): + """ + check the record is valid and set keys in the dict + + parameters + ---------- + record: string + Dict representing a record or a string representing a FITS header + card + """ + + if keys: + import warnings + + warnings.warn( + "The keyword arguments '%s' are being ignored! This warning " + "will be an error in a future version of `fitsio`!" % keys, + DeprecationWarning, + stacklevel=2, + ) + + if isstring(record): + card = FITSCard(record) + self.update(card) + + self.verify() + + else: + if isinstance(record, FITSRecord): + self.update(record) + elif isinstance(record, dict): + if 'name' in record and 'value' in record: + self.update(record) + + elif 'card_string' in record: + self.set_record(record['card_string']) + + else: + raise ValueError( + 'record must have name,value fields ' + 'or a card_string field' + ) + else: + raise ValueError( + "record must be a string card or dictionary or FITSRecord" + ) + + def verify(self): + """ + make sure name,value exist + """ + if 'name' not in self: + raise ValueError("each record must have a 'name' field") + if 'value' not in self: + raise ValueError("each record must have a 'value' field") + + +_BLANK = ' ' + + +class FITSCard(FITSRecord): + """ + class to represent ordinary FITS cards. + + CONTINUE not supported + + examples + -------- + + # from a card + card=FITSRecord('test = 77 / My comment') + """ + + def __init__(self, card_string): + self.set_card(card_string) + + def set_card(self, card_string): + self['card_string'] = card_string + + self._check_hierarch() + + if self._is_hierarch: + self._set_as_key() + else: + self._check_equals() + + self._check_type() + self._check_len() + + front = card_string[0:7] + if not self.has_equals() or front in [ + 'COMMENT', + 'HISTORY', + 'CONTINU', + _BLANK, + ]: + if front == 'HISTORY': + self._set_as_history() + elif front == 'CONTINU': + self._set_as_continue() + elif front == _BLANK: + self._set_as_blank() + else: + # note anything without an = and not history and not blank + # key comment is treated as COMMENT; this is built into + # cfitsio as well + self._set_as_comment() + + if self.has_equals(): + mess = ( + "warning: It is not FITS-compliant for a %s header " + "card to include an = sign. There may be slight " + "inconsistencies if you write this back out to a " + "file." + ) + mess = mess % (card_string[:8]) + warnings.warn(mess, FITSRuntimeWarning) + else: + self._set_as_key() + + def has_equals(self): + """ + True if = is in position 8 + """ + return self._has_equals + + def _check_hierarch(self): + card_string = self['card_string'] + if card_string[0:8].upper() == 'HIERARCH': + self._is_hierarch = True + else: + self._is_hierarch = False + + def _check_equals(self): + """ + check for = in position 8, set attribute _has_equals + """ + card_string = self['card_string'] + if len(card_string) < 9: + self._has_equals = False + elif card_string[8] == '=': + self._has_equals = True + else: + self._has_equals = False + + def _set_as_key(self): + card_string = self['card_string'] + res = _fitsio_wrap.parse_card(card_string) + if len(res) == 5: + keyclass, name, value, dtype, comment = res + else: + keyclass, name, dtype, comment = res + value = None + + if keyclass == TYP_CONT_KEY: + raise ValueError( + "bad card '%s'. CONTINUE not supported" % card_string + ) + + self['class'] = keyclass + self['name'] = name + self['value_orig'] = value + self['value'] = self._convert_value(value) + self['dtype'] = dtype + self['comment'] = comment + + def _set_as_blank(self): + self['class'] = TYP_USER_KEY + self['name'] = None + self['value'] = None + self['comment'] = self['card_string'][8:] + + def _set_as_comment(self): + comment = self._extract_comm_or_hist_value() + + self['class'] = TYP_COMM_KEY + self['name'] = 'COMMENT' + self['value'] = comment + + def _set_as_history(self): + history = self._extract_comm_or_hist_value() + + self['class'] = TYP_COMM_KEY + self['name'] = 'HISTORY' + self['value'] = history + + def _set_as_continue(self): + value = self._extract_comm_or_hist_value() + + self['class'] = TYP_CONT_KEY + self['name'] = 'CONTINUE' + self['value'] = value + + def _convert_value(self, value_orig): + """ + things like 6 and 1.25 are converted with ast.literal_value + + Things like 'hello' are stripped of quotes + """ + import ast + + if value_orig is None: + return value_orig + + if value_orig.startswith("'") and value_orig.endswith("'"): + value = value_orig[1:-1] + else: + try: + avalue = ast.parse(value_orig).body[0].value + if isinstance(avalue, ast.BinOp): + # this is probably a string that happens to look like + # a binary operation, e.g. '25-3' + value = value_orig + else: + value = ast.literal_eval(value_orig) + except Exception: + value = self._convert_string(value_orig) + + if isinstance(value, int) and '_' in value_orig: + value = value_orig + + return value + + def _convert_string(self, s): + if s == 'T': + return True + elif s == 'F': + return False + else: + return s + + def _extract_comm_or_hist_value(self): + card_string = self['card_string'] + if self._has_equals: + if len(card_string) >= 9: + value = card_string[9:] + else: + value = '' + else: + if len(card_string) >= 8: + # value=card_string[7:] + value = card_string[8:] + else: + value = '' + return value + + def _check_type(self): + card_string = self['card_string'] + if not isstring(card_string): + raise TypeError( + "card must be a string, got type %s" % type(card_string) + ) + + def _check_len(self): + ln = len(self['card_string']) + if ln > 80: + mess = "len(card) is %d. cards must have length < 80" + raise ValueError(mess) diff --git a/fitsio/test_images/test_gzip_compressed_image.fits.fz b/fitsio/test_images/test_gzip_compressed_image.fits.fz new file mode 100644 index 0000000000000000000000000000000000000000..6dae22d4bbbf5c7d382d89931af279d6f135535a GIT binary patch literal 8640 zcmeHL&2G~`5RL=~9-%iz;!rq1ny5kusfVO-+m#Z>)GjK%w3~X9tmN3i-n30$0SWO4 z9C-{LgD2oQm|bt`xZU&z0TQq)S+i?xzRY~HGoD$W^t`SEumy$@KpjrfWSC}?97GB` zB=8{@G>K>y8TR9=HVHg(xB~AL;?XRlbBL&*Am&qMMk(LAtwZAD(x~4C;w7`mfMt*l z7s!0VlANbWUV<#moWsCzJ1g*lrHyx-DzH25wc9KMmI2FvWxz6E8L$jk2L65qWI5@R zzPknMZQ=&4cGp>7vZ@XPp3rO#f(~K^>|7MZ>C%?!^pDoh&HLBSsvd8wsGnthf5sy* zmgRV1F;8GH7tCCYqv;B-X~t_*>-#J{%K`BU;3{pGD{s4h;9?zb=ogxsRlHG_&ZaP> z8J#e}GP8=itMLFD75)^S8Rb$v2zH3m?f54BuHp?jiz8(*qbFlP^Gle^SrEMQ9OaMo z?S9YOcYNRJ9CcbjOC7^H#B{)7z!nY^9_oDK2zL7Wy#lY{wVyUOpF#CJtIkb!gj7f` zE6~?)N;9rbc+8R!`oHM%@Sj4`Ywh0DUzQ<(ou;51`?iyGGR;_?vk3Tvjw(H3SjL-1 zzM>ydzbH{}v=;T#0cSWb&tGn$f6Go4txYpN!g!9a>$UisP&cdwJOCMQ(V{<NX8vWbT`M~WH-@)Tu#T&*c6+9WiH06na_cNLZeu1wLKW3+xD=H&=+v>`cVf2Ue z?|t#!9JJhkglMnZKkATR&)Hv6unumDAtJ^slR8E-B`Qi*)C0WFkrtzV%lS{G1__Jz zLzvx_f5#XOw||W7eNL5MyRr=YP6leV+8RD`NV#}6^n6dtV=d3M{G{a= (3, 0, 0) and arr1.dtype.char == 'S': + _arr1 = arr1.astype('U') + else: + _arr1 = arr1 + + res = np.where(_arr1 != arr2) + for i, w in enumerate(res): + assert w.size == 0, "testing array '%s' dim %d are equal" % (name, i) + + +def compare_array_tol(arr1, arr2, tol, name): + assert arr1.shape == arr2.shape, ( + "testing arrays '%s' shapes are equal: " + "input %s, read: %s" % (name, arr1.shape, arr2.shape) + ) + + adiff = np.abs((arr1 - arr2) / arr1) + maxdiff = adiff.max() + res = np.where(adiff > tol) + for i, w in enumerate(res): + assert w.size == 0, ( + "testing array '%s' dim %d are " + "equal within tolerance %e, found " + "max diff %e" % (name, i, tol, maxdiff) + ) + + +def compare_array_abstol(arr1, arr2, tol, name): + assert arr1.shape == arr2.shape, ( + "testing arrays '%s' shapes are equal: " + "input %s, read: %s" % (name, arr1.shape, arr2.shape) + ) + + adiff = np.abs(arr1 - arr2) + maxdiff = adiff.max() + res = np.where(adiff > tol) + for i, w in enumerate(res): + assert w.size == 0, ( + "testing array '%s' dim %d are " + "equal within tolerance %e, found " + "max diff %e" % (name, i, tol, maxdiff) + ) + + +def compare_object_array(arr1, arr2, name, rows=None): + """ + The first must be object + """ + if rows is None: + rows = np.arange(arr1.size) + + for i, row in enumerate(rows): + if ( + sys.version_info >= (3, 0, 0) and isinstance(arr2[i], bytes) + ) or isinstance(arr2[i], str): + if sys.version_info >= (3, 0, 0) and isinstance(arr1[row], bytes): + _arr1row = arr1[row].decode('ascii') + else: + _arr1row = arr1[row] + + assert _arr1row == arr2[i], "%s str el %d equal" % (name, i) + else: + delement = arr2[i] + orig = arr1[row] + s = len(orig) + compare_array( + orig, delement[0:s], "%s num el %d equal" % (name, i) + ) + + +def compare_rec(rec1, rec2, name): + for f in rec1.dtype.names: + rec1_shape = cast_shape(rec1[f].shape) + rec2_shape = cast_shape(rec2[f].shape) + + assert rec1_shape == rec2_shape, ( + "testing '%s' field '%s' shapes are equal: " + "input %s, read: %s" % (name, f, rec1_shape, rec2_shape) + ) + + if sys.version_info >= (3, 0, 0) and rec1[f].dtype.char == 'S': + # for python 3, we get back unicode always + _rec1f = rec1[f].astype('U') + else: + _rec1f = rec1[f] + + assert np.all(_rec1f == rec2[f]) + # res = np.where(_rec1f != rec2[f]) + # for w in res: + # assert w.size == 0, "testing column %s" % f + + +def compare_rec_subrows(rec1, rec2, rows, name): + for f in rec1.dtype.names: + rec1_shape = cast_shape(rec1[f][rows].shape) + rec2_shape = cast_shape(rec2[f].shape) + + assert rec1_shape == rec2_shape, ( + "testing '%s' field '%s' shapes are equal: " + "input %s, read: %s" % (name, f, rec1_shape, rec2_shape) + ) + + if sys.version_info >= (3, 0, 0) and rec1[f].dtype.char == 'S': + # for python 3, we get back unicode always + _rec1frows = rec1[f][rows].astype('U') + else: + _rec1frows = rec1[f][rows] + + res = np.where(_rec1frows != rec2[f]) + for w in res: + assert w.size == 0, "testing column %s" % f + + +def compare_rec_with_var(rec1, rec2, name, rows=None): + """ + + First one *must* be the one with object arrays + + Second can have fixed length + + both should be same number of rows + + """ + + if rows is None: + rows = np.arange(rec2.size) + assert rec1.size == rec2.size, ( + "testing '%s' same number of rows" % name + ) + + # rec2 may have fewer fields + for f in rec2.dtype.names: + # f1 will have the objects + if util.is_object(rec1[f]): + compare_object_array( + rec1[f], + rec2[f], + "testing '%s' field '%s'" % (name, f), + rows=rows, + ) + else: + compare_array( + rec1[f][rows], + rec2[f], + "testing '%s' num field '%s' equal" % (name, f), + ) + + +def compare_names(read_names, true_names, lower=False, upper=False): + for nread, ntrue in zip(read_names, true_names): + if lower: + tname = ntrue.lower() + mess = "lower: '%s' vs '%s'" % (nread, tname) + else: + tname = ntrue.upper() + mess = "upper: '%s' vs '%s'" % (nread, tname) + + assert nread == tname, mess diff --git a/fitsio/tests/makedata.py b/fitsio/tests/makedata.py new file mode 100644 index 0000000..83e0394 --- /dev/null +++ b/fitsio/tests/makedata.py @@ -0,0 +1,439 @@ +import sys +import numpy as np +from functools import lru_cache + +from ..util import cfitsio_version + +CFITSIO_VERSION = cfitsio_version(asfloat=True) + +lorem_ipsum = ( + 'Lorem ipsum dolor sit amet, consectetur adipiscing ' + 'elit, sed do eiusmod tempor incididunt ut labore ' + 'et dolore magna aliqua' +) + + +@lru_cache(maxsize=1) +def make_data(): + nvec = 2 + ashape = (21, 21) + Sdtype = 'S6' + Udtype = 'U6' + + # all currently available types, scalar, 1-d and 2-d array columns + dtype = [ + ('u1scalar', 'u1'), + ('i1scalar', 'i1'), + ('b1scalar', '?'), + ('u2scalar', 'u2'), + ('i2scalar', 'i2'), + ('u4scalar', 'u4'), + ('i4scalar', 'f8'), + ('c8scalar', 'c8'), # complex, two 32-bit + ('c16scalar', 'c16'), # complex, two 32-bit + ('u1vec', 'u1', nvec), + ('i1vec', 'i1', nvec), + ('b1vec', '?', nvec), + ('u2vec', 'u2', nvec), + ('i2vec', 'i2', nvec), + ('u4vec', 'u4', nvec), + ('i4vec', 'i4', nvec), + ('i8vec', 'i8', nvec), + ('f4vec', 'f4', nvec), + ('f8vec', 'f8', nvec), + ('c8vec', 'c8', nvec), + ('c16vec', 'c16', nvec), + ('u1arr', 'u1', ashape), + ('i1arr', 'i1', ashape), + ('b1arr', '?', ashape), + ('u2arr', 'u2', ashape), + ('i2arr', 'i2', ashape), + ('u4arr', 'u4', ashape), + ('i4arr', 'i4', ashape), + ('i8arr', 'i8', ashape), + ('f4arr', 'f4', ashape), + ('f8arr', 'f8', ashape), + ('c8arr', 'c8', ashape), + ('c16arr', 'c16', ashape), + # special case of (1,) + ('f8arr_dim1', 'f8', (1,)), + ('Sscalar', Sdtype), + ('Svec', Sdtype, nvec), + ('Sarr', Sdtype, ashape), + ] + + if CFITSIO_VERSION > 4: + dtype += [ + ('u8scalar', 'u8'), + ('u8vec', 'u8', nvec), + ('u8arr', 'u8', ashape), + ] + + # cfitsio 3 or earlier does not + # handle non-space padded strings + # properly + if CFITSIO_VERSION >= 4: + dtype += [ + ('Sscalar_nopad', Sdtype), + ('Svec_nopad', Sdtype, nvec), + ('Sarr_nopad', Sdtype, ashape), + ] + + if sys.version_info > (3, 0, 0): + dtype += [ + ('Uscalar', Udtype), + ('Uvec', Udtype, nvec), + ('Uarr', Udtype, ashape), + ] + + # cfitsio 3 or earlier does not + # handle non-space padded strings + # properly + if CFITSIO_VERSION >= 4: + dtype += [ + ('Uscalar_nopad', Udtype), + ('Uvec_nopad', Udtype, nvec), + ('Uarr_nopad', Udtype, ashape), + ] + + dtype2 = [ + ('index', 'i4'), + ('x', 'f8'), + ('y', 'f8'), + ] + + nrows = 4 + data = np.zeros(nrows, dtype=dtype) + + dtypes = [ + 'u1', + 'i1', + 'u2', + 'i2', + 'u4', + 'i4', + 'i8', + 'f4', + 'f8', + 'c8', + 'c16', + ] + if CFITSIO_VERSION > 4: + dtypes += ["u8"] + + for t in dtypes: + if t in ['c8', 'c16']: + data[t + 'scalar'] = [ + complex(i + 1, (i + 1) * 2) for i in range(nrows) + ] + vname = t + 'vec' + for row in range(nrows): + for i in range(nvec): + index = (row + 1) * (i + 1) + data[vname][row, i] = complex(index, index * 2) + aname = t + 'arr' + for row in range(nrows): + for i in range(ashape[0]): + for j in range(ashape[1]): + index = (row + 1) * (i + 1) * (j + 1) + data[aname][row, i, j] = complex(index, index * 2) + + else: + data[t + 'scalar'] = 1 + np.arange(nrows, dtype=t) + data[t + 'vec'] = 1 + np.arange( + nrows * nvec, + dtype=t, + ).reshape(nrows, nvec) + arr = 1 + np.arange(nrows * ashape[0] * ashape[1], dtype=t) + data[t + 'arr'] = arr.reshape(nrows, ashape[0], ashape[1]) + + for t in ['b1']: + data[t + 'scalar'] = (np.arange(nrows) % 2 == 0).astype('?') + data[t + 'vec'] = ( + (np.arange(nrows * nvec) % 2 == 0).astype('?').reshape(nrows, nvec) + ) + + arr = (np.arange(nrows * ashape[0] * ashape[1]) % 2 == 0).astype('?') + data[t + 'arr'] = arr.reshape(nrows, ashape[0], ashape[1]) + + # strings get padded when written to the fits file. And the way I do + # the read, I read all bytes (ala mrdfits) so the spaces are preserved. + # + # so we need to pad out the strings with blanks so we can compare + + data['Sscalar'] = ['%-6s' % s for s in ['hello', 'world', 'good', 'bye']] + data['Svec'][:, 0] = '%-6s' % 'hello' + data['Svec'][:, 1] = '%-6s' % 'world' + + s = 1 + np.arange(nrows * ashape[0] * ashape[1]) + s = ['%-6s' % el for el in s] + data['Sarr'] = np.array(s).reshape(nrows, ashape[0], ashape[1]) + + # cfitsio 3 or earlier does not + # handle non-space padded strings + # properly + if CFITSIO_VERSION >= 4: + data['Sscalar_nopad'] = ['hello', 'world', 'good', 'bye'] + data['Svec_nopad'][:, 0] = 'hello' + data['Svec_nopad'][:, 1] = 'world' + + s = 1 + np.arange(nrows * ashape[0] * ashape[1]) + s = ['%s' % el for el in s] + data['Sarr_nopad'] = np.array(s).reshape(nrows, ashape[0], ashape[1]) + + if sys.version_info >= (3, 0, 0): + data['Uscalar'] = [ + '%-6s' % s for s in ['hello', 'world', 'good', 'bye'] + ] + data['Uvec'][:, 0] = '%-6s' % 'hello' + data['Uvec'][:, 1] = '%-6s' % 'world' + + s = 1 + np.arange(nrows * ashape[0] * ashape[1]) + s = ['%-6s' % el for el in s] + data['Uarr'] = np.array(s).reshape(nrows, ashape[0], ashape[1]) + + # cfitsio 3 or earlier does not + # handle non-space padded strings + # properly + if CFITSIO_VERSION >= 4: + data['Uscalar_nopad'] = ['hello', 'world', 'good', 'bye'] + data['Uvec_nopad'][:, 0] = 'hello' + data['Uvec_nopad'][:, 1] = 'world' + + s = 1 + np.arange(nrows * ashape[0] * ashape[1]) + s = ['%s' % el for el in s] + data['Uarr_nopad'] = np.array(s).reshape( + nrows, + ashape[0], + ashape[1], + ) + + # use a dict list so we can have comments + # for long key we used the largest possible + + keys = [ + {'name': 'test1', 'value': 35}, + {'name': 'empty', 'value': ''}, + {'name': 'long_keyword_name', 'value': 'stuff'}, + { + 'name': 'test2', + 'value': 'stuff', + 'comment': 'this is a string keyword', + }, + { + 'name': 'dbl', + 'value': 23.299843, + 'comment': "this is a double keyword", + }, + { + 'name': 'edbl', + 'value': 1.384123233e43, + 'comment': "double keyword with exponent", + }, + { + 'name': 'lng', + 'value': 2**63 - 1, + 'comment': 'this is a long keyword', + }, + {'name': 'lngstr', 'value': lorem_ipsum, 'comment': 'long string'}, + ] + + # a second extension using the convenience function + nrows2 = 10 + data2 = np.zeros(nrows2, dtype=dtype2) + data2['index'] = np.arange(nrows2, dtype='i4') + data2['x'] = np.arange(nrows2, dtype='f8') + data2['y'] = np.arange(nrows2, dtype='f8') + + # + # ascii table + # + + nvec = 2 + ashape = (2, 3) + Sdtype = 'S6' + Udtype = 'U6' + + # we support writing i2, i4, i8, f4 f8, but when reading cfitsio always + # reports their types as i4 and f8, so can't really use i8 and we are + # forced to read all floats as f8 precision + + adtype = [ + ('i2scalar', 'i2'), + ('i4scalar', 'i4'), + # ('i8scalar', 'i8'), + ('f4scalar', 'f4'), + ('f8scalar', 'f8'), + ('Sscalar', Sdtype), + ] + if sys.version_info >= (3, 0, 0): + adtype += [('Uscalar', Udtype)] + + nrows = 4 + try: + tdt = np.dtype(adtype, align=True) + except TypeError: # older numpy may not understand `align` argument + tdt = np.dtype(adtype) + adata = np.zeros(nrows, dtype=tdt) + + adata['i2scalar'][:] = -32222 + np.arange(nrows, dtype='i2') + adata['i4scalar'][:] = -1353423423 + np.arange(nrows, dtype='i4') + adata['f4scalar'][:] = ( + -2.55555555555555555555555e35 + np.arange(nrows, dtype='f4') * 1.0e35 + ) + adata['f8scalar'][:] = ( + -2.55555555555555555555555e110 + np.arange(nrows, dtype='f8') * 1.0e110 + ) + adata['Sscalar'] = ['hello', 'world', 'good', 'bye'] + + if sys.version_info >= (3, 0, 0): + adata['Uscalar'] = ['hello', 'world', 'good', 'bye'] + + ascii_data = adata + + # + # for variable length columns + # + + # all currently available types, scalar, 1-d and 2-d array columns + dtype = [ + ('u1scalar', 'u1'), + ('u1obj', 'O'), + ('i1scalar', 'i1'), + ('i1obj', 'O'), + ('u2scalar', 'u2'), + ('u2obj', 'O'), + ('i2scalar', 'i2'), + ('i2obj', 'O'), + ('u4scalar', 'u4'), + ('u4obj', 'O'), + ('i4scalar', 'f8'), + ('f8obj', 'O'), + ('u1vec', 'u1', nvec), + ('i1vec', 'i1', nvec), + ('u2vec', 'u2', nvec), + ('i2vec', 'i2', nvec), + ('u4vec', 'u4', nvec), + ('i4vec', 'i4', nvec), + ('i8vec', 'i8', nvec), + ('f4vec', 'f4', nvec), + ('f8vec', 'f8', nvec), + ('u1arr', 'u1', ashape), + ('i1arr', 'i1', ashape), + ('u2arr', 'u2', ashape), + ('i2arr', 'i2', ashape), + ('u4arr', 'u4', ashape), + ('i4arr', 'i4', ashape), + ('i8arr', 'i8', ashape), + ('f4arr', 'f4', ashape), + ('f8arr', 'f8', ashape), + # special case of (1,) + ('f8arr_dim1', 'f8', (1,)), + ('Sscalar', Sdtype), + ('Sobj', 'O'), + ('Svec', Sdtype, nvec), + ('Sarr', Sdtype, ashape), + ] + if CFITSIO_VERSION > 4: + dtype += [ + ('u8vec', 'u8', nvec), + ('u8arr', 'u8', ashape), + ('u8scalar', 'i8'), + ('u8obj', 'O'), + ] + + if sys.version_info > (3, 0, 0): + dtype += [ + ('Uscalar', Udtype), + ('Uvec', Udtype, nvec), + ('Uarr', Udtype, ashape), + ] + + nrows = 4 + vardata = np.zeros(nrows, dtype=dtype) + + _dtypes = ['u1', 'i1', 'u2', 'i2', 'u4', 'i4', 'i8', 'f4', 'f8'] + if CFITSIO_VERSION > 4: + _dtypes += ["u8"] + for t in _dtypes: + vardata[t + 'scalar'] = 1 + np.arange(nrows, dtype=t) + vardata[t + 'vec'] = 1 + np.arange(nrows * nvec, dtype=t).reshape( + nrows, + nvec, + ) + arr = 1 + np.arange(nrows * ashape[0] * ashape[1], dtype=t) + vardata[t + 'arr'] = arr.reshape(nrows, ashape[0], ashape[1]) + + for i in range(nrows): + vardata[t + 'obj'][i] = vardata[t + 'vec'][i] + + # strings get padded when written to the fits file. And the way I do + # the read, I real all bytes (ala mrdfits) so the spaces are preserved. + # + # so for comparisons, we need to pad out the strings with blanks so we + # can compare + + vardata['Sscalar'] = [ + '%-6s' % s for s in ['hello', 'world', 'good', 'bye'] + ] + vardata['Svec'][:, 0] = '%-6s' % 'hello' + vardata['Svec'][:, 1] = '%-6s' % 'world' + + s = 1 + np.arange(nrows * ashape[0] * ashape[1]) + s = ['%-6s' % el for el in s] + vardata['Sarr'] = np.array(s).reshape(nrows, ashape[0], ashape[1]) + + if sys.version_info >= (3, 0, 0): + vardata['Uscalar'] = [ + '%-6s' % s for s in ['hello', 'world', 'good', 'bye'] + ] + vardata['Uvec'][:, 0] = '%-6s' % 'hello' + vardata['Uvec'][:, 1] = '%-6s' % 'world' + + s = 1 + np.arange(nrows * ashape[0] * ashape[1]) + s = ['%-6s' % el for el in s] + vardata['Uarr'] = np.array(s).reshape(nrows, ashape[0], ashape[1]) + + for i in range(nrows): + vardata['Sobj'][i] = vardata['Sscalar'][i].rstrip() + + # + # for bitcol columns + # + nvec = 2 + ashape = (21, 21) + + dtype = [('b1vec', '?', nvec), ('b1arr', '?', ashape)] + + nrows = 4 + bdata = np.zeros(nrows, dtype=dtype) + + for t in ['b1']: + bdata[t + 'vec'] = ( + (np.arange(nrows * nvec) % 2 == 0) + .astype('?') + .reshape( + nrows, + nvec, + ) + ) + arr = (np.arange(nrows * ashape[0] * ashape[1]) % 2 == 0).astype('?') + bdata[t + 'arr'] = arr.reshape(nrows, ashape[0], ashape[1]) + + return { + 'data': data, + 'keys': keys, + 'data2': data2, + 'ascii_data': ascii_data, + 'vardata': vardata, + 'bdata': bdata, + } diff --git a/fitsio/tests/test_empty_slice.py b/fitsio/tests/test_empty_slice.py new file mode 100644 index 0000000..3c1a4b7 --- /dev/null +++ b/fitsio/tests/test_empty_slice.py @@ -0,0 +1,20 @@ +import tempfile +import os +import numpy as np +from ..fitslib import write, FITS + + +def test_empty_image_slice(): + shape = (10, 10) + data = np.arange(shape[0] * shape[1]).reshape(shape) + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + write(fname, data, clobber=True) + + with FITS(fname) as fits: + assert fits[0][0:0, 0:0].size == 0 + + assert fits[0][0:8, 0:0].size == 0 + + assert fits[0][0:0, 0:8].size == 0 diff --git a/fitsio/tests/test_header.py b/fitsio/tests/test_header.py new file mode 100644 index 0000000..8fd668b --- /dev/null +++ b/fitsio/tests/test_header.py @@ -0,0 +1,576 @@ +import os +import tempfile +import warnings +import numpy as np + +import pytest + +from .makedata import make_data, lorem_ipsum +from .checks import check_header, compare_headerlist_header +from ..fitslib import FITS, read_header, write +from ..header import FITSHDR +from ..hdu.base import INVALID_HDR_CHARS +from ..util import cfitsio_version + +CFITSIO_VERSION = cfitsio_version(asfloat=True) + + +def test_free_form_string(): + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + with open(fname, 'w') as f: + s = ( + "SIMPLE = T / Standard FITS " # noqa + + "BITPIX = 16 / number of bits per data pixel " # noqa + + "NAXIS = 0 / number of data axes " # noqa + + "EXTEND = T / File contains extensions " # noqa + + "PHOTREF = 'previous MegaCam' / Source: cum.photcat " # noqa + + "EXTRA = 7 / need another line following PHOTREF " # noqa + + "END " # noqa + ) + f.write(s + ' ' * (2880 - len(s))) + hdr = read_header(fname) + assert hdr['PHOTREF'] == 'previous MegaCam' + + +def test_add_delete_and_update_records(): + # Build a FITSHDR from a few records (no need to write on disk) + # Record names have to be in upper case to match with FITSHDR.add_record + recs = [ + {'name': "First_record".upper(), 'value': 1, 'comment': "number 1"}, + {'name': "Second_record".upper(), 'value': "2"}, + {'name': "Third_record".upper(), 'value': "3"}, + {'name': "Last_record".upper(), 'value': 4, 'comment': "number 4"}, + ] + hdr = FITSHDR(recs) + + # Add a new record + hdr.add_record({'name': 'New_record'.upper(), 'value': 5}) + + # Delete number 2 and 4 + hdr.delete('Second_record'.upper()) + hdr.delete('Last_record'.upper()) + + # Update records : first and new one + hdr['First_record'] = 11 + hdr['New_record'] = 3 + + # Do some checks : len and get value/comment + assert len(hdr) == 3 + assert hdr['First_record'] == 11 + assert hdr['New_record'] == 3 + assert hdr['Third_record'] == '3' + assert hdr.get_comment('First_record') == 'number 1' + assert not hdr.get_comment('New_record') + + +def testHeaderCommentPreserved(): + """ + Test that the comment is preserved after resetting the value + """ + + l1 = 'KEY1 = 77 / My comment1' + l2 = 'KEY2 = 88 / My comment2' + hdr = FITSHDR() + hdr.add_record(l1) + hdr.add_record(l2) + + hdr['key1'] = 99 + assert hdr.get_comment('key1') == 'My comment1', 'comment not preserved' + + +def test_header_write_read(): + """ + Test a basic header write and read + + Note the other read/write tests also are checking header writing with + a list of dicts + """ + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + data = np.zeros(10) + header = { + 'x': 35, + 'y': 88.215, + 'eval': 1.384123233e43, + 'empty': '', + 'funky': '35-8', # test old bug when strings look + # like expressions + 'name': 'J. Smith', + 'what': '89113e6', # test bug where converted to float + 'und': None, + 'binop': '25-3', # test string with binary operation in it + 'unders': '1_000_000', # test string with underscore + 'longs': lorem_ipsum, + } + if CFITSIO_VERSION > 4.02: + # force hierarch + continue + header["long_keyword_name"] = lorem_ipsum + + fits.write_image(data, header=header) + + rh = fits[0].read_header() + check_header(header, rh) + + with FITS(fname) as fits: + rh = fits[0].read_header() + check_header(header, rh) + + +def test_header_delete(): + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + data = np.zeros(10) + header1 = {'SCARD': 'one', 'ICARD': 1, 'FCARD': 1.0, 'LCARD': True} + fits.write_image(data, header=header1) + rh = fits[0].read_header() + check_header(header1, rh) + + fits[0].delete_key("SCARD") + del header1["SCARD"] + rh = fits[0].read_header() + check_header(header1, rh) + + fits[0].delete_keys(["ICARD", "FCARD"]) + del header1["ICARD"] + del header1["FCARD"] + rh = fits[0].read_header() + check_header(header1, rh) + + with FITS(fname) as fits: + rh = fits[0].read_header() + check_header(header1, rh) + + +def test_header_update(): + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + data = np.zeros(10) + header1 = {'SCARD': 'one', 'ICARD': 1, 'FCARD': 1.0, 'LCARD': True} + header2 = { + 'SCARD': 'two', + 'ICARD': 2, + 'FCARD': 2.0, + 'LCARD': False, + 'SNEW': 'two', + 'INEW': 2, + 'FNEW': 2.0, + 'LNEW': False, + } + fits.write_image(data, header=header1) + rh = fits[0].read_header() + check_header(header1, rh) + + # Update header + fits[0].write_keys(header2) + + with FITS(fname) as fits: + rh = fits[0].read_header() + check_header(header2, rh) + + +def test_read_header_case(): + """ + Test read_header with and without case sensitivity + + The reason we need a special test for this is because + the read_header code is optimized for speed and has + a different code path + """ + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + data = np.zeros(10) + adata = make_data() + fits.write_image(data, header=adata['keys'], extname='First') + fits.write_image(data, header=adata['keys'], extname='second') + + cases = [ + ('First', True), + ('FIRST', False), + ('second', True), + ('seConD', False), + ] + for ext, ci in cases: + h = read_header(fname, ext=ext, case_sensitive=ci) + compare_headerlist_header(adata['keys'], h) + + +def test_blank_key_comments(): + """ + test a few different comments + """ + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + records = [ + # empty should return empty + {'name': None, 'value': '', 'comment': ''}, + # this will also return empty + {'name': None, 'value': '', 'comment': ' '}, + # this will return exactly + {'name': None, 'value': '', 'comment': ' h'}, + # this will return exactly + {'name': None, 'value': '', 'comment': '--- test comment ---'}, + ] + header = FITSHDR(records) + + fits.write(None, header=header) + + rh = fits[0].read_header() + + rrecords = rh.records() + + for i, ri in ((0, 6), (1, 7), (2, 8)): + rec = records[i] + rrec = rrecords[ri] + + assert rec['name'] is None, 'checking name is None' + + comment = rec['comment'] + rcomment = rrec['comment'] + if '' == comment.strip(): + comment = '' + + assert comment == rcomment, "check empty key comment" + + +def test_blank_key_comments_from_cards(): + """ + test a few different comments + """ + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + records = [ + ' ', # noqa + ' --- testing comment --- ', # noqa + ' --- testing comment --- ', # noqa + "COMMENT testing ", # noqa + ] + header = FITSHDR(records) + + fits.write(None, header=header) + + rh = fits[0].read_header() + + rrecords = rh.records() + + assert rrecords[6]['name'] is None, 'checking name is None' + assert rrecords[6]['comment'] == '', 'check empty key comment' + assert rrecords[7]['name'] is None, 'checking name is None' + assert rrecords[7]['comment'] == ' --- testing comment ---', ( + "check empty key comment" + ) + assert rrecords[8]['name'] is None, 'checking name is None' + assert rrecords[8]['comment'] == '--- testing comment ---', ( + "check empty key comment" + ) + assert rrecords[9]['name'] == 'COMMENT', 'checking name is COMMENT' + assert rrecords[9]['comment'] == 'testing', "check comment" + + +def test_header_from_cards(): + """ + test generating a header from cards, writing it out and getting + back what we put in + """ + hdr_from_cards = FITSHDR( + [ + "IVAL = 35 / integer value ", # noqa + "SHORTS = 'hello world' ", # noqa + "UND = ", # noqa + "LONGS = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiu&'", # noqa + "CONTINUE 'smod tempor incididunt ut labore et dolore magna aliqua' ", # noqa + "DBL = 1.25 ", # noqa + ] + ) + header = [ + {'name': 'ival', 'value': 35, 'comment': 'integer value'}, + {'name': 'shorts', 'value': 'hello world'}, + {'name': 'und', 'value': None}, + {'name': 'longs', 'value': lorem_ipsum}, + {'name': 'dbl', 'value': 1.25}, + ] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + data = np.zeros(10) + fits.write_image(data, header=hdr_from_cards) + + rh = fits[0].read_header() + compare_headerlist_header(header, rh) + + with FITS(fname) as fits: + rh = fits[0].read_header() + compare_headerlist_header(header, rh) + + +def test_bad_header_write_raises(): + """ + Test that an invalid header raises. + """ + + for c in INVALID_HDR_CHARS: + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + try: + hdr = {'bla%sg' % c: 3} + data = np.zeros(10) + + write(fname, data, header=hdr, clobber=True) + except Exception as e: + assert "header key 'BLA%sG' has" % c in str(e) + + +def test_header_template(): + """ + test adding bunch of cards from a split template + """ + + header_template = """SIMPLE = T / +BITPIX = 8 / bits per data value +NAXIS = 0 / number of axes +EXTEND = T / Extensions are permitted +ORIGIN = 'LSST DM Header Service'/ FITS file originator + + ---- Date, night and basic image information ---- +DATE = / Creation Date and Time of File +DATE-OBS= / Date of the observation (image acquisition) +DATE-BEG= / Time at the start of integration +DATE-END= / end date of the observation +MJD = / Modified Julian Date that the file was written +MJD-OBS = / Modified Julian Date of observation +MJD-BEG = / Modified Julian Date derived from DATE-BEG +MJD-END = / Modified Julian Date derived from DATE-END +OBSID = / ImageName from Camera StartIntergration +GROUPID = / imageSequenceName from StartIntergration +OBSTYPE = / BIAS, DARK, FLAT, OBJECT +BUNIT = 'adu ' / Brightness units for pixel array + + ---- Telescope info, location, observer ---- +TELESCOP= 'LSST AuxTelescope' / Telescope name +INSTRUME= 'LATISS' / Instrument used to obtain these data +OBSERVER= 'LSST' / Observer name(s) +OBS-LONG= -70.749417 / [deg] Observatory east longitude +OBS-LAT = -30.244639 / [deg] Observatory latitude +OBS-ELEV= 2663.0 / [m] Observatory elevation +OBSGEO-X= 1818938.94 / [m] X-axis Geocentric coordinate +OBSGEO-Y= -5208470.95 / [m] Y-axis Geocentric coordinate +OBSGEO-Z= -3195172.08 / [m] Z-axis Geocentric coordinate + + ---- Pointing info, etc. ---- + +DECTEL = / Telescope DEC of observation +ROTPATEL= / Telescope Rotation +ROTCOORD= 'sky' / Telescope Rotation Coordinates +RA = / RA of Target +DEC = / DEC of Target +ROTPA = / Rotation angle relative to the sky (deg) +HASTART = / [HH:MM:SS] Telescope hour angle at start +ELSTART = / [deg] Telescope zenith distance at start +AZSTART = / [deg] Telescope azimuth angle at start +AMSTART = / Airmass at start +HAEND = / [HH:MM:SS] Telescope hour angle at end +ELEND = / [deg] Telescope zenith distance at end +AZEND = / [deg] Telescope azimuth angle at end +AMEND = / Airmass at end + + ---- Image-identifying used to build OBS-ID ---- +TELCODE = 'AT' / The code for the telecope +CONTRLLR= / The controller (e.g. O for OCS, C for CCS) +DAYOBS = / The observation day as defined by image name +SEQNUM = / The sequence number from the image name +GROUPID = / + + ---- Information from Camera +CCD_MANU= 'ITL' / CCD Manufacturer +CCD_TYPE= '3800C' / CCD Model Number +CCD_SERN= '20304' / Manufacturers? CCD Serial Number +LSST_NUM= 'ITL-3800C-098' / LSST Assigned CCD Number +SEQCKSUM= / Checksum of Sequencer +SEQNAME = / SequenceName from Camera StartIntergration +REBNAME = / Name of the REB +CONTNUM = / CCD Controller (WREB) Serial Number +IMAGETAG= / DAQ Image id +TEMP_SET= / Temperature set point (deg C) +CCDTEMP = / Measured temperature (deg C) + + ---- Geometry from Camera ---- +DETSIZE = / Size of sensor +OVERH = / Over-scan pixels +OVERV = / Vert-overscan pix +PREH = / Pre-scan pixels + + ---- Filter/grating information ---- +FILTER = / Name of the filter +FILTPOS = / Filter position +GRATING = / Name of the second disperser +GRATPOS = / disperser position +LINSPOS = / Linear Stage + + ---- Exposure-related information ---- +EXPTIME = / Exposure time in seconds +SHUTTIME= / Shutter exposure time in seconds +DARKTIME= / Dark time in seconds + + ---- Header information ---- +FILENAME= / Original file name +HEADVER = / Version of header + + ---- Checksums ---- +CHECKSUM= / checksum for the current HDU +DATASUM = / checksum of the data records\n""" + + lines = header_template.splitlines() + hdr = FITSHDR() + for line in lines: + hdr.add_record(line) + + +def test_corrupt_continue(): + """ + test with corrupt continue, just make sure it doesn't crash + """ + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + with warnings.catch_warnings(record=True) as _: + hdr_from_cards = FITSHDR( + [ + "IVAL = 35 / integer value ", # noqa + "SHORTS = 'hello world' ", # noqa + "CONTINUE= ' ' / '&' / Current observing orogram ", # noqa + "UND = ", # noqa + "DBL = 1.25 ", # noqa + ] + ) + + with FITS(fname, 'rw') as fits: + fits.write(None, header=hdr_from_cards) + + read_header(fname) + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + with warnings.catch_warnings(record=True) as _: + hdr_from_cards = FITSHDR( + [ + "IVAL = 35 / integer value ", # noqa + "SHORTS = 'hello world' ", # noqa + "PROGRAM = 'Setting the Scale: Determining the Absolute Mass Normalization and &'", # noqa + "CONTINUE 'Scaling Relations for Clusters at z~0.1&' ", # noqa + "CONTINUE '&' / Current observing orogram ", # noqa + "UND = ", # noqa + "DBL = 1.25 ", # noqa + ] + ) + + with FITS(fname, 'rw') as fits: + fits.write(None, header=hdr_from_cards) + + read_header(fname) + + +def record_exists(header_records, key, value): + for rec in header_records: + if rec['name'] == key and rec['value'] == value: + return True + + return False + + +def test_read_comment_history(): + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + data = np.arange(100).reshape(10, 10) + fits.create_image_hdu(data) + hdu = fits[-1] + hdu.write_comment('A COMMENT 1') + hdu.write_comment('A COMMENT 2') + hdu.write_history('SOME HISTORY 1') + hdu.write_history('SOME HISTORY 2') + fits.close() + + with FITS(fname, 'r') as fits: + hdu = fits[-1] + header = hdu.read_header() + records = header.records() + assert record_exists(records, 'COMMENT', 'A COMMENT 1') + assert record_exists(records, 'COMMENT', 'A COMMENT 2') + assert record_exists(records, 'HISTORY', 'SOME HISTORY 1') + assert record_exists(records, 'HISTORY', 'SOME HISTORY 2') + + +def test_write_key_dict(): + """ + test that write_key works using a standard key dict + """ + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + with FITS(fname, 'rw') as fits: + im = np.zeros((10, 10), dtype='i2') + fits.write(im) + + keydict = { + 'name': 'test', + 'value': 35, + 'comment': 'keydict test', + } + fits[-1].write_key(**keydict) + + h = fits[-1].read_header() + + assert h['test'] == keydict['value'] + assert h.get_comment('test') == keydict['comment'] + + +@pytest.mark.parametrize("fname", ["test.fits", "mem://"]) +def test_header_update_compressed_image_to_table(fname): + data = np.arange(10).reshape(5, 2).astype(np.float32) + + fname = "test.fits" + with tempfile.TemporaryDirectory() as tmpdir: + if "mem://" not in fname: + fpth = os.path.join(tmpdir, fname) + else: + fpth = fname + + with FITS(fpth, "rw") as fits: + fits.write(data, compress="RICE", qlevel=1, dither_seed=10) + hdr = fits[1].read_header() + + info_before = fits[1].get_info() + for key in hdr.keys(): + if key.startswith("Z"): + fits[1].delete_key(key) + for i in range(1000): + fits[1].write_key("test" + str(i), "blah") + fits[1].delete_key("test0") + + fits.update_hdu_list() + info_after = fits[1].get_info() + + assert info_after != info_before + assert fits[1].get_exttype() == "BINARY_TBL" + + +if __name__ == '__main__': + test_header_write_read() diff --git a/fitsio/tests/test_header_junk.py b/fitsio/tests/test_header_junk.py new file mode 100644 index 0000000..8daa480 --- /dev/null +++ b/fitsio/tests/test_header_junk.py @@ -0,0 +1,66 @@ +import os +import tempfile +import pytest +from ..fitslib import read_header, FITS +from ..fits_exceptions import FITSFormatError + + +def test_header_junk(): + """ + test lenient treatment of garbage written by IDL mwrfits + """ + + data = b"""SIMPLE = T /Primary Header created by MWRFITS v1.11 BITPIX = 16 / NAXIS = 0 / EXTEND = T /Extensions may be present BLAT = 1 /integer FOO = 1.00000 /float (or double?) BAR@ = NAN /float NaN BI.Z = NaN /double NaN BAT = INF /1.0 / 0.0 BOO = -INF /-1.0 / 0.0 QUAT = ' ' /blank string QUIP = '1.0 ' /number in quotes QUIZ = ' 1.0 ' /number in quotes with a leading space QUI\xf4\x04 = 'NaN ' /NaN in quotes HIERARCH QU.@D = 'Inf ' END """ # noqa + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with open(fname, 'wb') as fobj: + fobj.write(data) + + h = read_header(fname) + # these keys are not hierarch but we can parse the name and then + # leave the value as a string, so we do that. + assert h['bar@'] == 'NAN', 'NAN garbage' + assert h['bi.z'] == 'NaN', 'NaN garbage' + assert h['bat'] == 'INF', 'INF garbage' + assert h['boo'] == '-INF', '-INF garbage' + assert h['quat'] == '', 'blank' + assert h['quip'] == '1.0', '1.0 in quotes' + assert h['quiz'] == ' 1.0', '1.0 in quotes' + # the key in the header is 'QUI' + two non-ascii chars and gets + # translated to `QUI__` + assert h['qui__'] == 'NaN', 'NaN in quotes' + # this key is `HIERARCH QU.@D` in the header and so gets read as is + assert h['qu.@d'] == 'Inf', 'Inf in quotes' + + +def test_Header_Junk_Non_Ascii(): + data = b"SIMPLE = T / file does conform to FITS standard BITPIX = 16 / number of bits per data pixel NAXIS = 0 / number of data axes EXTEND = T / FITS dataset may contain extensions COMMENT FITS (Flexible Image Transport System) format is defined in 'AstronomyCOMMENT and Astrophysics', volume 376, page 359; bibcode: 2001A&A...376..359H @\x0f@\x0f \x02\x05\x18@\x02\x02\xc5@\x0c\x03\xf3@\x080\x02\x03\xbc@\x0f@@@@@@@@ END " # noqa + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + with open(fname, 'wb') as fobj: + fobj.write(data) + + h = read_header(fname) + assert h["@_@_"] is None + + +def test_missing_xtension_keyword(): + """ + Misformatted header with extension not properly marked with + XTENSION + """ + + data = b"""SIMPLE = T / This is a FITS file BITPIX = 8 / NAXIS = 0 / EXTEND = T / This file may contain FITS extensions NEXTEND = 7 / Number of extensions END SIMPLE = T / file does conform to FITS standard BITPIX = 32 / number of bits per data pixel NAXIS = 2 / number of data axes NAXIS1 = 30 / length of data axis 1 NAXIS2 = 30 / length of data axis 2 EXTEND = T / FITS dataset may contain extensions END """ # noqa + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with open(fname, 'wb') as fobj: + fobj.write(data) + + with pytest.raises(FITSFormatError): + with FITS(fname) as fits: + print(fits) diff --git a/fitsio/tests/test_image.py b/fitsio/tests/test_image.py new file mode 100644 index 0000000..ab2e65e --- /dev/null +++ b/fitsio/tests/test_image.py @@ -0,0 +1,804 @@ +import os +import tempfile + +import pytest + +# import warnings +from .checks import check_header, compare_array +from ..util import cfitsio_version, cfitsio_is_bundled +import numpy as np +from ..fitslib import FITS + +CFITSIO_VERSION = cfitsio_version(asfloat=True) +DTYPES = ['u1', 'i1', 'u2', 'i2', 'f4', 'f8'] +if CFITSIO_VERSION > 3.44: + DTYPES += ["u8"] + + +@pytest.mark.parametrize("with_nan", [False, True]) +def test_image_write_read(with_nan): + """ + Test a basic image write, data and a header, then reading back in to + check the values + """ + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + with FITS(fname, 'rw') as fits: + # note mixing up byte orders a bit + for dtype in DTYPES: + data = np.arange(5 * 20, dtype=dtype).reshape(5, 20) + if "f" in dtype and with_nan: + data[3, 13] = np.nan + + header = {'DTYPE': dtype, 'NBYTES': data.dtype.itemsize} + fits.write_image(data, header=header) + rdata = fits[-1].read() + + np.testing.assert_array_equal(data, rdata) + + rh = fits[-1].read_header() + check_header(header, rh) + + with FITS(fname) as fits: + for i in range(len(DTYPES)): + assert not fits[i].is_compressed(), 'not compressed' + + +@pytest.mark.parametrize("fname", ["mem://", "test.fits"]) +def test_image_write_read_bool(fname): + rng = np.random.RandomState(seed=10) + with tempfile.TemporaryDirectory() as tmpdir: + if "mem://" not in fname: + fpth = os.path.join(tmpdir, fname) + else: + fpth = fname + + with FITS(fpth, "rw") as fits: + a = rng.rand(10) + fits.write(a) + a = rng.rand(10) > 0.5 + with pytest.raises(TypeError) as e: + fits.write(a) + + assert "Unsupported numpy image datatype 0" in str(e) + + +@pytest.mark.parametrize("with_nan", [False, True]) +@pytest.mark.parametrize("dtype", DTYPES) +def test_image_write_read_unaligned(dtype, with_nan): + """ + Test a basic image write, data and a header, then reading back in to + check the values. The data from numpy is an unaligned view. The code + to make the unaligned view was generated by Google's AI and then modified + by hand to fix a bug. + """ + + if ( + dtype == ">f4" or ("f" in dtype and with_nan) + ) and not cfitsio_is_bundled(): + pytest.xfail( + reason=( + "Non-bundled cfitsio libraries have a bug for " + "underflow handling. " + "See https://github.com/HEASARC/cfitsio/pull/102." + ), + ) + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + with FITS(fname, 'rw') as fits: + # note mixing up byte orders a bit + data = np.arange(20, dtype=dtype) + unaligned_data = np.ndarray( + shape=(19,), + dtype=data.dtype, + buffer=data.data, + offset=1, # Offset by 1 byte + strides=data.strides, + ) + if not dtype.endswith("1"): + assert not unaligned_data.flags["ALIGNED"] + + if "f" in dtype and with_nan: + unaligned_data[3] = np.nan + + header = { + 'DTYPE': dtype, + 'NBYTES': unaligned_data.dtype.itemsize, + } + fits.write_image(unaligned_data, header=header) + rdata = fits[-1].read() + + np.testing.assert_array_equal(unaligned_data, rdata) + + rh = fits[-1].read_header() + check_header(header, rh) + + with FITS(fname) as fits: + assert not fits[0].is_compressed(), 'not compressed' + + +@pytest.mark.parametrize("with_nan", [False, True]) +def test_image_subnormal_float32(with_nan): + if not cfitsio_is_bundled(): + pytest.xfail( + reason=( + "Non-bundled cfitsio libraries have a bug for " + "underflow handling. " + "See https://github.com/HEASARC/cfitsio/pull/102." + ), + ) + v = 8.82818e-44 + v = [v] * 10 + if with_nan: + v += [np.nan] + nv = np.array(v, dtype=np.float32) + + with FITS("mem://", 'rw') as fits: + fits.write_image(nv) + rdata = fits[-1].read() + + np.testing.assert_array_equal(rdata, nv) + + +@pytest.mark.parametrize("with_nan", [False, True]) +def test_image_subnormal_float64(with_nan): + if not cfitsio_is_bundled(): + pytest.xfail( + reason=( + "Non-bundled cfitsio libraries have a bug for " + "underflow handling. " + "See https://github.com/HEASARC/cfitsio/pull/102." + ), + ) + v = 2.225073858507203e-309 + v = [v] * 10 + if with_nan: + v += [np.nan] + nv = np.array(v, dtype=np.float64) + + with FITS("mem://", 'rw') as fits: + fits.write_image(nv) + rdata = fits[-1].read() + + np.testing.assert_array_equal(rdata, nv) + + +def test_image_write_empty(): + """ + Test a basic image write, with no data and just a header, then reading + back in to check the values + """ + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + data = None + + header = { + 'EXPTIME': 120, + 'OBSERVER': 'Beatrice Tinsley', + 'INSTRUME': 'DECam', + 'FILTER': 'r', + } + ccds = ['CCD1', 'CCD2', 'CCD3', 'CCD4', 'CCD5', 'CCD6', 'CCD7', 'CCD8'] + with FITS(fname, 'rw', ignore_empty=True) as fits: + for extname in ccds: + fits.write_image(data, header=header) + _ = fits[-1].read() + rh = fits[-1].read_header() + check_header(header, rh) + + +@pytest.mark.parametrize("with_nan", [False, True]) +def test_image_write_read_from_dims(with_nan): + """ + Test creating an image from dims and writing in place + """ + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + # note mixing up byte orders a bit + for dtype in DTYPES: + data = np.arange(5 * 20, dtype=dtype).reshape(5, 20) + if "f" in dtype and with_nan: + data[3, 13] = np.nan + + fits.create_image_hdu(dims=data.shape, dtype=data.dtype) + + fits[-1].write(data) + rdata = fits[-1].read() + + np.testing.assert_array_equal(data, rdata) + + with FITS(fname) as fits: + for i in range(len(DTYPES)): + assert not fits[i].is_compressed(), "not compressed" + + +@pytest.mark.parametrize("with_nan", [False, True]) +def test_image_write_read_from_dims_chunks(with_nan): + """ + Test creating an image and reading/writing chunks + """ + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + # note mixing up byte orders a bit + for dtype in DTYPES: + data = np.arange(5 * 3, dtype=dtype).reshape(5, 3) + if "f" in dtype and with_nan: + data[3, 1] = np.nan + + fits.create_image_hdu(dims=data.shape, dtype=data.dtype) + + chunk1 = data[0:2, :] + chunk2 = data[2:, :] + + # + # first using scalar pixel offset + # + + fits[-1].write(chunk1) + + start = chunk1.size + fits[-1].write(chunk2, start=start) + + rdata = fits[-1].read() + + np.testing.assert_array_equal(data, rdata) + + # + # now using sequence, easier to calculate + # + + fits.create_image_hdu(dims=data.shape, dtype=data.dtype) + + # first using pixel offset + fits[-1].write(chunk1) + + start = [2, 0] + fits[-1].write(chunk2, start=start) + + rdata2 = fits[-1].read() + + np.testing.assert_array_equal(data, rdata2) + + with FITS(fname) as fits: + for i in range(len(DTYPES)): + assert not fits[i].is_compressed(), "not compressed" + + +@pytest.mark.parametrize("with_nan", [False, True]) +def test_image_slice(with_nan): + """ + test reading an image slice + """ + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + # note mixing up byte orders a bit + for dtype in DTYPES: + data = np.arange(16 * 20, dtype=dtype).reshape(16, 20) + if "f" in dtype and with_nan: + data[3, 13] = np.nan + + header = {'DTYPE': dtype, 'NBYTES': data.dtype.itemsize} + + fits.write_image(data, header=header) + rdata = fits[-1][4:12, 9:17] + + np.testing.assert_array_equal(data[4:12, 9:17], rdata) + + rh = fits[-1].read_header() + check_header(header, rh) + + +def _check_shape(expected_data, rdata): + mess = 'Data are not the same (Expected shape: %s, actual shape: %s.' % ( + expected_data.shape, + rdata.shape, + ) + np.testing.assert_array_equal(expected_data, rdata, mess) + + +@pytest.mark.parametrize("with_nan", [False, True]) +def test_read_flip_axis_slice(with_nan): + """ + Test reading a slice when the slice's start is less than the slice's stop. + """ + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + dtype = np.float32 + data = np.arange(100 * 200, dtype=dtype).reshape(100, 200) + if with_nan: + data[3, 13] = np.nan + fits.write_image(data) + hdu = fits[-1] + rdata = hdu[:, 130:70] + + # Expanded by two to emulate adding one to the start value, and + # adding one to the calculated dimension. + expected_data = data[:, 130:70:-1] + + _check_shape(expected_data, rdata) + + rdata = hdu[:, 130:70:-6] + expected_data = data[:, 130:70:-6] + _check_shape(expected_data, rdata) + + # Expanded by two to emulate adding one to the start value, and + # adding one to the calculated dimension. + expected_data = data[:, 130:70:-6] + _check_shape(expected_data, rdata) + + # Positive step integer with start > stop will return an empty + # array + rdata = hdu[:, 90:60:4] + expected_data = np.empty(0, dtype=dtype) + _check_shape(expected_data, rdata) + + # Negative step integer with start < stop will return an empty + # array. + rdata = hdu[:, 60:90:-4] + expected_data = np.empty(0, dtype=dtype) + _check_shape(expected_data, rdata) + + +@pytest.mark.parametrize("with_nan", [False, True]) +def test_image_slice_striding(with_nan): + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + # note mixing up byte orders a bit + for dtype in DTYPES: + data = np.arange(16 * 20, dtype=dtype).reshape(16, 20) + if "f" in dtype and with_nan: + data[3, 13] = np.nan + header = {'DTYPE': dtype, 'NBYTES': data.dtype.itemsize} + fits.write_image(data, header=header) + + rdata = fits[-1][4:16:4, 2:20:2] + expected_data = data[4:16:4, 2:20:2] + assert rdata.shape == expected_data.shape, ( + "Shapes differ with dtype %s" % dtype + ) + np.testing.assert_array_equal( + expected_data, rdata, "images with dtype %s" % dtype + ) + + +@pytest.mark.parametrize("with_nan", [False, True]) +def test_read_ignore_scaling(with_nan): + """ + Test the flag to ignore scaling when reading an HDU. + """ + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + dtype = 'i2' + data = np.arange(10 * 20, dtype=dtype).reshape(10, 20) + if "f" in dtype and with_nan: + data[3, 13] = np.nan + header = { + 'DTYPE': dtype, + 'BITPIX': 16, + 'NBYTES': data.dtype.itemsize, + 'BZERO': 9.33, + 'BSCALE': 3.281, + } + + fits.write_image(data, header=header) + hdu = fits[-1] + + rdata = hdu.read() + assert rdata.dtype == np.float32, 'Wrong dtype.' + + hdu.ignore_scaling = True + rdata = hdu[:, :] + assert rdata.dtype == dtype, 'Wrong dtype when ignoring.' + np.testing.assert_array_equal( + data, rdata, err_msg='Wrong unscaled data.' + ) + + rh = fits[-1].read_header() + check_header(header, rh) + + hdu.ignore_scaling = False + rdata = hdu[:, :] + assert rdata.dtype == np.float32, 'Wrong dtype when not ignoring.' + np.testing.assert_array_equal( + data.astype(np.float32), + rdata, + err_msg='Wrong scaled data returned.', + ) + + +@pytest.mark.parametrize( + "compress_kws", + [ + {}, + { + "compress": "RICE", + "tile_dims": (3, 1, 2), + "qlevel": 2048, + "dither_seed": 10, + }, + { + "compress": "GZIP", + "tile_dims": (3, 1, 2), + "qlevel": 0, + "dither_seed": 10, + }, + ], +) +@pytest.mark.parametrize("with_nan", [False, True]) +@pytest.mark.parametrize("fname", ["mem://", "test.fits"]) +@pytest.mark.parametrize("sx", [0, 6, 9]) +@pytest.mark.parametrize("sy", [0, 3, 4]) +@pytest.mark.parametrize("sz", [0, 2, 5]) +def test_image_write_subset_3d(sx, sy, sz, fname, with_nan, compress_kws): + rng = np.random.RandomState(seed=10) + img = np.arange(300).reshape(6, 5, 10).astype(np.float32) + img2 = (rng.normal(size=30).reshape(3, 2, 5) * 1000).astype(np.float32) + if with_nan: + img2[0, 1, 2] = np.nan + + if compress_kws and (sx > 5 or sy > 3 or sz > 3): + pytest.skip(reason="tile-compressed fits images cannot be resized!") + + with tempfile.TemporaryDirectory() as tmpdir: + if "mem://" not in fname: + fpth = os.path.join(tmpdir, fname) + else: + fpth = fname + + with FITS(fpth, "rw") as fits: + fits.write(img, **compress_kws) + if compress_kws: + fits[1].write(img2, start=[sz, sy, sx]) + img_final = fits[1].read() + else: + fits[0].write(img2, start=[sz, sy, sx]) + img_final = fits[0].read() + + if ( + "compress" in compress_kws + and compress_kws.get("qlevel", np.inf) != 0 + ): + np.testing.assert_allclose( + img_final[ + sz : sz + img2.shape[0], + sy : sy + img2.shape[1], + sx : sx + img2.shape[2], + ], + img2, + ) + else: + np.testing.assert_array_equal( + img_final[ + sz : sz + img2.shape[0], + sy : sy + img2.shape[1], + sx : sx + img2.shape[2], + ], + img2, + ) + + +@pytest.mark.parametrize( + "compress_kws", + [ + {}, + { + "compress": "RICE", + "tile_dims": (5, 2), + "qlevel": 128, + "dither_seed": 10, + }, + {"compress": "GZIP", "tile_dims": (5, 2), "qlevel": 0}, + ], +) +@pytest.mark.parametrize("with_nan_base_img", [False, True]) +@pytest.mark.parametrize("with_nan", [False, True]) +@pytest.mark.parametrize( + "fname", + [ + "mem://", + "test.fits", + ], +) +@pytest.mark.parametrize("sx", [0, 1, 9]) +@pytest.mark.parametrize("sy", [0, 1, 9]) +@pytest.mark.parametrize("xnan", [0, 1, 9]) +@pytest.mark.parametrize("ynan", [0, 1, 9]) +def test_image_write_subset_2d( + sx, sy, fname, with_nan, compress_kws, with_nan_base_img, xnan, ynan +): + rng = np.random.RandomState(seed=10) + img = np.arange(100).reshape(10, 10) + nse = rng.normal(size=100).reshape(10, 10) + img = (img + 1e-4 * nse).reshape(10, 10).astype(np.float32) + img2 = (10 + rng.normal(size=6).reshape(3, 2)).astype(np.float32) + if with_nan_base_img: + img[ynan, xnan] = np.nan + if with_nan: + img2[1, 0] = np.nan + + if compress_kws and (sx > 8 or sy > 7): + pytest.skip(reason="tile-compressed fits images cannot be resized!") + + if compress_kws and (sx == 9 or sy == 9): + pytest.skip(reason="tile-compressed fits images cannot be resized!") + + # these test cases have the subset image img2 overlapping two + # different compressed image tiles which causes a bug when + # combined with one of the tiles changing its compression type + # due to an edge case in the compression algorithm + partial_overlap_str = f"{xnan}-{ynan}-{sx}-{sy}" + partial_overlap_str_cases = [ + "0-0-0-1", + "0-0-1-0", + "0-0-1-1", + "0-1-1-0", + "0-1-1-1", + "1-0-0-1", + "1-0-1-1", + ] + if ( + with_nan + and with_nan_base_img + and partial_overlap_str in partial_overlap_str_cases + and not cfitsio_is_bundled() + and compress_kws + and compress_kws.get("qlevel", 0) > 0 + ): + pytest.xfail( + reason=( + "Non-bundled cfitsio libraries have a bug for " + "overwriting tile-compressed images in an edge case. " + "See https://github.com/HEASARC/cfitsio/pull/101." + ), + ) + + with tempfile.TemporaryDirectory() as tmpdir: + if "mem://" not in fname: + fpth = os.path.join(tmpdir, fname) + else: + fpth = fname + + with FITS(fpth, "rw") as fits: + fits.write(img, **compress_kws) + + if compress_kws: + img_final = fits[1].read() + else: + img_final = fits[0].read() + + np.testing.assert_allclose( + img, + img_final, + atol=1e-3, + rtol=0.2, + ) + + if compress_kws: + fits[1].write(img2, start=[sy, sx]) + else: + fits[0].write(img2, start=[sy, sx]) + + if compress_kws: + img_final = fits[1].read() + else: + img_final = fits[0].read() + + if compress_kws: + img_final_slice = fits[1][ + sy : sy + img2.shape[0], sx : sx + img2.shape[1] + ] + else: + img_final_slice = fits[0][ + sy : sy + img2.shape[0], sx : sx + img2.shape[1] + ] + + if ( + "compress" in compress_kws + and compress_kws.get("qlevel", np.inf) != 0 + ): + np.testing.assert_allclose( + img_final[sy : sy + img2.shape[0], sx : sx + img2.shape[1]], + img2, + atol=0, + rtol=0.2, + ) + else: + np.testing.assert_array_equal( + img_final[sy : sy + img2.shape[0], sx : sx + img2.shape[1]], + img2, + ) + + np.testing.assert_array_equal( + img_final[sy : sy + img2.shape[0], sx : sx + img2.shape[1]], + img_final_slice, + ) + + +@pytest.mark.parametrize("with_nan", [False, True]) +@pytest.mark.parametrize("fname", ["mem://", "test.fits"]) +@pytest.mark.parametrize("sx", [0, 13, 99]) +def test_image_write_subset_1d(sx, fname, with_nan): + rng = np.random.RandomState(seed=10) + img = np.arange(100) + img2 = (rng.normal(size=6) * 1000).astype(np.int_) + if with_nan: + img = img.astype(np.float32) + img2 = img2.astype(np.float32) + img2[5] = np.nan + + for _sx in [sx, [sx]]: + with tempfile.TemporaryDirectory() as tmpdir: + if "mem://" not in fname: + fpth = os.path.join(tmpdir, fname) + else: + fpth = fname + + with FITS(fpth, "rw") as fits: + fits.write(img) + fits[0].write(img2, start=_sx) + img_final = fits[0].read() + + np.testing.assert_array_equal( + img_final[sx : sx + img2.shape[0]], + img2, + ) + + +@pytest.mark.parametrize("fname", ["mem://", "test.fits"]) +@pytest.mark.parametrize( + "shape,reshape", + [ + ((6, 5, 10), (10, 12, 23)), + ((1,), (10,)), + ((6, 5), (10, 12)), + ((6, 5, 10), (3, 2, 7)), + ((10,), (3,)), + ((10, 5), (12, 2)), + ], +) +def test_image_reshape(shape, reshape, fname): + img = np.arange(int(np.prod(shape))).reshape(shape) + + with tempfile.TemporaryDirectory() as tmpdir: + if "mem://" not in fname: + fpth = os.path.join(tmpdir, fname) + else: + fpth = fname + + with FITS(fpth, "rw") as fits: + fits.write(img) + fits[0].reshape(reshape) + img_final = fits[0].read() + + nel = img.ravel().shape[0] + nel_final = img_final.ravel().shape[0] + min_nel = min(nel, nel_final) + assert np.array_equal( + img_final.ravel()[:min_nel], + img.ravel()[:min_nel], + ) + if nel_final > nel: + assert np.array_equal( + img_final.ravel()[nel:], + np.zeros(nel_final - nel), + ) + + +@pytest.mark.parametrize("fname", ["mem://", "test.fits"]) +@pytest.mark.parametrize( + "dims", + [ + ((1,)), + ( + ( + 2, + 3, + ) + ), + ( + 4, + 5, + 6, + ), + ], +) +def test_image_write_subset_raises(dims, fname): + ndims = len(dims) + rng = np.random.RandomState(seed=10) + img = np.arange(int(np.prod(dims))).reshape(dims) + exdims = dims + (5,) + img2 = ( + rng.normal(size=int(np.prod(exdims))).reshape(exdims) * 1000 + ).astype(np.int_) + + with tempfile.TemporaryDirectory() as tmpdir: + if "mem://" not in fname: + fpth = os.path.join(tmpdir, fname) + else: + fpth = fname + + with FITS(fpth, "rw") as fits: + fits.write(img) + with pytest.raises(ValueError) as err: + fits[0].write(img2, start=0) + assert ( + "the input image must have the same number of dimensions" + in str(err.value) + ) + + with tempfile.TemporaryDirectory() as tmpdir: + if "mem://" not in fname: + fpth = os.path.join(tmpdir, fname) + else: + fpth = fname + + with FITS(fpth, "rw") as fits: + fits.write(img) + if ndims > 1: + with pytest.raises(ValueError) as err: + fits[0].write(img2[..., 0], start=9999) + assert ( + "the start keyword must have the same number of dimensions" + in str(err.value) + ) + else: + fits[0].write(img2[..., 0], start=9999) + + with tempfile.TemporaryDirectory() as tmpdir: + if "fpth" not in fname: + fpth = os.path.join(tmpdir, fname) + else: + fpth = fname + + with FITS("mem://", "rw") as fits: + fits.write(img) + if ndims > 1: + fits[0].write(img2[..., :-1, 0], start=1) + else: + fits[0].write(img2[..., 0], start=1) + + +def test_image_read_write_ulonglong(): + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + with FITS(fname, 'rw') as fits: + data = np.arange(5 * 20, dtype='u8').reshape(5, 20) + header = {'DTYPE': 'u8', 'NBYTES': data.dtype.itemsize} + if CFITSIO_VERSION < 3.45: + with pytest.raises(TypeError) as e: + fits.write_image(data, header=header) + assert ( + "Unsigned 8 byte integer images are not supported " + "by the FITS standard" in str(e.value) + ) + else: + fits.write_image(data, header=header) + rdata = fits[-1].read() + + compare_array(data, rdata, "images") + + rh = fits[-1].read_header() + check_header(header, rh) + + if CFITSIO_VERSION >= 3.45: + with FITS(fname) as fits: + assert not fits[0].is_compressed(), 'not compressed' diff --git a/fitsio/tests/test_image_compression.py b/fitsio/tests/test_image_compression.py new file mode 100644 index 0000000..ad72038 --- /dev/null +++ b/fitsio/tests/test_image_compression.py @@ -0,0 +1,670 @@ +import pytest +import sys +import os +import tempfile +from .checks import ( + # check_header, + compare_array, +) +import numpy as np +from ..fitslib import ( + FITS, + read, + write, + RICE_1, + SUBTRACTIVE_DITHER_1, + GZIP_1, + GZIP_2, + PLIO_1, + HCOMPRESS_1, +) +from ..util import cfitsio_is_bundled, cfitsio_version + +CFITSIO_VERSION = cfitsio_version(asfloat=True) + + +@pytest.mark.parametrize("with_nan", [False, True]) +@pytest.mark.parametrize( + 'compress', + [ + 'rice', + 'hcompress', + 'plio', + 'gzip', + 'gzip_2', + 'gzip_lossless', + 'gzip_2_lossless', + ], +) +@pytest.mark.parametrize( + 'dtype', ['u1', 'i1', 'u2', 'i2', 'u4', 'i4', 'f4', 'f8'] +) +def test_compressed_write_read(compress, dtype, with_nan): + """ + Test writing and reading a rice compressed image + """ + nrows = 5 + ncols = 20 + if compress in ['rice', 'hcompress'] or 'gzip' in compress: + pass + elif compress == 'plio': + if dtype not in ['i1', 'i2', 'i4', 'f4', 'f8']: + return + else: + raise ValueError('unexpected compress %s' % compress) + + if 'lossless' in compress: + qlevel = None + else: + qlevel = 16 + + seed = 1919 + rng = np.random.RandomState(seed) + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + if dtype[0] == 'f': + data = rng.normal(size=(nrows, ncols)) + if compress == 'plio': + data = data.clip(min=0) + data = data.astype(dtype) + else: + data = np.arange( + nrows * ncols, + dtype=dtype, + ).reshape(nrows, ncols) + + if "f" in dtype and with_nan and compress != "plio": + data[3, 11] = np.nan + + csend = compress.replace('_lossless', '') + write(fname, data, compress=csend, qlevel=qlevel) + rdata = read(fname, ext=1) + + if 'lossless' in compress or dtype[0] in ['i', 'u']: + np.testing.assert_array_equal( + data, + rdata, + err_msg="%s compressed images ('%s')" % (compress, dtype), + ) + else: + # lossy floating point + np.testing.assert_allclose( + data, + rdata, + rtol=0, + atol=0.2, + err_msg="%s compressed images ('%s')" % (compress, dtype), + ) + + with FITS(fname) as fits: + assert fits[1].is_compressed(), "is compressed" + + +@pytest.mark.parametrize("with_nan", [False, True]) +@pytest.mark.parametrize( + 'compress', + [ + 'rice', + 'hcompress', + 'plio', + 'gzip', + 'gzip_2', + 'gzip_lossless', + 'gzip_2_lossless', + ], +) +@pytest.mark.parametrize( + 'dtype', ['u1', 'i1', 'u2', 'i2', 'u4', 'i4', 'f4', 'f8'] +) +def test_compressed_write_read_fitsobj(compress, dtype, with_nan): + """ + Test writing and reading a rice compressed image + + In this version, keep the fits object open + """ + + if ( + "gzip" in compress + and dtype in ["u2", "i2", "u4", "i4"] + and not cfitsio_is_bundled() + ): + pytest.xfail( + reason=( + "Non-bundled cfitsio libraries have a bug. " + "See https://github.com/HEASARC/cfitsio/pull/97." + ) + ) + + nrows = 5 + ncols = 20 + if compress in ['rice', 'hcompress'] or 'gzip' in compress: + pass + elif compress == 'plio': + if dtype not in ['i1', 'i2', 'i4', 'f4', 'f8']: + return + else: + raise ValueError('unexpected compress %s' % compress) + + if 'lossless' in compress: + qlevel = None + # qlevel = 9999 + else: + qlevel = 16 + + seed = 1919 + rng = np.random.RandomState(seed) + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + # note i8 not supported for compressed! + + if dtype[0] == 'f': + data = rng.normal(size=(nrows, ncols)) + if compress == 'plio': + data = data.clip(min=0) + data = data.astype(dtype) + else: + data = np.arange( + nrows * ncols, + dtype=dtype, + ).reshape(nrows, ncols) + + if "f" in dtype and with_nan and compress != "plio": + data[3, 11] = np.nan + + csend = compress.replace('_lossless', '') + fits.write_image(data, compress=csend, qlevel=qlevel) + rdata = fits[-1].read() + + if 'lossless' in compress or dtype[0] in ['i', 'u']: + # for integers we have chosen a wide range of values, so + # there will be no quantization and we expect no + # information loss + np.testing.assert_array_equal( + data, + rdata, + "%s compressed images ('%s')" % (compress, dtype), + ) + else: + # lossy floating point + np.testing.assert_allclose( + data, + rdata, + rtol=0, + atol=0.2, + err_msg="%s compressed images ('%s')" % (compress, dtype), + ) + + with FITS(fname) as fits: + assert fits[1].is_compressed(), "is compressed" + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason='importlib bug in 3.8') +@pytest.mark.skipif(CFITSIO_VERSION < 3.49, reason='bug in cfitsio < 3.49') +def test_gzip_tile_compressed_read_lossless_astropy(): + """ + Test reading an image gzip compressed by astropy (fixed by cfitsio 3.49) + """ + import importlib.resources + + ref = ( + importlib.resources.files("fitsio") + / 'test_images' + / 'test_gzip_compressed_image.fits.fz' + ) # noqa + with importlib.resources.as_file(ref) as gzip_file: + data = read(gzip_file) + + compare_array(data, data * 0.0, "astropy lossless compressed image") + + +@pytest.mark.parametrize("with_nan", [False, True]) +def test_compress_preserve_zeros(with_nan): + """ + Test writing and reading gzip compressed image + """ + + zinds = [ + (1, 3), + (2, 9), + ] + + dtypes = ['f4', 'f8'] + + seed = 2020 + rng = np.random.RandomState(seed) + + # Do not test hcompress as it doesn't support SUBTRACTIVE_DITHER_2 + for compress in ['gzip', 'gzip_2', 'rice']: + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + for dtype in dtypes: + data = rng.normal(size=5 * 20).reshape(5, 20).astype(dtype) + for zind in zinds: + data[zind[0], zind[1]] = 0.0 + if with_nan: + data[3, 15] = np.nan + + fits.write_image( + data, + compress=compress, + qlevel=16, + qmethod='SUBTRACTIVE_DITHER_2', + ) + rdata = fits[-1].read() + + for zind in zinds: + assert rdata[zind[0], zind[1]] == 0.0 + if with_nan: + assert np.isnan(rdata[3, 15]) + + +@pytest.mark.parametrize("with_nan", [False, True]) +@pytest.mark.parametrize( + 'compress', + [ + 'rice', + 'hcompress', + 'plio', + ], +) +@pytest.mark.parametrize( + 'seed_type', + ['matched', 'unmatched', 'checksum', 'checksum_int'], +) +@pytest.mark.parametrize( + 'use_fits_object', + [False, True], +) +@pytest.mark.parametrize( + 'dtype', + ['f4', 'f8'], +) +def test_compressed_seed( + compress, seed_type, use_fits_object, dtype, with_nan +): + """ + Test writing and reading a rice compressed image + """ + nrows = 5 + ncols = 20 + + qlevel = 16 + + seed = 1919 + rng = np.random.RandomState(seed) + + if seed_type == 'matched': + # dither_seed = 9881 + dither_seed1 = 9881 + dither_seed2 = 9881 + elif seed_type == 'unmatched': + # dither_seed = None + dither_seed1 = 3 + dither_seed2 = 4 + elif seed_type == 'checksum': + dither_seed1 = 'checksum' + dither_seed2 = b'checksum' + elif seed_type == 'checksum_int': + dither_seed1 = -1 + # any negative means use checksum + dither_seed2 = -3 + + with tempfile.TemporaryDirectory() as tmpdir: + fname1 = os.path.join(tmpdir, 'test1.fits') + fname2 = os.path.join(tmpdir, 'test2.fits') + + data = rng.normal(size=(nrows, ncols)) + if compress == 'plio': + data = data.clip(min=0) + data = data.astype(dtype) + + if "f" in dtype and with_nan and compress != "plio": + data[3, 11] = np.nan + + if use_fits_object: + with FITS(fname1, 'rw') as fits1: + fits1.write( + data, + compress=compress, + qlevel=qlevel, + # dither_seed=dither_seed, + dither_seed=dither_seed1, + ) + rdata1 = fits1[-1].read() + + with FITS(fname2, 'rw') as fits2: + fits2.write( + data, + compress=compress, + qlevel=qlevel, + # dither_seed=dither_seed, + dither_seed=dither_seed2, + ) + rdata2 = fits2[-1].read() + else: + write( + fname1, + data, + compress=compress, + qlevel=qlevel, + # dither_seed=dither_seed, + dither_seed=dither_seed1, + ) + rdata1 = read(fname1) + + write( + fname2, + data, + compress=compress, + qlevel=qlevel, + # dither_seed=dither_seed, + dither_seed=dither_seed2, + ) + rdata2 = read(fname2) + + mess = "%s compressed images ('%s')" % (compress, dtype) + + if seed_type in ['checksum', 'checksum_int', 'matched']: + np.testing.assert_array_equal(rdata1, rdata2, mess) + else: + with pytest.raises(AssertionError): + np.testing.assert_array_equal(rdata1, rdata2, mess) + + if "f" in dtype and with_nan and compress != "plio": + assert np.isnan(rdata1[3, 11]) + assert np.isnan(rdata2[3, 11]) + else: + assert np.all(np.isfinite(rdata1)) + assert np.all(np.isfinite(rdata2)) + + +@pytest.mark.parametrize( + 'dither_seed', + ['blah', 10_001], +) +def test_compressed_seed_bad(dither_seed): + """ + Test writing and reading a rice compressed image + """ + compress = 'rice' + dtype = 'f4' + nrows = 5 + ncols = 20 + + qlevel = 16 + + seed = 1919 + rng = np.random.RandomState(seed) + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + data = rng.normal(size=(nrows, ncols)) + data = data.astype(dtype) + + with pytest.raises(ValueError): + write( + fname, + data, + compress=compress, + qlevel=qlevel, + dither_seed=dither_seed, + ) + + +def test_memory_compressed_seed(): + import fitsio + + dtype = 'f4' + nrows = 300 + ncols = 500 + + seed = 1919 + rng = np.random.RandomState(seed) + + with tempfile.TemporaryDirectory() as tmpdir: + fname1 = os.path.join(tmpdir, 'test1.fits') + fname2 = os.path.join(tmpdir, 'test2.fits') + + data = rng.normal(size=(nrows, ncols)) + data = data.astype(dtype) + + fitsio.write( + fname1, + data.copy(), + dither_seed='checksum', + compress='RICE', + qlevel=1e-4, + tile_dims=(100, 100), + clobber=True, + ) + hdr = fitsio.read_header(fname1, ext=1) + dither1 = hdr['ZDITHER0'] + assert dither1 == 8269 + + fits = fitsio.FITS('mem://[compress R 100,100; qz -1e-4]', 'rw') + fits.write(data.copy(), dither_seed='checksum') + data = fits.read_raw() + fits.close() + f = open(fname2, 'wb') + f.write(data) + f.close() + hdr = fitsio.read_header(fname2, ext=1) + dither2 = hdr['ZDITHER0'] + assert dither1 == dither2 + + +def test_image_compression_inmem_subdither2(): + H, W = 100, 100 + rng = np.random.RandomState(seed=10) + img = rng.normal(size=(H, W)) + img[40:50, :] = 0.0 + with FITS('mem://[compress G 100,100; qz 0]', 'rw') as F: + F.write(img) + rawdata = F.read_raw() + + with tempfile.TemporaryDirectory() as tmpdir: + pth = os.path.join(tmpdir, 'out.fits') + with open(pth, 'wb') as f: + f.write(rawdata) + im2 = read(pth) + z = im2[40:50, :] + + minval = z.min() + assert minval == 0 + + +@pytest.mark.parametrize( + "kw,val", + [ + ("compress", RICE_1), + ("tile_dims", (10, 2)), + ("tile_dims", np.array([10, 2])), + ("tile_dims", [10, 2]), + ("qlevel", 10.0), + ("qmethod", SUBTRACTIVE_DITHER_1), + ("hcomp_scale", 10.0), + ("hcomp_smooth", True), + ], +) +@pytest.mark.parametrize("set_val_to_none", [False, True]) +def test_image_compression_raises_on_python_set(kw, val, set_val_to_none): + H, W = 100, 100 + rng = np.random.RandomState(seed=10) + img = rng.normal(size=(H, W)) + if set_val_to_none: + kws = {kw: None} + else: + kws = {kw: val} + + with FITS('mem://[compress G 100,100; qz 0]', 'rw') as F: + with pytest.raises(ValueError): + F.write(img, **kws) + + with FITS('mem://[compress G 100,100; qz 4.0]', 'rw') as F: + F.write(img, dither_seed=10) + + +@pytest.mark.parametrize( + "compress", + [ + RICE_1, + GZIP_1, + GZIP_2, + PLIO_1, + HCOMPRESS_1, + ], +) +@pytest.mark.parametrize( + "dtype", + [ + np.uint8, + np.int8, + np.uint16, + np.int16, + np.uint32, + np.int32, + ], +) +@pytest.mark.parametrize("fname", ["mem://", "test.fits"]) +def test_image_compression_inmem_lossess_int(compress, dtype, fname): + if not cfitsio_is_bundled(): + pytest.xfail( + reason=( + "Non-bundled cfitsio libraries have a bug. " + "See https://github.com/HEASARC/cfitsio/pull/97 " + "and https://github.com/HEASARC/cfitsio/pull/99." + ), + ) + if compress == PLIO_1 and dtype in [np.int16, np.uint32, np.int32]: + pytest.skip( + reason="PLIO lossless compression of int16, uint32, and " + "int32 types is not supported by cfitsio", + ) + rng = np.random.RandomState(seed=10) + img = rng.normal(size=(300, 300)) + if dtype in [ + np.uint8, + np.uint16, + np.uint32, + ]: + img = np.abs(img) + img = img.astype(dtype) + with tempfile.TemporaryDirectory() as tmpdir: + if "mem://" not in fname: + fpth = os.path.join(tmpdir, fname) + else: + fpth = fname + + with FITS(fpth, 'rw') as F: + F.write(img, compress=compress, qlevel=0) + rimg = F[-1].read() + assert rimg is not None + assert np.array_equal(rimg, img) + + +def test_image_compression_inmem_lossessgzip_int_zeros(): + img = np.zeros((300, 300)).astype(np.int32) + with FITS('mem://', 'rw') as F: + F.write(img, compress='GZIP', qlevel=0) + rimg = F[-1].read() + assert rimg is not None + assert np.array_equal(rimg, img) + + +def test_image_compression_inmem_lossessgzip_float(): + rng = np.random.RandomState(seed=10) + img = rng.normal(size=(300, 300)) + with FITS('mem://', 'rw') as F: + F.write(img, compress='GZIP', qlevel=0) + rimg = F[-1].read() + assert rimg is not None + assert np.array_equal(rimg, img) + + +def test_image_mem_reopen_noop(): + rng = np.random.RandomState(seed=10) + img = rng.normal(size=(300, 300)) + with FITS('mem://', 'rw') as F: + F.write(img) + rimg = F[-1].read() + assert rimg is not None + assert np.array_equal(rimg, img) + F.reopen() + rimg = F[-1].read() + assert rimg is not None + assert np.array_equal(rimg, img) + + with FITS('mem://', 'rw') as F: + F.write(img) + F.reopen() + rimg = F[-1].read() + assert rimg is not None + assert np.array_equal(rimg, img) + + +@pytest.mark.parametrize("nan_value", [np.nan, np.inf, -np.inf]) +@pytest.mark.parametrize("dtype", [np.float32, np.float64]) +@pytest.mark.parametrize( + "fname", + [ + "test.fits", + "mem://", + ], +) +def test_image_compression_nulls(fname, dtype, nan_value): + data = np.arange(36).reshape((6, 6)).astype(dtype) + data[1, 1] = nan_value + + # everything comes back as nan + if nan_value is not np.nan: + msk = ~np.isfinite(data) + cdata = data.copy() + cdata[msk] = np.nan + else: + cdata = data + + with tempfile.TemporaryDirectory() as tmpdir: + if "mem://" not in fname: + fpth = os.path.join(tmpdir, fname) + else: + fpth = fname + + with FITS(fpth, "rw") as fits: + fits.write( + data, + compress='RICE_1', + tile_dims=(3, 3), + dither_seed=10, + qlevel=2, + ) + read_data = fits[1].read() + + np.testing.assert_allclose( + read_data, + cdata, + ) + + if "mem://" not in fpth: + with FITS(fpth, "r") as fits: + read_data = fits[1].read() + np.testing.assert_allclose( + read_data, + cdata, + ) + + +if __name__ == '__main__': + test_compressed_seed( + compress='rice', + match_seed=False, + use_fits_object=True, + dtype='f4', + ) diff --git a/fitsio/tests/test_image_compression_defaults.py b/fitsio/tests/test_image_compression_defaults.py new file mode 100644 index 0000000..c7ee03e --- /dev/null +++ b/fitsio/tests/test_image_compression_defaults.py @@ -0,0 +1,287 @@ +import os +import tempfile +import numpy as np +import fitsio + + +def test_compression_nocompress(): + with tempfile.TemporaryDirectory() as tmpdir: + fn = os.path.join(tmpdir, 'test.fits') + + img = np.ones((20, 20)) + with fitsio.FITS(fn, 'rw', clobber=True) as fits: + fits.write(img) + with fitsio.FITS(fn) as fits: + assert len(fits) == 1 + + +def test_compression_diskfile_kwargs(): + with tempfile.TemporaryDirectory() as tmpdir: + fn = os.path.join(tmpdir, 'test.fits') + + img = np.ones((20, 20)) + with fitsio.FITS(fn, 'rw', clobber=True) as fits: + fits.write( + img, + compress='RICE', + tile_dims=(10, 5), + qlevel=7.0, + qmethod='SUBTRACTIVE_DITHER_2', + dither_seed=42, + ) + with fitsio.FITS(fn) as fits: + assert len(fits) == 2 + hdr = fitsio.read_header(fn, ext=1) + for key, val in [ + ('ZTILE1', 5), + ('ZTILE2', 10), + ('ZQUANTIZ', 'SUBTRACTIVE_DITHER_2'), + ('ZDITHER0', 42), + ('ZCMPTYPE', 'RICE_ONE'), + ]: + assert hdr[key] == val + + +def test_compression_efns(): + with tempfile.TemporaryDirectory() as tmpdir: + fn = os.path.join(tmpdir, 'test.fits') + + img = np.ones((20, 20)) + with fitsio.FITS(fn + '[compress G]', 'rw', clobber=True) as fits: + fits.write(img) + hdr = fitsio.read_header(fn, ext=1) + for key, val in [ + ('ZTILE1', 20), + ('ZTILE2', 1), + ('ZQUANTIZ', 'SUBTRACTIVE_DITHER_1'), + ('ZCMPTYPE', 'GZIP_1'), + ]: + assert hdr[key] == val + + +def test_compression_efns_kwargs(): + with tempfile.TemporaryDirectory() as tmpdir: + fn = os.path.join(tmpdir, 'test.fits') + + img = np.ones((20, 20)) + with fitsio.FITS( + fn + '[compress G 5 10; qz 8.0]', 'rw', clobber=True + ) as fits: + fits.write(img, dither_seed=42) + hdr = fitsio.read_header(fn, ext=1) + for key, val in [ + ('ZTILE1', 5), + ('ZTILE2', 10), + ('ZQUANTIZ', 'SUBTRACTIVE_DITHER_2'), + ('ZCMPTYPE', 'GZIP_1'), + ('ZDITHER0', 42), + ]: + assert hdr[key] == val + + +def test_compression_qlevels_none_zero(): + default_kws = { + "compress": fitsio.GZIP_2, + "tile_dims": np.array([100, 100]), + "qmethod": fitsio.SUBTRACTIVE_DITHER_2, + } + with tempfile.TemporaryDirectory() as tmpdir: + fnpat = os.path.join(tmpdir, 'test-%i.fits') + + H, W = 200, 200 + bigimg = np.random.uniform(size=(H, W)) + results = [] + # None: don't even use compression at all + # 0: lossless gzip + for i, qlevel in enumerate([None, 0, 16, 4, 1]): + fn = fnpat % i + ql = qlevel + kw = {} + kw.update(default_kws) + if ql is None: + kw.update(compress=0) + ql = 0 + kw["qlevel"] = ql + with fitsio.FITS(fn, 'rw', clobber=True) as fits: + fits.write(bigimg, dither_seed=42, **kw) + filesize = os.stat(fn).st_size + img2 = fitsio.read(fn) + rms = np.sqrt(np.mean((img2 - bigimg) ** 2)) + results.append((qlevel, filesize, rms)) + # No compression + q, sz, rms = results[0] + assert sz == 2880 * (1 + int(np.ceil(H * W * 8 / 2880.0))) + assert rms == 0.0 + # GZIP lossless + q, sz, rms = results[1] + assert rms == 0.0 + # Decreasing file size + for r1, r2 in zip(results, results[1:]): + q1, sz1, rms1 = r1 + q2, sz2, rms2 = r2 + assert sz1 > sz2 + assert rms1 <= rms2 + + +def test_compression_hcomp_args(): + with tempfile.TemporaryDirectory() as tmpdir: + fn = os.path.join(tmpdir, 'test.fits') + + img = np.ones((20, 20)) + with fitsio.FITS( + fn + '[compress HS 10 10; s 2.0]', 'rw', clobber=True + ) as fits: + fits.write(img, dither_seed=42) + hdr = fitsio.read_header(fn, ext=1) + for key, val in [ + ('ZTILE1', 10), + ('ZTILE2', 10), + ('ZQUANTIZ', 'SUBTRACTIVE_DITHER_1'), + ('ZCMPTYPE', 'HCOMPRESS_1'), + ('ZDITHER0', 42), + ('ZNAME1', 'SCALE'), + ('ZVAL1', 2.0), + ('ZNAME2', 'SMOOTH'), + ('ZVAL2', 1), + ]: + assert hdr[key] == val + + +def test_compression_qlevel_default(): + # Check that if not specified, qlevel defaults to 4. + with tempfile.TemporaryDirectory() as tmpdir: + fn = os.path.join(tmpdir, 'test.fits') + + H, W = 200, 200 + bigimg = np.random.uniform(size=(H, W)) + # Default qlevel + with fitsio.FITS(fn, 'rw', clobber=True) as fits: + fits.write(bigimg, compress='GZIP') + size_def = os.stat(fn).st_size + hdr = fitsio.read_header(fn, ext=1) + print(hdr) + for key, val in [ + ('ZQUANTIZ', 'SUBTRACTIVE_DITHER_1'), + ('ZCMPTYPE', 'GZIP_1'), + ]: + assert hdr[key] == val + # qlevel=0 + with fitsio.FITS(fn, 'rw', clobber=True) as fits: + fits.write(bigimg, compress='GZIP', qlevel=0) + size_0 = os.stat(fn).st_size + # qlevel=4 + with fitsio.FITS(fn, 'rw', clobber=True) as fits: + fits.write(bigimg, compress='GZIP', qlevel=4) + size_4 = os.stat(fn).st_size + # qlevel=16 + with fitsio.FITS(fn, 'rw', clobber=True) as fits: + fits.write(bigimg, compress='GZIP', qlevel=16) + size_16 = os.stat(fn).st_size + # zero means NO COMPRESSION + assert size_0 > size_4 + # heh, lower values mean MORE COMPRESSION + assert size_4 < size_16 + assert size_def == size_4 + + +def test_compression_multihdu_diskfile(): + # Check multi-HDU case with a normal file + with tempfile.TemporaryDirectory() as tmpdir: + fn = os.path.join(tmpdir, 'test.fits') + + img = np.ones((20, 20)) + with fitsio.FITS(fn, 'rw', clobber=True) as fits: + # A + fits.write(img, extname='A') + # B + fits.write(img, extname='B', compress='GZIP') + # C + fits.write( + img, + extname='C', + compress='GZIP', + qmethod='SUBTRACTIVE_DITHER_2', + ) + # D + fits.write(img, extname='D') + # E + fits.write(img, extname='E', compress='GZIP') + # F + fits.write(img, extname='F', compress=None) + with fitsio.FITS(fn) as fits: + assert len(fits) == 6 + hdrA = fits['A'].read_header() + hdrB = fits['B'].read_header() + hdrC = fits['C'].read_header() + hdrD = fits['D'].read_header() + hdrE = fits['E'].read_header() + hdrF = fits['F'].read_header() + # A is uncompressed + assert 'ZCMPTYPE' not in hdrA + # B is gzip + assert hdrB['ZCMPTYPE'] == 'GZIP_1' + assert hdrB['ZQUANTIZ'] == 'SUBTRACTIVE_DITHER_1' + # C is gzip with SD2 + assert hdrC['ZCMPTYPE'] == 'GZIP_1' + assert hdrC['ZQUANTIZ'] == 'SUBTRACTIVE_DITHER_2' + # D is not compressed + assert 'ZCMPTYPE' not in hdrD + # E is GZIP again + assert hdrE['ZCMPTYPE'] == 'GZIP_1' + assert hdrE['ZQUANTIZ'] == 'SUBTRACTIVE_DITHER_1' + # F is not compressed + assert 'ZCMPTYPE' not in hdrF + + +def test_compression_multihdu_memfile(): + # Check multi-HDU case with a normal file + with tempfile.TemporaryDirectory() as tmpdir: + fn = os.path.join(tmpdir, 'test.fits') + + img = np.ones((20, 20)) + with fitsio.FITS("mem://", 'rw', clobber=True) as fits: + # A + fits.write(img, extname='A') + # B + fits.write(img, extname='B', compress='GZIP') + # C + fits.write( + img, + extname='C', + compress='GZIP', + qmethod='SUBTRACTIVE_DITHER_2', + ) + # D + fits.write(img, extname='D') + # E + fits.write(img, extname='E', compress='GZIP') + # F + fits.write(img, extname='F', compress=None) + + data = fits.read_raw() + with open(fn, 'wb') as f: + f.write(data) + + with fitsio.FITS(fn) as fits: + assert len(fits) == 6 + hdrA = fits['A'].read_header() + hdrB = fits['B'].read_header() + hdrC = fits['C'].read_header() + hdrD = fits['D'].read_header() + hdrE = fits['E'].read_header() + hdrF = fits['F'].read_header() + # A is uncompressed + assert 'ZCMPTYPE' not in hdrA + # B is gzip + assert hdrB['ZCMPTYPE'] == 'GZIP_1' + assert hdrB['ZQUANTIZ'] == 'SUBTRACTIVE_DITHER_1' + # C is gzip with SD2 + assert hdrC['ZCMPTYPE'] == 'GZIP_1' + assert hdrC['ZQUANTIZ'] == 'SUBTRACTIVE_DITHER_2' + # D is not compressed + assert 'ZCMPTYPE' not in hdrD + # E is GZIP again + assert hdrE['ZCMPTYPE'] == 'GZIP_1' + assert hdrE['ZQUANTIZ'] == 'SUBTRACTIVE_DITHER_1' + # F is not compressed + assert 'ZCMPTYPE' not in hdrF diff --git a/fitsio/tests/test_lib.py b/fitsio/tests/test_lib.py new file mode 100644 index 0000000..f00b34b --- /dev/null +++ b/fitsio/tests/test_lib.py @@ -0,0 +1,112 @@ +import os +import tempfile +import numpy as np +from ..fitslib import FITS, read_header +from .checks import compare_array, compare_rec + + +def test_move_by_name(): + """ + test moving hdus by name + """ + + nrows = 3 + + seed = 1234 + rng = np.random.RandomState(seed) + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + data1 = np.zeros(nrows, dtype=[('ra', 'f8'), ('dec', 'f8')]) + data1['ra'] = rng.uniform(nrows) + data1['dec'] = rng.uniform(nrows) + fits.write_table(data1, extname='mytable') + + fits[-1].write_key('EXTVER', 1) + + data2 = np.zeros(nrows, dtype=[('ra', 'f8'), ('dec', 'f8')]) + data2['ra'] = rng.uniform(nrows) + data2['dec'] = rng.uniform(nrows) + + fits.write_table(data2, extname='mytable') + fits[-1].write_key('EXTVER', 2) + + hdunum1 = fits.movnam_hdu('mytable', extver=1) + assert hdunum1 == 2 + hdunum2 = fits.movnam_hdu('mytable', extver=2) + assert hdunum2 == 3 + + +def test_ext_ver(): + """ + Test using extname and extver, all combinations I can think of + """ + + seed = 9889 + rng = np.random.RandomState(seed) + + dtype = [('num', 'i4'), ('ra', 'f8'), ('dec', 'f8')] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + img1 = np.arange(2 * 3, dtype='i4').reshape(2, 3) + 5 + img2 = np.arange(2 * 3, dtype='i4').reshape(2, 3) + 6 + img3 = np.arange(2 * 3, dtype='i4').reshape(2, 3) + 7 + + nrows = 3 + data1 = np.zeros(nrows, dtype=dtype) + + data1['num'] = 1 + data1['ra'] = rng.uniform(nrows) + data1['dec'] = rng.uniform(nrows) + + data2 = np.zeros(nrows, dtype=dtype) + + data2['num'] = 2 + data2['ra'] = rng.uniform(nrows) + data2['dec'] = rng.uniform(nrows) + + data3 = np.zeros(nrows, dtype=dtype) + data3['num'] = 3 + data3['ra'] = rng.uniform(nrows) + data3['dec'] = rng.uniform(nrows) + + hdr1 = {'k1': 'key1'} + hdr2 = {'k2': 'key2'} + + fits.write_image(img1, extname='myimage', header=hdr1, extver=1) + fits.write_table(data1) + fits.write_table(data2, extname='mytable', extver=1) + fits.write_image(img2, extname='myimage', header=hdr2, extver=2) + fits.write_table(data3, extname='mytable', extver=2) + fits.write_image(img3) + + d1 = fits[1].read() + d2 = fits['mytable'].read() + d2b = fits['mytable', 1].read() + d3 = fits['mytable', 2].read() + + for f in data1.dtype.names: + compare_rec(data1, d1, "data1") + compare_rec(data2, d2, "data2") + compare_rec(data2, d2b, "data2b") + compare_rec(data3, d3, "data3") + + dimg1 = fits[0].read() + dimg1b = fits['myimage', 1].read() + dimg2 = fits['myimage', 2].read() + dimg3 = fits[5].read() + + compare_array(img1, dimg1, "img1") + compare_array(img1, dimg1b, "img1b") + compare_array(img2, dimg2, "img2") + compare_array(img3, dimg3, "img3") + + rhdr1 = read_header(fname, ext='myimage', extver=1) + rhdr2 = read_header(fname, ext='myimage', extver=2) + assert 'k1' in rhdr1, 'testing k1 in header version 1' + assert 'k2' in rhdr2, 'testing k2 in header version 2' diff --git a/fitsio/tests/test_table.py b/fitsio/tests/test_table.py new file mode 100644 index 0000000..a5d900f --- /dev/null +++ b/fitsio/tests/test_table.py @@ -0,0 +1,1658 @@ +import pytest +import numpy as np +import os +import tempfile +from .checks import ( + compare_names, + compare_array, + compare_array_tol, + compare_object_array, + compare_rec, + compare_headerlist_header, + compare_rec_with_var, + compare_rec_subrows, +) +from .makedata import make_data +from ..fitslib import FITS, write, read +from .. import util +from .. import cfitsio_has_bzip2_support + +CFITSIO_VERSION = util.cfitsio_version(asfloat=True) +DTYPES = ['u1', 'i1', 'u2', 'i2', 'f4', 'f8'] +if CFITSIO_VERSION > 4: + DTYPES += ["u8"] + + +def test_table_read_write(): + adata = make_data() + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.write_table( + adata['data'], header=adata['keys'], extname='mytable' + ) + + d = fits[1].read() + compare_rec(adata['data'], d, "table read/write") + + h = fits[1].read_header() + compare_headerlist_header(adata['keys'], h) + + # see if our convenience functions are working + write( + fname, + adata['data2'], + extname="newext", + header={'ra': 335.2, 'dec': -25.2}, + ) + d = read(fname, ext='newext') + compare_rec(adata['data2'], d, "table data2") + + # now test read_column + with FITS(fname) as fits: + for f in adata['data'].dtype.names: + d = fits[1].read_column(f) + compare_array( + adata['data'][f], d, "table 1 single field read '%s'" % f + ) + + for f in adata['data2'].dtype.names: + d = fits['newext'].read_column(f) + compare_array( + adata['data2'][f], d, "table 2 single field read '%s'" % f + ) + + # now list of columns + for cols in [ + ['u2scalar', 'f4vec', 'Sarr'], + ['f8scalar', 'u2arr', 'Sscalar'], + ]: + d = fits[1].read(columns=cols) + for f in d.dtype.names: + compare_array( + adata['data'][f][:], d[f], "test column list %s" % f + ) + + for rows in [[1, 3], [3, 1], [2, 2, 1]]: + d = fits[1].read(columns=cols, rows=rows) + for col in d.dtype.names: + compare_array( + adata['data'][col][rows], + d[col], + "test column list %s row subset" % col, + ) + for col in cols: + d = fits[1].read_column(col, rows=rows) + compare_array( + adata['data'][col][rows], + d, + "test column list %s row subset" % col, + ) + + +@pytest.mark.parametrize('nvec', [2, 1]) +def test_table_read_write_vec1(nvec): + """ + ensure the data for vec length 1 gets round-tripped, even though + the shape is not preserved + """ + dtype = [('x', 'f4', (nvec,))] + num = 10 + data = np.zeros(num, dtype=dtype) + data['x'] = np.arange(num * nvec).reshape(num, nvec) + assert data['x'].shape == (num, nvec) + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.write_table(data) + + d = fits[1].read() + if nvec == 1: + assert d['x'].shape == (num,) + compare_array( + data['x'].ravel(), + d['x'].ravel(), + "table single field read 'x'", + ) + + # see if our convenience functions are working + write( + fname, + data, + extname="newext", + ) + d = read(fname, ext='newext') + if nvec == 1: + assert d['x'].shape == (num,) + compare_array(data['x'].ravel(), d['x'].ravel(), "table data2") + + # now test read_column + with FITS(fname) as fits: + d = fits[1].read_column('x') + if nvec == 1: + assert d.shape == (num,) + compare_array( + data['x'].ravel(), d.ravel(), "table single field read 'x'" + ) + + +@pytest.mark.parametrize('nvec', [2, 1]) +def test_table_read_write_uvec1(nvec): + """ + ensure the data for U string vec length 1 gets round-tripped, even though + the shape is not preserved. Also test 2 for consistency + """ + + dtype = [('string', 'U10', (nvec,))] + num = 10 + data = np.zeros(num, dtype=dtype) + sravel = data['string'].ravel() + sravel[:] = ['%-10s' % i for i in range(num * nvec)] + assert data['string'].shape == (num, nvec) + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.write_table(data) + + d = fits[1].read() + + if nvec == 1: + assert d['string'].shape == (num,) + + compare_array( + data['string'].ravel(), + d['string'].ravel(), + "table single field read 'string'", + ) + + # see if our convenience functions are working + write( + fname, + data, + extname="newext", + ) + d = read(fname, ext='newext') + + if nvec == 1: + assert d['string'].shape == (num,) + compare_array( + data['string'].ravel(), + d['string'].ravel(), + "table data2", + ) + + # now test read_column + with FITS(fname) as fits: + d = fits[1].read_column('string') + + if nvec == 1: + assert d.shape == (num,) + compare_array( + data['string'].ravel(), + d.ravel(), + "table single field read 'string'", + ) + + +def test_table_column_index_scalar(): + """ + Test a basic table write, data and a header, then reading back in to + check the values + """ + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + data = np.empty(1, dtype=[('Z', 'f8')]) + data['Z'][:] = 1.0 + fits.write_table(data) + fits.write_table(data) + + with FITS(fname, 'r') as fits: + assert fits[1]['Z'][0].ndim == 0 + assert fits[1][0].ndim == 0 + + +def test_table_read_empty_rows(): + """ + test reading empty list of rows from an table. + """ + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + data = np.empty(1, dtype=[('Z', 'f8')]) + data['Z'][:] = 1.0 + fits.write_table(data) + fits.write_table(data) + + with FITS(fname, 'r') as fits: + assert len(fits[1].read(rows=[])) == 0 + assert len(fits[1].read(rows=range(0, 0))) == 0 + assert len(fits[1].read(rows=np.arange(0, 0))) == 0 + + +def test_table_format_column_subset(): + """ + Test a basic table write, data and a header, then reading back in to + check the values + """ + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + data = np.empty(1, dtype=[('Z', 'f8'), ('Z_PERSON', 'f8')]) + data['Z'][:] = 1.0 + data['Z_PERSON'][:] = 1.0 + fits.write_table(data) + fits.write_table(data) + fits.write_table(data) + + with FITS(fname, 'r') as fits: + # assert we do not have an extra row of 'Z' + sz = str(fits[2]['Z_PERSON']).split('\n') + s = str(fits[2][('Z_PERSON', 'Z')]).split('\n') + assert len(sz) == len(s) - 1 + + +def test_table_write_dict_of_arrays_scratch(): + adata = make_data() + data = adata['data'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + d = {} + for n in data.dtype.names: + d[n] = data[n] + + fits.write(d) + + d = read(fname) + compare_rec(data, d, "list of dicts, scratch") + + +def test_table_write_dict_of_arrays(): + adata = make_data() + data = adata['data'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.create_table_hdu(data, extname='mytable') + + d = {} + for n in data.dtype.names: + d[n] = data[n] + + fits[-1].write(d) + + d = read(fname) + compare_rec(data, d, "list of dicts") + + +def test_table_write_dict_of_arrays_var(): + """ + This version creating the table from a dict of arrays, variable + lenght columns + """ + + adata = make_data() + vardata = adata['vardata'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + d = {} + for n in vardata.dtype.names: + d[n] = vardata[n] + + fits.write(d) + + d = read(fname) + compare_rec_with_var(vardata, d, "dict of arrays, var") + + +def test_table_write_list_of_arrays_scratch(): + """ + This version creating the table from the names and list, creating + table first + """ + + adata = make_data() + data = adata['data'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + names = [n for n in data.dtype.names] + dlist = [data[n] for n in data.dtype.names] + fits.write(dlist, names=names) + + d = read(fname) + compare_rec(data, d, "list of arrays, scratch") + + +def test_table_write_list_of_arrays(): + adata = make_data() + data = adata['data'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.create_table_hdu(data, extname='mytable') + + columns = [n for n in data.dtype.names] + dlist = [data[n] for n in data.dtype.names] + fits[-1].write(dlist, columns=columns) + + d = read(fname, ext='mytable') + compare_rec(data, d, "list of arrays") + + +def test_table_write_list_of_arrays_var(): + """ + This version creating the table from the names and list, variable + lenght cols + """ + adata = make_data() + vardata = adata['vardata'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + names = [n for n in vardata.dtype.names] + dlist = [vardata[n] for n in vardata.dtype.names] + fits.write(dlist, names=names) + + d = read(fname) + compare_rec_with_var(vardata, d, "list of arrays, var") + + +def test_table_write_bad_string(): + for d in ['S0', 'U0']: + dt = [('s', d)] + + # old numpy didn't allow this dtype, so will throw + # a TypeError for empty dtype + try: + data = np.zeros(1, dtype=dt) + supported = True + except TypeError: + supported = False + + if supported: + with pytest.raises(ValueError): + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + with FITS(fname, 'rw') as fits: + fits.write(data) + + +def test_variable_length_columns(): + adata = make_data() + vardata = adata['vardata'] + + for vstorage in ['fixed', 'object']: + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw', vstorage=vstorage) as fits: + fits.write(vardata) + + # reading multiple columns + d = fits[1].read() + compare_rec_with_var( + vardata, d, "read all test '%s'" % vstorage + ) + + cols = ['u2scalar', 'Sobj'] + d = fits[1].read(columns=cols) + compare_rec_with_var( + vardata, d, "read all test subcols '%s'" % vstorage + ) + + # one at a time + for f in vardata.dtype.names: + d = fits[1].read_column(f) + if util.is_object(vardata[f]): + compare_object_array( + vardata[f], d, "read all field '%s'" % f + ) + + # same as above with slices + # reading multiple columns + d = fits[1][:] + compare_rec_with_var( + vardata, d, "read all test '%s'" % vstorage + ) + + d = fits[1][cols][:] + compare_rec_with_var( + vardata, d, "read all test subcols '%s'" % vstorage + ) + + # one at a time + for f in vardata.dtype.names: + d = fits[1][f][:] + if util.is_object(vardata[f]): + compare_object_array( + vardata[f], d, "read all field '%s'" % f + ) + + # + # now same with sub rows + # + + # reading multiple columns, sorted and unsorted + for rows in [[0, 2], [2, 0]]: + d = fits[1].read(rows=rows) + compare_rec_with_var( + vardata, + d, + "read subrows test '%s'" % vstorage, + rows=rows, + ) + + d = fits[1].read(columns=cols, rows=rows) + compare_rec_with_var( + vardata, + d, + "read subrows test subcols '%s'" % vstorage, + rows=rows, + ) + + # one at a time + for f in vardata.dtype.names: + d = fits[1].read_column(f, rows=rows) + if util.is_object(vardata[f]): + compare_object_array( + vardata[f], + d, + "read subrows field '%s'" % f, + rows=rows, + ) + + # same as above with slices + # reading multiple columns + d = fits[1][rows] + compare_rec_with_var( + vardata, + d, + "read subrows slice test '%s'" % vstorage, + rows=rows, + ) + d = fits[1][2:4] + compare_rec_with_var( + vardata, + d, + "read slice test '%s'" % vstorage, + rows=[2, 3], + ) + + d = fits[1][cols][rows] + compare_rec_with_var( + vardata, + d, + "read subcols subrows slice test '%s'" % vstorage, + rows=rows, + ) + + d = fits[1][cols][2:4] + + compare_rec_with_var( + vardata, + d, + "read subcols slice test '%s'" % vstorage, + rows=[2, 3], + ) + + # one at a time + for f in vardata.dtype.names: + d = fits[1][f][rows] + if util.is_object(vardata[f]): + compare_object_array( + vardata[f], + d, + "read subrows field '%s'" % f, + rows=rows, + ) + d = fits[1][f][2:4] + if util.is_object(vardata[f]): + compare_object_array( + vardata[f], + d, + "read slice field '%s'" % f, + rows=[2, 3], + ) + + +def test_table_iter(): + """ + Test iterating over rows of a table + """ + + adata = make_data() + data = adata['data'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.write_table(data, header=adata['keys'], extname='mytable') + + # one row at a time + with FITS(fname) as fits: + hdu = fits["mytable"] + i = 0 + for row_data in hdu: + compare_rec(data[i], row_data, "table data") + i += 1 + + +def test_ascii_table_write_read(): + """ + Test write and read for an ascii table + """ + + adata = make_data() + ascii_data = adata['ascii_data'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.write_table( + ascii_data, + table_type='ascii', + header=adata['keys'], + extname='mytable', + ) + + # cfitsio always reports type as i4 and f8, period, even if if + # written with higher precision. Need to fix that somehow + for f in ascii_data.dtype.names: + d = fits[1].read_column(f) + if d.dtype == np.float64: + # note we should be able to do 1.11e-16 in principle, but + # in practice we get more like 2.15e-16 + compare_array_tol( + ascii_data[f], d, 2.15e-16, "table field read '%s'" % f + ) + else: + compare_array( + ascii_data[f], d, "table field read '%s'" % f + ) + + for rows in [[1, 3], [3, 1]]: + for f in ascii_data.dtype.names: + d = fits[1].read_column(f, rows=rows) + if d.dtype == np.float64: + compare_array_tol( + ascii_data[f][rows], + d, + 2.15e-16, + "table field read subrows '%s'" % f, + ) + else: + compare_array( + ascii_data[f][rows], + d, + "table field read subrows '%s'" % f, + ) + + beg = 1 + end = 3 + for f in ascii_data.dtype.names: + d = fits[1][f][beg:end] + if d.dtype == np.float64: + compare_array_tol( + ascii_data[f][beg:end], + d, + 2.15e-16, + "table field read slice '%s'" % f, + ) + else: + compare_array( + ascii_data[f][beg:end], + d, + "table field read slice '%s'" % f, + ) + + cols = ['i2scalar', 'f4scalar'] + for f in ascii_data.dtype.names: + data = fits[1].read(columns=cols) + for f in data.dtype.names: + d = data[f] + if d.dtype == np.float64: + compare_array_tol( + ascii_data[f], + d, + 2.15e-16, + "table subcol, '%s'" % f, + ) + else: + compare_array( + ascii_data[f], d, "table subcol, '%s'" % f + ) + + data = fits[1][cols][:] + for f in data.dtype.names: + d = data[f] + if d.dtype == np.float64: + compare_array_tol( + ascii_data[f], + d, + 2.15e-16, + "table subcol, '%s'" % f, + ) + else: + compare_array( + ascii_data[f], d, "table subcol, '%s'" % f + ) + + for rows in [[1, 3], [3, 1]]: + for f in ascii_data.dtype.names: + data = fits[1].read(columns=cols, rows=rows) + for f in data.dtype.names: + d = data[f] + if d.dtype == np.float64: + compare_array_tol( + ascii_data[f][rows], + d, + 2.15e-16, + "table subcol, '%s'" % f, + ) + else: + compare_array( + ascii_data[f][rows], + d, + "table subcol, '%s'" % f, + ) + + data = fits[1][cols][rows] + for f in data.dtype.names: + d = data[f] + if d.dtype == np.float64: + compare_array_tol( + ascii_data[f][rows], + d, + 2.15e-16, + "table subcol/row, '%s'" % f, + ) + else: + compare_array( + ascii_data[f][rows], + d, + "table subcol/row, '%s'" % f, + ) + + for f in ascii_data.dtype.names: + data = fits[1][cols][beg:end] + for f in data.dtype.names: + d = data[f] + if d.dtype == np.float64: + compare_array_tol( + ascii_data[f][beg:end], + d, + 2.15e-16, + "table subcol/slice, '%s'" % f, + ) + else: + compare_array( + ascii_data[f][beg:end], + d, + "table subcol/slice, '%s'" % f, + ) + + +def test_table_insert_column(): + """ + Insert a new column + """ + adata = make_data() + data = adata['data'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.write_table(data, header=adata['keys'], extname='mytable') + + d = fits[1].read() + + for n in d.dtype.names: + newname = n + '_insert' + + fits[1].insert_column(newname, d[n]) + + newdata = fits[1][newname][:] + + compare_array( + d[n], + newdata, + "table single field insert and read '%s'" % n, + ) + + +def test_table_delete_row_range(): + """ + Test deleting a range of rows using the delete_rows method + """ + + adata = make_data() + data = adata['data'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.write_table(data) + + rowslice = slice(1, 3) + with FITS(fname, 'rw') as fits: + fits[1].delete_rows(rowslice) + + with FITS(fname) as fits: + d = fits[1].read() + + compare_data = data[[0, 3]] + compare_rec(compare_data, d, "delete row range") + + +def test_table_delete_rows(): + """ + Test deleting specific set of rows using the delete_rows method + """ + + adata = make_data() + data = adata['data'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.write_table(data) + + rows2delete = [1, 3] + with FITS(fname, 'rw') as fits: + fits[1].delete_rows(rows2delete) + + with FITS(fname) as fits: + d = fits[1].read() + + compare_data = data[[0, 2]] + compare_rec(compare_data, d, "delete rows") + + +def test_table_where(): + """ + Use the where method to get indices for a row filter expression + """ + + adata = make_data() + data2 = adata['data2'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.write_table(data2) + + # + # get all indices + # + with FITS(fname) as fits: + a = fits[1].where('x > 3 && y < 8') + b = np.where((data2['x'] > 3) & (data2['y'] < 8))[0] + np.testing.assert_array_equal(a, b) + + # + # get slice of indices + # + with FITS(fname) as fits: + a = fits[1].where('x > 3 && y < 8', 2, 8) + b = np.where((data2['x'][2:8] > 3) & (data2['y'][2:8] < 8))[0] + np.testing.assert_array_equal(a, b) + + +def test_table_resize(): + """ + Use the resize method to change the size of a table + + default values get filled in and these are tested + """ + adata = make_data() + data = adata['data'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + # + # shrink from back + # + with FITS(fname, 'rw', clobber=True) as fits: + fits.write_table(data) + + nrows = 2 + with FITS(fname, 'rw') as fits: + fits[1].resize(nrows) + + with FITS(fname) as fits: + d = fits[1].read() + + compare_data = data[0:nrows] + compare_rec(compare_data, d, "shrink from back") + + # + # shrink from front + # + with FITS(fname, 'rw', clobber=True) as fits: + fits.write_table(data) + + with FITS(fname, 'rw') as fits: + fits[1].resize(nrows, front=True) + + with FITS(fname) as fits: + d = fits[1].read() + + compare_data = data[nrows - data.size :] + compare_rec(compare_data, d, "shrink from front") + + # These don't get zerod + # the defaults below come out of cfitsio + # IDK where they are defined + nrows = 10 + add_data = np.zeros(nrows - data.size, dtype=data.dtype) + add_data['i1scalar'] = -(2**7) + add_data['i1vec'] = -(2**7) + add_data['i1arr'] = -(2**7) + add_data['u2scalar'] = 2**15 + add_data['u2vec'] = 2**15 + add_data['u2arr'] = 2**15 + add_data['u4scalar'] = 2**31 + add_data['u4vec'] = 2**31 + add_data['u4arr'] = 2**31 + if CFITSIO_VERSION > 4: + add_data['u8scalar'] = 2**63 + add_data['u8vec'] = 2**63 + add_data['u8arr'] = 2**63 + + # + # expand at the back + # + with FITS(fname, 'rw', clobber=True) as fits: + fits.write_table(data) + with FITS(fname, 'rw') as fits: + fits[1].resize(nrows) + + with FITS(fname) as fits: + d = fits[1].read() + + compare_data = np.hstack((data, add_data)) + compare_rec(compare_data, d, "expand at the back") + + # + # expand at the front + # + with FITS(fname, 'rw', clobber=True) as fits: + fits.write_table(data) + with FITS(fname, 'rw') as fits: + fits[1].resize(nrows, front=True) + + with FITS(fname) as fits: + d = fits[1].read() + + compare_data = np.hstack((add_data, data)) + # These don't get zerod + compare_rec(compare_data, d, "expand at the front") + + +def test_slice(): + """ + Test reading by slice + """ + adata = make_data() + data = adata['data'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + # initial write + fits.write_table(data) + + # test reading single columns + for f in data.dtype.names: + d = fits[1][f][:] + compare_array( + data[f], d, "test read all rows %s column subset" % f + ) + + # test reading row subsets + rows = [1, 3] + for f in data.dtype.names: + d = fits[1][f][rows] + compare_array(data[f][rows], d, "test %s row subset" % f) + for f in data.dtype.names: + d = fits[1][f][1:3] + compare_array(data[f][1:3], d, "test %s row slice" % f) + for f in data.dtype.names: + d = fits[1][f][1:4:2] + compare_array( + data[f][1:4:2], d, "test %s row slice with step" % f + ) + for f in data.dtype.names: + d = fits[1][f][::2] + compare_array( + data[f][::2], d, "test %s row slice with only setp" % f + ) + + # now list of columns + cols = ['u2scalar', 'f4vec', 'Sarr'] + d = fits[1][cols][:] + for f in d.dtype.names: + compare_array(data[f][:], d[f], "test column list %s" % f) + + cols = ['u2scalar', 'f4vec', 'Sarr'] + d = fits[1][cols][rows] + for f in d.dtype.names: + compare_array( + data[f][rows], d[f], "test column list %s row subset" % f + ) + + cols = ['u2scalar', 'f4vec', 'Sarr'] + d = fits[1][cols][1:3] + for f in d.dtype.names: + compare_array( + data[f][1:3], d[f], "test column list %s row slice" % f + ) + + +def test_table_append(): + """ + Test creating a table and appending new rows. + """ + adata = make_data() + data = adata['data'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + # initial write + fits.write_table(data, header=adata['keys'], extname='mytable') + # now append + data2 = data.copy() + data2['f4scalar'] = 3 + fits[1].append(data2) + + d = fits[1].read() + assert d.size == data.size * 2 + + compare_rec(data, d[0 : data.size], "Comparing initial write") + compare_rec(data2, d[data.size :], "Comparing appended data") + + h = fits[1].read_header() + compare_headerlist_header(adata['keys'], h) + + # append with list of arrays and names + names = data.dtype.names + data3 = [np.array(data[name]) for name in names] + fits[1].append(data3, names=names) + + d = fits[1].read() + assert d.size == data.size * 3 + compare_rec(data, d[2 * data.size :], "Comparing appended data") + + # append with list of arrays and columns + fits[1].append(data3, columns=names) + + d = fits[1].read() + assert d.size == data.size * 4 + compare_rec(data, d[3 * data.size :], "Comparing appended data") + + +def test_table_subsets(): + """ + testing reading subsets + """ + adata = make_data() + data = adata['data'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.write_table(data, header=adata['keys'], extname='mytable') + + for rows in [[1, 3], [3, 1]]: + d = fits[1].read(rows=rows) + compare_rec_subrows(data, d, rows, "table subset") + columns = ['i1scalar', 'f4arr'] + d = fits[1].read(columns=columns, rows=rows) + + for f in columns: + d = fits[1].read_column(f, rows=rows) + compare_array( + data[f][rows], d, "row subset, multi-column '%s'" % f + ) + for f in data.dtype.names: + d = fits[1].read_column(f, rows=rows) + compare_array( + data[f][rows], d, "row subset, column '%s'" % f + ) + + +def test_gz_write_read(): + """ + Test a basic table write, data and a header, then reading back in to + check the values + + this code all works, but the file is zere size when done! + """ + adata = make_data() + data = adata['data'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.write_table(data, header=adata['keys'], extname='mytable') + + d = fits[1].read() + compare_rec(data, d, "gzip write/read") + + h = fits[1].read_header() + for entry in adata['keys']: + name = entry['name'].upper() + value = entry['value'] + hvalue = h[name] + if isinstance(hvalue, str): + hvalue = hvalue.strip() + assert value == hvalue, "testing header key '%s'" % name + + if 'comment' in entry: + assert ( + entry['comment'].strip() == h.get_comment(name).strip() + ), "testing comment for header key '%s'" % name + + stat = os.stat(fname) + assert stat.st_size != 0, "Making sure the data was flushed to disk" + + +@pytest.mark.skipif( + not cfitsio_has_bzip2_support(), + reason='cfitsio was not built with bzip2 support', +) +def test_bz2_read(): + ''' + Write a normal .fits file, run bzip2 on it, then read the bz2 + file and verify that it's the same as what we put in; we don't + [currently support or] test *writing* bzip2. + ''' + + adata = make_data() + data = adata['data'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + bzfname = fname + '.bz2' + + try: + fits = FITS(fname, 'rw') + fits.write_table(data, header=adata['keys'], extname='mytable') + fits.close() + + os.system('bzip2 %s' % fname) + f2 = FITS(bzfname) + d = f2[1].read() + compare_rec(data, d, "bzip2 read") + + h = f2[1].read_header() + for entry in adata['keys']: + name = entry['name'].upper() + value = entry['value'] + hvalue = h[name] + if isinstance(hvalue, str): + hvalue = hvalue.strip() + + assert value == hvalue, "testing header key '%s'" % name + + if 'comment' in entry: + assert ( + entry['comment'].strip() == h.get_comment(name).strip() + ), "testing comment for header key '%s'" % name + except Exception: + import traceback + + traceback.print_exc() + + assert False, 'Exception in testing bzip2 reading' + + +def test_checksum(): + """ + test that checksumming works + """ + adata = make_data() + data = adata['data'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.write_table(data, header=adata['keys'], extname='mytable') + fits[1].write_checksum() + fits[1].verify_checksum() + + +def test_trim_strings(): + """ + test mode where we strim strings on read + """ + + dt = [('fval', 'f8'), ('name', 'S15'), ('vec', 'f4', 2)] + n = 3 + data = np.zeros(n, dtype=dt) + data['fval'] = np.random.random(n) + data['vec'] = np.random.random(n * 2).reshape(n, 2) + + data['name'] = ['mike', 'really_long_name_to_fill', 'jan'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.write(data) + + for onconstruct in [True, False]: + if onconstruct: + ctrim = True + otrim = False + else: + ctrim = False + otrim = True + + with FITS(fname, 'rw', trim_strings=ctrim) as fits: + if ctrim: + dread = fits[1][:] + compare_rec( + data, + dread, + "trimmed strings constructor", + ) + + dname = fits[1]['name'][:] + compare_array( + data['name'], + dname, + "trimmed strings col read, constructor", + ) + dread = fits[1][['name']][:] + compare_array( + data['name'], + dread['name'], + "trimmed strings col read, constructor", + ) + + dread = fits[1].read(trim_strings=otrim) + compare_rec( + data, + dread, + "trimmed strings keyword", + ) + dname = fits[1].read(columns='name', trim_strings=otrim) + compare_array( + data['name'], + dname, + "trimmed strings col keyword", + ) + dread = fits[1].read(columns=['name'], trim_strings=otrim) + compare_array( + data['name'], + dread['name'], + "trimmed strings col keyword", + ) + + # convenience function + dread = read(fname, trim_strings=True) + compare_rec( + data, + dread, + "trimmed strings convenience function", + ) + dname = read(fname, columns='name', trim_strings=True) + compare_array( + data['name'], + dname, + "trimmed strings col convenience function", + ) + dread = read(fname, columns=['name'], trim_strings=True) + compare_array( + data['name'], + dread['name'], + "trimmed strings col convenience function", + ) + + +def test_lower_upper(): + """ + test forcing names to upper and lower + """ + + rng = np.random.RandomState(8908) + + dt = [('MyName', 'f8'), ('StuffThings', 'i4'), ('Blah', 'f4')] + data = np.zeros(3, dtype=dt) + data['MyName'] = rng.uniform(data.size) + data['StuffThings'] = rng.uniform(data.size) + data['Blah'] = rng.uniform(data.size) + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.write(data) + + for i in [1, 2]: + if i == 1: + lower = True + upper = False + else: + lower = False + upper = True + + with FITS(fname, 'rw', lower=lower, upper=upper) as fits: + for rows in [None, [1, 2]]: + d = fits[1].read(rows=rows) + compare_names( + d.dtype.names, + data.dtype.names, + lower=lower, + upper=upper, + ) + + d = fits[1].read( + rows=rows, columns=['MyName', 'stuffthings'] + ) + compare_names( + d.dtype.names, + data.dtype.names[0:2], + lower=lower, + upper=upper, + ) + + d = fits[1][1:2] + compare_names( + d.dtype.names, + data.dtype.names, + lower=lower, + upper=upper, + ) + + if rows is not None: + d = fits[1][rows] + else: + d = fits[1][:] + + compare_names( + d.dtype.names, + data.dtype.names, + lower=lower, + upper=upper, + ) + + if rows is not None: + d = fits[1][['myname', 'stuffthings']][rows] + else: + d = fits[1][['myname', 'stuffthings']][:] + + compare_names( + d.dtype.names, + data.dtype.names[0:2], + lower=lower, + upper=upper, + ) + + # using overrides + with FITS(fname, 'rw') as fits: + for rows in [None, [1, 2]]: + d = fits[1].read(rows=rows, lower=lower, upper=upper) + compare_names( + d.dtype.names, + data.dtype.names, + lower=lower, + upper=upper, + ) + + d = fits[1].read( + rows=rows, + columns=['MyName', 'stuffthings'], + lower=lower, + upper=upper, + ) + compare_names( + d.dtype.names, + data.dtype.names[0:2], + lower=lower, + upper=upper, + ) + + for rows in [None, [1, 2]]: + d = read(fname, rows=rows, lower=lower, upper=upper) + compare_names( + d.dtype.names, data.dtype.names, lower=lower, upper=upper + ) + + d = read( + fname, + rows=rows, + columns=['MyName', 'stuffthings'], + lower=lower, + upper=upper, + ) + compare_names( + d.dtype.names, + data.dtype.names[0:2], + lower=lower, + upper=upper, + ) + + +def test_read_raw(): + """ + testing reading the file as raw bytes + """ + rng = np.random.RandomState(8908) + + dt = [('MyName', 'f8'), ('StuffThings', 'i4'), ('Blah', 'f4')] + data = np.zeros(3, dtype=dt) + data['MyName'] = rng.uniform(data.size) + data['StuffThings'] = rng.uniform(data.size) + data['Blah'] = rng.uniform(data.size) + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + try: + with FITS(fname, 'rw') as fits: + fits.write(data) + raw1 = fits.read_raw() + + with FITS('mem://', 'rw') as fits: + fits.write(data) + raw2 = fits.read_raw() + + with open(fname, 'rb') as fobj: + raw3 = fobj.read() + + assert raw1 == raw2 + assert raw1 == raw3 + except Exception: + import traceback + + traceback.print_exc() + assert False, 'Exception in testing read_raw' + + +def test_table_bitcol_read_write(): + """ + Test basic write/read with bitcols + """ + + adata = make_data() + bdata = adata['bdata'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.write_table(bdata, extname='mytable', write_bitcols=True) + + d = fits[1].read() + compare_rec(bdata, d, "table read/write") + + rows = [0, 2] + d = fits[1].read(rows=rows) + compare_rec(bdata[rows], d, "table read/write rows") + + d = fits[1][:2] + compare_rec(bdata[:2], d, "table read/write slice") + + # now test read_column + with FITS(fname) as fits: + for f in bdata.dtype.names: + d = fits[1].read_column(f) + compare_array( + bdata[f], d, "table 1 single field read '%s'" % f + ) + + # now list of columns + for cols in [['b1vec', 'b1arr']]: + d = fits[1].read(columns=cols) + for f in d.dtype.names: + compare_array(bdata[f][:], d[f], "test column list %s" % f) + + for rows in [[1, 3], [3, 1]]: + d = fits[1].read(columns=cols, rows=rows) + for f in d.dtype.names: + compare_array( + bdata[f][rows], + d[f], + "test column list %s row subset" % f, + ) + + +def test_table_bitcol_append(): + """ + Test creating a table with bitcol support and appending new rows. + """ + adata = make_data() + bdata = adata['bdata'] + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + # initial write + fits.write_table(bdata, extname='mytable', write_bitcols=True) + + with FITS(fname, 'rw') as fits: + # now append + bdata2 = bdata.copy() + fits[1].append(bdata2) + + d = fits[1].read() + assert d.size == bdata.size * 2 + + compare_rec(bdata, d[0 : bdata.size], "Comparing initial write") + compare_rec(bdata2, d[bdata.size :], "Comparing appended data") + + +def test_table_bitcol_insert(): + """ + Test creating a table with bitcol support and appending new rows. + """ + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + # initial write + nrows = 3 + d = np.zeros(nrows, dtype=[('ra', 'f8')]) + d['ra'] = range(d.size) + fits.write(d) + + with FITS(fname, 'rw') as fits: + bcol = np.array([True, False, True]) + + # now append + fits[-1].insert_column( + 'bscalar_inserted', bcol, write_bitcols=True + ) + + d = fits[-1].read() + assert d.size == nrows, 'read size equals' + compare_array(bcol, d['bscalar_inserted'], "inserted bitcol") + + bvec = np.array([[True, False], [False, True], [True, True]]) + + # now append + fits[-1].insert_column('bvec_inserted', bvec, write_bitcols=True) + + d = fits[-1].read() + assert d.size == nrows, 'read size equals' + compare_array(bvec, d['bvec_inserted'], "inserted bitcol") + + +def test_table_write_dict_of_arrays_unaligned(): + data = {} + for dtype in DTYPES: + _data = np.arange(20, dtype=dtype) + # The code to make the unaligned view was generated + # by Google's AI and then modified by hand to fix a bug. + unaligned_data = np.ndarray( + shape=(19,), + dtype=_data.dtype, + buffer=_data.data, + offset=1, # Offset by 1 byte + strides=_data.strides, + ) + if not dtype.endswith("1"): + assert not unaligned_data.flags["ALIGNED"] + + data[dtype.replace("<", "l")] = unaligned_data + + dtype = np.dtype( + { + "names": list(data.keys()), + "formats": [v.dtype for v in data.values()], + } + ) + data_stra = np.zeros(data[dtype.names[0]].shape, dtype=dtype) + for k, v in data.items(): + data_stra[k] = v + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + fits.create_table_hdu(data, extname='mytable') + fits[-1].write(data) + + d = read(fname) + compare_rec(data_stra, d, "list of dicts") + + +@pytest.mark.parametrize("table_type", ["binary", "ascii"]) +def test_table_big_col(table_type): + d = np.ones(1, dtype=[("blah", "U70000")]) + d["blah"] = "".join(["a"] * 60000) + with tempfile.TemporaryDirectory() as tmpdir: + pth = os.path.join(tmpdir, "test.fits") + # v3 cfitsio that is not bundled fails for big + # columns + if table_type == "ascii" or CFITSIO_VERSION < 4: + with pytest.raises(OSError) as e: + write(pth, d, table_type=table_type) + assert "FITSIO status = 236: column exceeds width of table" in str( + e.value + ) + assert ( + "string column is too wide: 70000; " + "max supported width is" in str(e.value) + ) + else: + write(pth, d, table_type=table_type) + data = read(pth) + np.testing.assert_array_equal(d, data) + + +@pytest.mark.xfail( + condition=CFITSIO_VERSION < 4, + reason=( + "cfitsio versions < 4 do not easily support null-terminated strings" + ), +) +@pytest.mark.parametrize("table_type", ["binary", "ascii"]) +def test_table_null_end_strings(table_type): + d = np.ones(2, dtype=[("blah", "U70")]) + d["blah"][0] = "".join(["a"] * 60) + d["blah"][1] = "" + with tempfile.TemporaryDirectory() as tmpdir: + pth = os.path.join(tmpdir, "test.fits") + write(pth, d, table_type=table_type) + data = read(pth) + assert len(data["blah"][0]) == 60 + assert "U70" in data["blah"].dtype.descr[0][1] + + if table_type == "ascii": + # null strings in ascii tables are a single blank + d["blah"][1] = " " + np.testing.assert_array_equal(d, data) + + +def test_table_read_write_ulonglong(): + adata = np.zeros(5, dtype=[("u8scalar", "u8")]) + adata["u8scalar"] = (2**64 - 1) - np.arange(5, dtype="u8") + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + if CFITSIO_VERSION < 3.45: + with pytest.raises(IOError) as e: + fits.write_table( + adata, + extname='mytable', + ) + assert "'W'" in str(e.value) + else: + fits.write_table( + adata, + extname='mytable', + ) + d = fits[1].read() + compare_rec(adata, d, "table read/write") + + +@pytest.mark.parametrize("typ", ["u8", "u4", "i8"]) +def test_table_read_write_ulonglong_ascii_raises(typ): + adata = np.zeros(5, dtype=[("scalar", typ)]) + if typ == "u8": + val = 2**64 - 1 + elif typ == "u4": + val = 2**32 - 1 + elif typ == "i8": + val = 2**31 - 1 + adata["scalar"] = (val) - np.arange(5, dtype=typ) + + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + with FITS(fname, 'rw') as fits: + with pytest.raises(ValueError) as e: + fits.write_table( + adata, + extname='mytable', + table_type='ascii', + ) + assert f"unsupported type '{typ}' for ascii tables" in str(e.value) diff --git a/fitsio/tests/test_util.py b/fitsio/tests/test_util.py new file mode 100644 index 0000000..7fd76b9 --- /dev/null +++ b/fitsio/tests/test_util.py @@ -0,0 +1,78 @@ +import math +import numpy as np + +import pytest + +from ..util import ( + _nonfinite_as_cfitsio_floating_null_value, + cfitsio_version, + _FLOATING_NULL_VALUE, +) + +CFITSIO_VERSION = cfitsio_version(asfloat=True) +DTYPES = ['u1', 'i1', 'u2', 'i2', 'f4', 'f8'] +if CFITSIO_VERSION > 3.44: + DTYPES += ["u8"] + + +@pytest.mark.parametrize("dtype", DTYPES) +@pytest.mark.parametrize("with_nan", [True, False]) +@pytest.mark.parametrize("hdu_is_compressed", [True, False]) +def test_nonfinite_as_cfitsio_floating_null_value( + dtype, with_nan, hdu_is_compressed +): + data = np.arange(5 * 20, dtype=dtype).reshape(5, 20) + if "f" in dtype and with_nan: + data[3, 13] = np.nan + data[1, 7] = np.inf + data[1, 9] = -np.inf + + with _nonfinite_as_cfitsio_floating_null_value( + data, hdu_is_compressed + ) as ( + nan_data, + any_nan, + ): + if with_nan and "f" in dtype and hdu_is_compressed: + assert any_nan + assert not np.any(np.isnan(nan_data)) + msk = ~np.isfinite(data) + np.testing.assert_array_equal(data[msk], _FLOATING_NULL_VALUE) + np.testing.assert_array_equal(data[~msk], nan_data[~msk]) + else: + assert not any_nan + np.testing.assert_array_equal(nan_data, data) + + if with_nan and "f" in dtype: + np.testing.assert_array_equal(data[3, 13], np.nan) + np.testing.assert_array_equal(data[1, 7], np.inf) + np.testing.assert_array_equal(data[1, 9], -np.inf) + + +def test_cfitsio_floating_null_value_equal_inf(): + assert np.float64(np.inf) == _FLOATING_NULL_VALUE + assert np.float32(np.inf) == _FLOATING_NULL_VALUE + assert np.inf == _FLOATING_NULL_VALUE + assert math.inf == _FLOATING_NULL_VALUE + + +def test_nonfinite_as_cfitsio_floating_null_value_with_exception(): + data = np.arange(5 * 20, dtype=np.float32).reshape(5, 20) + data[3, 13] = np.nan + data[1, 7] = np.inf + data[1, 9] = -np.inf + + with pytest.raises(RuntimeError): + with _nonfinite_as_cfitsio_floating_null_value(data, True) as ( + nan_data, + any_nan, + ): + assert any_nan + assert not np.any(np.isnan(nan_data)) + msk = ~np.isfinite(data) + np.testing.assert_array_equal(data[msk], _FLOATING_NULL_VALUE) + np.testing.assert_array_equal(data[~msk], nan_data[~msk]) + raise RuntimeError("Exception raised while data is modified!") + + assert data[1, 7] == np.inf + assert data[1, 9] == -np.inf diff --git a/fitsio/tests/test_warnings.py b/fitsio/tests/test_warnings.py new file mode 100644 index 0000000..3a6d50b --- /dev/null +++ b/fitsio/tests/test_warnings.py @@ -0,0 +1,42 @@ +import os +import tempfile +import warnings +import numpy as np +from ..fitslib import FITS +from ..util import FITSRuntimeWarning + + +def test_non_standard_key_value(): + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'test.fits') + + im = np.zeros((3, 3)) + with warnings.catch_warnings(record=True) as w: + with FITS(fname, 'rw') as fits: + fits.write(im) + + # now write a key with a non-standard value + value = {'test': 3} + fits[-1].write_key('odd', value) + + # DeprecationWarnings have crept into the Warning list. This will + # filter the list to be just + # FITSRuntimeWarning instances. + # @at88mph 2019.10.09 + filtered_warnings = list( + filter( + lambda x: 'FITSRuntimeWarning' in '{}'.format(x.category), + w, + ) # noqa + ) + + assert len(filtered_warnings) == 1, ( + 'Wrong length of output (Expected {} but got {}.)'.format( + 1, + len(filtered_warnings), + ) + ) + assert issubclass( + filtered_warnings[-1].category, + FITSRuntimeWarning, + ) diff --git a/fitsio/util.py b/fitsio/util.py new file mode 100644 index 0000000..197c6bd --- /dev/null +++ b/fitsio/util.py @@ -0,0 +1,221 @@ +""" +utilities for the fits library +""" + +from contextlib import contextmanager +import sys +import numpy + +from . import _fitsio_wrap + +if sys.version_info >= (3, 0, 0): + IS_PY3 = True +else: + IS_PY3 = False + +_FLOATING_NULL_VALUE = _fitsio_wrap.cfitsio_null_value_for_nan() + + +class FITSRuntimeWarning(RuntimeWarning): + pass + + +def cfitsio_version(asfloat=False): + """ + Return the cfitsio version as a string. + """ + # use string version to avoid roundoffs + ver = '%0.3f' % _fitsio_wrap.cfitsio_version() + if asfloat: + return float(ver) + else: + return ver + + +def cfitsio_is_bundled(): + """Return True if library was built with a + bundled copy of cfitsio. + """ + return _fitsio_wrap.cfitsio_is_bundled() + + +if sys.version_info > (3, 0, 0): + _itypes = (int,) + _stypes = (str, bytes) +else: + _itypes = (int, long) # noqa - only for py2 + _stypes = ( + basestring, # noqa - only for py2 + unicode, # noqa - only for py2 + ) # noqa - only for py2 + +_itypes += ( + numpy.uint8, + numpy.int8, + numpy.uint16, + numpy.int16, + numpy.uint32, + numpy.int32, + numpy.uint64, + numpy.int64, +) + +# different for py3 +if numpy.lib.NumpyVersion(numpy.__version__) < "1.28.0": + _stypes += ( + numpy.string_, + numpy.str_, + ) +else: + _stypes += ( + numpy.bytes_, + numpy.str_, + ) + +# for header keywords +_ftypes = (float, numpy.float32, numpy.float64) + + +def isstring(arg): + return isinstance(arg, _stypes) + + +def isinteger(arg): + return isinstance(arg, _itypes) + + +def is_object(arr): + if arr.dtype.descr[0][1][1] == 'O': + return True + else: + return False + + +def fields_are_object(arr): + isobj = numpy.zeros(len(arr.dtype.names), dtype=bool) + for i, name in enumerate(arr.dtype.names): + if is_object(arr[name]): + isobj[i] = True + return isobj + + +def is_little_endian(array): + """ + Return True if array is little endian, False otherwise. + + Parameters + ---------- + array: numpy array + A numerical python array. + + Returns + ------- + Truth value: + True for little-endian + + Notes + ----- + Strings are neither big or little endian. The input must be a simple numpy + array, not an array with fields. + """ + if numpy.little_endian: + machine_little = True + else: + machine_little = False + + byteorder = array.dtype.base.byteorder + return (byteorder == '<') or (machine_little and byteorder == '=') + + +def array_to_native(array, inplace=False): + """ + Convert an array to the native byte order. + + NOTE: the inplace keyword argument is not currently used. + """ + if numpy.little_endian: + machine_little = True + else: + machine_little = False + + data_little = False + if array.dtype.names is None: + if array.dtype.base.byteorder == '|': + # strings and 1 byte integers + return array + + data_little = is_little_endian(array) + else: + # assume all are same byte order: we only need to find one with + # little endian + for fname in array.dtype.names: + if is_little_endian(array[fname]): + data_little = True + break + + if (machine_little and not data_little) or ( + not machine_little and data_little + ): + output = array.byteswap(inplace) + else: + output = array + + return numpy.require(output, requirements=['ALIGNED']) + + +if numpy.lib.NumpyVersion(numpy.__version__) >= "2.0.0": + copy_if_needed = None +elif numpy.lib.NumpyVersion(numpy.__version__) < "1.28.0": + copy_if_needed = False +else: + # 2.0.0 dev versions, handle cases where copy may or may not exist + try: + numpy.array([1]).__array__(copy=None) + copy_if_needed = None + except TypeError: + copy_if_needed = False + + +def array_to_native_c(array_in, inplace=False): + # copy only made if not C order + arr = numpy.require( + array_in, + requirements=['C_CONTIGUOUS', 'ALIGNED'], + ) + return array_to_native(arr, inplace=inplace) + + +def mks(val): + """ + make sure the value is a string, paying mind to python3 vs 2 + """ + if sys.version_info > (3, 0, 0): + if isinstance(val, bytes): + sval = str(val, 'utf-8') + else: + sval = str(val) + else: + sval = str(val) + + return sval + + +@contextmanager +def _nonfinite_as_cfitsio_floating_null_value(data, target_hdu_compressed): + try: + has_nonfinite = False + if ( + data is not None + and data.dtype.kind == "f" + and target_hdu_compressed + ): + msk_nonfinite = ~numpy.isfinite(data) + if numpy.any(msk_nonfinite): + has_nonfinite = True + old_vals = data[msk_nonfinite] + data[msk_nonfinite] = _FLOATING_NULL_VALUE + + yield data, has_nonfinite + finally: + if has_nonfinite: + data[msk_nonfinite] = old_vals diff --git a/patches/Makefile.am.patch b/patches/Makefile.am.patch new file mode 100644 index 0000000..be193b2 --- /dev/null +++ b/patches/Makefile.am.patch @@ -0,0 +1,14 @@ +--- a/cfitsio-4.6.3/Makefile.am ++++ b/cfitsio-4.6.3/Makefile.am +@@ -82,7 +82,10 @@ libcfitsio_la_SOURCES = \ + fits_hdecompress.c \ + simplerng.c \ + zcompress.c \ +- zuncompress.c ++ zuncompress.c \ ++ adler32.c crc32.c deflate.c infback.c \ ++ inffast.c inflate.c inftrees.c trees.c \ ++ uncompr.c zutil.c + + if !NOFORTRAN + libcfitsio_la_SOURCES += $(F77_WRAPPERS) diff --git a/patches/Makefile.in.patch b/patches/Makefile.in.patch new file mode 100644 index 0000000..09acae6 --- /dev/null +++ b/patches/Makefile.in.patch @@ -0,0 +1,267 @@ +--- a/cfitsio-4.6.3/Makefile.in ++++ b/cfitsio-4.6.3/Makefile.in +@@ -166,8 +166,9 @@ am__libcfitsio_la_SOURCES_DIST = buffers.c cfileio.c checksum.c \ + putcoluj.c putkey.c region.c scalnull.c swapproc.c wcssub.c \ + wcsutil.c imcompress.c quantize.c ricecomp.c pliocomp.c \ + fits_hcompress.c fits_hdecompress.c simplerng.c zcompress.c \ +- zuncompress.c f77_wrap1.c f77_wrap2.c f77_wrap3.c f77_wrap4.c \ +- drvrgsiftp.c ++ zuncompress.c adler32.c crc32.c deflate.c infback.c inffast.c \ ++ inflate.c inftrees.c trees.c uncompr.c zutil.c f77_wrap1.c \ ++ f77_wrap2.c f77_wrap3.c f77_wrap4.c drvrgsiftp.c + am__objects_1 = libcfitsio_la-f77_wrap1.lo libcfitsio_la-f77_wrap2.lo \ + libcfitsio_la-f77_wrap3.lo libcfitsio_la-f77_wrap4.lo + @NOFORTRAN_FALSE@am__objects_2 = $(am__objects_1) +@@ -204,6 +205,11 @@ am_libcfitsio_la_OBJECTS = libcfitsio_la-buffers.lo \ + libcfitsio_la-pliocomp.lo libcfitsio_la-fits_hcompress.lo \ + libcfitsio_la-fits_hdecompress.lo libcfitsio_la-simplerng.lo \ + libcfitsio_la-zcompress.lo libcfitsio_la-zuncompress.lo \ ++ libcfitsio_la-adler32.lo libcfitsio_la-crc32.lo \ ++ libcfitsio_la-deflate.lo libcfitsio_la-infback.lo \ ++ libcfitsio_la-inffast.lo libcfitsio_la-inflate.lo \ ++ libcfitsio_la-inftrees.lo libcfitsio_la-trees.lo \ ++ libcfitsio_la-uncompr.lo libcfitsio_la-zutil.lo \ + $(am__objects_2) $(am__objects_4) + libcfitsio_la_OBJECTS = $(am_libcfitsio_la_OBJECTS) + AM_V_lt = $(am__v_lt_@AM_V@) +@@ -279,9 +285,12 @@ am__v_at_1 = + DEFAULT_INCLUDES = -I.@am__isrc@ + depcomp = $(SHELL) $(top_srcdir)/config/depcomp + am__maybe_remake_depfiles = depfiles +-am__depfiles_remade = ./$(DEPDIR)/libcfitsio_la-buffers.Plo \ ++am__depfiles_remade = ./$(DEPDIR)/libcfitsio_la-adler32.Plo \ ++ ./$(DEPDIR)/libcfitsio_la-buffers.Plo \ + ./$(DEPDIR)/libcfitsio_la-cfileio.Plo \ + ./$(DEPDIR)/libcfitsio_la-checksum.Plo \ ++ ./$(DEPDIR)/libcfitsio_la-crc32.Plo \ ++ ./$(DEPDIR)/libcfitsio_la-deflate.Plo \ + ./$(DEPDIR)/libcfitsio_la-drvrfile.Plo \ + ./$(DEPDIR)/libcfitsio_la-drvrgsiftp.Plo \ + ./$(DEPDIR)/libcfitsio_la-drvrmem.Plo \ +@@ -317,6 +326,10 @@ am__depfiles_remade = ./$(DEPDIR)/libcfitsio_la-buffers.Plo \ + ./$(DEPDIR)/libcfitsio_la-grparser.Plo \ + ./$(DEPDIR)/libcfitsio_la-histo.Plo \ + ./$(DEPDIR)/libcfitsio_la-imcompress.Plo \ ++ ./$(DEPDIR)/libcfitsio_la-infback.Plo \ ++ ./$(DEPDIR)/libcfitsio_la-inffast.Plo \ ++ ./$(DEPDIR)/libcfitsio_la-inflate.Plo \ ++ ./$(DEPDIR)/libcfitsio_la-inftrees.Plo \ + ./$(DEPDIR)/libcfitsio_la-iraffits.Plo \ + ./$(DEPDIR)/libcfitsio_la-modkey.Plo \ + ./$(DEPDIR)/libcfitsio_la-pliocomp.Plo \ +@@ -341,10 +354,13 @@ am__depfiles_remade = ./$(DEPDIR)/libcfitsio_la-buffers.Plo \ + ./$(DEPDIR)/libcfitsio_la-scalnull.Plo \ + ./$(DEPDIR)/libcfitsio_la-simplerng.Plo \ + ./$(DEPDIR)/libcfitsio_la-swapproc.Plo \ ++ ./$(DEPDIR)/libcfitsio_la-trees.Plo \ ++ ./$(DEPDIR)/libcfitsio_la-uncompr.Plo \ + ./$(DEPDIR)/libcfitsio_la-wcssub.Plo \ + ./$(DEPDIR)/libcfitsio_la-wcsutil.Plo \ + ./$(DEPDIR)/libcfitsio_la-zcompress.Plo \ + ./$(DEPDIR)/libcfitsio_la-zuncompress.Plo \ ++ ./$(DEPDIR)/libcfitsio_la-zutil.Plo \ + utilities/$(DEPDIR)/cookbook.Po \ + utilities/$(DEPDIR)/fitscopy.Po \ + utilities/$(DEPDIR)/fitsverify-ftverify.Po \ +@@ -616,7 +632,9 @@ libcfitsio_la_SOURCES = buffers.c cfileio.c checksum.c drvrfile.c \ + putkey.c region.c scalnull.c swapproc.c wcssub.c wcsutil.c \ + imcompress.c quantize.c ricecomp.c pliocomp.c fits_hcompress.c \ + fits_hdecompress.c simplerng.c zcompress.c zuncompress.c \ +- $(am__append_1) $(am__append_2) ++ adler32.c crc32.c deflate.c infback.c inffast.c inflate.c \ ++ inftrees.c trees.c uncompr.c zutil.c $(am__append_1) \ ++ $(am__append_2) + libcfitsio_la_CFLAGS = $(AM_CFLAGS) @DEFS@ + libcfitsio_swapproc_la_CFLAGS = $(libcfitsio_la_CFLAGS) @SSE_FLAGS@ + libcfitsio_la_LIBADD = -lm ${LIBS_CURL} ${LIBS} +@@ -878,9 +896,12 @@ mostlyclean-compile: + distclean-compile: + -rm -f *.tab.c + ++@AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-adler32.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-buffers.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-cfileio.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-checksum.Plo@am__quote@ # am--include-marker ++@AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-crc32.Plo@am__quote@ # am--include-marker ++@AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-deflate.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-drvrfile.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-drvrgsiftp.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-drvrmem.Plo@am__quote@ # am--include-marker +@@ -916,6 +937,10 @@ distclean-compile: + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-grparser.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-histo.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-imcompress.Plo@am__quote@ # am--include-marker ++@AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-infback.Plo@am__quote@ # am--include-marker ++@AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-inffast.Plo@am__quote@ # am--include-marker ++@AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-inflate.Plo@am__quote@ # am--include-marker ++@AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-inftrees.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-iraffits.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-modkey.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-pliocomp.Plo@am__quote@ # am--include-marker +@@ -940,10 +965,13 @@ distclean-compile: + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-scalnull.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-simplerng.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-swapproc.Plo@am__quote@ # am--include-marker ++@AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-trees.Plo@am__quote@ # am--include-marker ++@AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-uncompr.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-wcssub.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-wcsutil.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-zcompress.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-zuncompress.Plo@am__quote@ # am--include-marker ++@AMDEP_TRUE@@am__include@ @am__quote@./$(DEPDIR)/libcfitsio_la-zutil.Plo@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@utilities/$(DEPDIR)/cookbook.Po@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@utilities/$(DEPDIR)/fitscopy.Po@am__quote@ # am--include-marker + @AMDEP_TRUE@@am__include@ @am__quote@utilities/$(DEPDIR)/fitsverify-ftverify.Po@am__quote@ # am--include-marker +@@ -1417,6 +1445,76 @@ libcfitsio_la-zuncompress.lo: zuncompress.c + @AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ + @am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -c -o libcfitsio_la-zuncompress.lo `test -f 'zuncompress.c' || echo '$(srcdir)/'`zuncompress.c + ++libcfitsio_la-adler32.lo: adler32.c ++@am__fastdepCC_TRUE@ $(AM_V_CC)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -MT libcfitsio_la-adler32.lo -MD -MP -MF $(DEPDIR)/libcfitsio_la-adler32.Tpo -c -o libcfitsio_la-adler32.lo `test -f 'adler32.c' || echo '$(srcdir)/'`adler32.c ++@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) $(DEPDIR)/libcfitsio_la-adler32.Tpo $(DEPDIR)/libcfitsio_la-adler32.Plo ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='adler32.c' object='libcfitsio_la-adler32.lo' libtool=yes @AMDEPBACKSLASH@ ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ ++@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -c -o libcfitsio_la-adler32.lo `test -f 'adler32.c' || echo '$(srcdir)/'`adler32.c ++ ++libcfitsio_la-crc32.lo: crc32.c ++@am__fastdepCC_TRUE@ $(AM_V_CC)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -MT libcfitsio_la-crc32.lo -MD -MP -MF $(DEPDIR)/libcfitsio_la-crc32.Tpo -c -o libcfitsio_la-crc32.lo `test -f 'crc32.c' || echo '$(srcdir)/'`crc32.c ++@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) $(DEPDIR)/libcfitsio_la-crc32.Tpo $(DEPDIR)/libcfitsio_la-crc32.Plo ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='crc32.c' object='libcfitsio_la-crc32.lo' libtool=yes @AMDEPBACKSLASH@ ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ ++@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -c -o libcfitsio_la-crc32.lo `test -f 'crc32.c' || echo '$(srcdir)/'`crc32.c ++ ++libcfitsio_la-deflate.lo: deflate.c ++@am__fastdepCC_TRUE@ $(AM_V_CC)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -MT libcfitsio_la-deflate.lo -MD -MP -MF $(DEPDIR)/libcfitsio_la-deflate.Tpo -c -o libcfitsio_la-deflate.lo `test -f 'deflate.c' || echo '$(srcdir)/'`deflate.c ++@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) $(DEPDIR)/libcfitsio_la-deflate.Tpo $(DEPDIR)/libcfitsio_la-deflate.Plo ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='deflate.c' object='libcfitsio_la-deflate.lo' libtool=yes @AMDEPBACKSLASH@ ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ ++@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -c -o libcfitsio_la-deflate.lo `test -f 'deflate.c' || echo '$(srcdir)/'`deflate.c ++ ++libcfitsio_la-infback.lo: infback.c ++@am__fastdepCC_TRUE@ $(AM_V_CC)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -MT libcfitsio_la-infback.lo -MD -MP -MF $(DEPDIR)/libcfitsio_la-infback.Tpo -c -o libcfitsio_la-infback.lo `test -f 'infback.c' || echo '$(srcdir)/'`infback.c ++@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) $(DEPDIR)/libcfitsio_la-infback.Tpo $(DEPDIR)/libcfitsio_la-infback.Plo ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='infback.c' object='libcfitsio_la-infback.lo' libtool=yes @AMDEPBACKSLASH@ ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ ++@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -c -o libcfitsio_la-infback.lo `test -f 'infback.c' || echo '$(srcdir)/'`infback.c ++ ++libcfitsio_la-inffast.lo: inffast.c ++@am__fastdepCC_TRUE@ $(AM_V_CC)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -MT libcfitsio_la-inffast.lo -MD -MP -MF $(DEPDIR)/libcfitsio_la-inffast.Tpo -c -o libcfitsio_la-inffast.lo `test -f 'inffast.c' || echo '$(srcdir)/'`inffast.c ++@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) $(DEPDIR)/libcfitsio_la-inffast.Tpo $(DEPDIR)/libcfitsio_la-inffast.Plo ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='inffast.c' object='libcfitsio_la-inffast.lo' libtool=yes @AMDEPBACKSLASH@ ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ ++@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -c -o libcfitsio_la-inffast.lo `test -f 'inffast.c' || echo '$(srcdir)/'`inffast.c ++ ++libcfitsio_la-inflate.lo: inflate.c ++@am__fastdepCC_TRUE@ $(AM_V_CC)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -MT libcfitsio_la-inflate.lo -MD -MP -MF $(DEPDIR)/libcfitsio_la-inflate.Tpo -c -o libcfitsio_la-inflate.lo `test -f 'inflate.c' || echo '$(srcdir)/'`inflate.c ++@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) $(DEPDIR)/libcfitsio_la-inflate.Tpo $(DEPDIR)/libcfitsio_la-inflate.Plo ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='inflate.c' object='libcfitsio_la-inflate.lo' libtool=yes @AMDEPBACKSLASH@ ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ ++@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -c -o libcfitsio_la-inflate.lo `test -f 'inflate.c' || echo '$(srcdir)/'`inflate.c ++ ++libcfitsio_la-inftrees.lo: inftrees.c ++@am__fastdepCC_TRUE@ $(AM_V_CC)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -MT libcfitsio_la-inftrees.lo -MD -MP -MF $(DEPDIR)/libcfitsio_la-inftrees.Tpo -c -o libcfitsio_la-inftrees.lo `test -f 'inftrees.c' || echo '$(srcdir)/'`inftrees.c ++@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) $(DEPDIR)/libcfitsio_la-inftrees.Tpo $(DEPDIR)/libcfitsio_la-inftrees.Plo ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='inftrees.c' object='libcfitsio_la-inftrees.lo' libtool=yes @AMDEPBACKSLASH@ ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ ++@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -c -o libcfitsio_la-inftrees.lo `test -f 'inftrees.c' || echo '$(srcdir)/'`inftrees.c ++ ++libcfitsio_la-trees.lo: trees.c ++@am__fastdepCC_TRUE@ $(AM_V_CC)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -MT libcfitsio_la-trees.lo -MD -MP -MF $(DEPDIR)/libcfitsio_la-trees.Tpo -c -o libcfitsio_la-trees.lo `test -f 'trees.c' || echo '$(srcdir)/'`trees.c ++@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) $(DEPDIR)/libcfitsio_la-trees.Tpo $(DEPDIR)/libcfitsio_la-trees.Plo ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='trees.c' object='libcfitsio_la-trees.lo' libtool=yes @AMDEPBACKSLASH@ ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ ++@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -c -o libcfitsio_la-trees.lo `test -f 'trees.c' || echo '$(srcdir)/'`trees.c ++ ++libcfitsio_la-uncompr.lo: uncompr.c ++@am__fastdepCC_TRUE@ $(AM_V_CC)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -MT libcfitsio_la-uncompr.lo -MD -MP -MF $(DEPDIR)/libcfitsio_la-uncompr.Tpo -c -o libcfitsio_la-uncompr.lo `test -f 'uncompr.c' || echo '$(srcdir)/'`uncompr.c ++@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) $(DEPDIR)/libcfitsio_la-uncompr.Tpo $(DEPDIR)/libcfitsio_la-uncompr.Plo ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='uncompr.c' object='libcfitsio_la-uncompr.lo' libtool=yes @AMDEPBACKSLASH@ ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ ++@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -c -o libcfitsio_la-uncompr.lo `test -f 'uncompr.c' || echo '$(srcdir)/'`uncompr.c ++ ++libcfitsio_la-zutil.lo: zutil.c ++@am__fastdepCC_TRUE@ $(AM_V_CC)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -MT libcfitsio_la-zutil.lo -MD -MP -MF $(DEPDIR)/libcfitsio_la-zutil.Tpo -c -o libcfitsio_la-zutil.lo `test -f 'zutil.c' || echo '$(srcdir)/'`zutil.c ++@am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) $(DEPDIR)/libcfitsio_la-zutil.Tpo $(DEPDIR)/libcfitsio_la-zutil.Plo ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ $(AM_V_CC)source='zutil.c' object='libcfitsio_la-zutil.lo' libtool=yes @AMDEPBACKSLASH@ ++@AMDEP_TRUE@@am__fastdepCC_FALSE@ DEPDIR=$(DEPDIR) $(CCDEPMODE) $(depcomp) @AMDEPBACKSLASH@ ++@am__fastdepCC_FALSE@ $(AM_V_CC@am__nodep@)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -c -o libcfitsio_la-zutil.lo `test -f 'zutil.c' || echo '$(srcdir)/'`zutil.c ++ + libcfitsio_la-f77_wrap1.lo: f77_wrap1.c + @am__fastdepCC_TRUE@ $(AM_V_CC)$(LIBTOOL) $(AM_V_lt) --tag=CC $(AM_LIBTOOLFLAGS) $(LIBTOOLFLAGS) --mode=compile $(CC) $(DEFS) $(DEFAULT_INCLUDES) $(INCLUDES) $(AM_CPPFLAGS) $(CPPFLAGS) $(libcfitsio_la_CFLAGS) $(CFLAGS) -MT libcfitsio_la-f77_wrap1.lo -MD -MP -MF $(DEPDIR)/libcfitsio_la-f77_wrap1.Tpo -c -o libcfitsio_la-f77_wrap1.lo `test -f 'f77_wrap1.c' || echo '$(srcdir)/'`f77_wrap1.c + @am__fastdepCC_TRUE@ $(AM_V_at)$(am__mv) $(DEPDIR)/libcfitsio_la-f77_wrap1.Tpo $(DEPDIR)/libcfitsio_la-f77_wrap1.Plo +@@ -1875,9 +1973,12 @@ clean-am: clean-binPROGRAMS clean-generic clean-libLTLIBRARIES \ + + distclean: distclean-am + -rm -f $(am__CONFIG_DISTCLEAN_FILES) ++ -rm -f ./$(DEPDIR)/libcfitsio_la-adler32.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-buffers.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-cfileio.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-checksum.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-crc32.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-deflate.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-drvrfile.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-drvrgsiftp.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-drvrmem.Plo +@@ -1913,6 +2014,10 @@ distclean: distclean-am + -rm -f ./$(DEPDIR)/libcfitsio_la-grparser.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-histo.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-imcompress.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-infback.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-inffast.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-inflate.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-inftrees.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-iraffits.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-modkey.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-pliocomp.Plo +@@ -1937,10 +2042,13 @@ distclean: distclean-am + -rm -f ./$(DEPDIR)/libcfitsio_la-scalnull.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-simplerng.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-swapproc.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-trees.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-uncompr.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-wcssub.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-wcsutil.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-zcompress.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-zuncompress.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-zutil.Plo + -rm -f utilities/$(DEPDIR)/cookbook.Po + -rm -f utilities/$(DEPDIR)/fitscopy.Po + -rm -f utilities/$(DEPDIR)/fitsverify-ftverify.Po +@@ -2004,9 +2112,12 @@ installcheck-am: + maintainer-clean: maintainer-clean-am + -rm -f $(am__CONFIG_DISTCLEAN_FILES) + -rm -rf $(top_srcdir)/autom4te.cache ++ -rm -f ./$(DEPDIR)/libcfitsio_la-adler32.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-buffers.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-cfileio.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-checksum.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-crc32.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-deflate.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-drvrfile.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-drvrgsiftp.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-drvrmem.Plo +@@ -2042,6 +2153,10 @@ maintainer-clean: maintainer-clean-am + -rm -f ./$(DEPDIR)/libcfitsio_la-grparser.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-histo.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-imcompress.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-infback.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-inffast.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-inflate.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-inftrees.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-iraffits.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-modkey.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-pliocomp.Plo +@@ -2066,10 +2181,13 @@ maintainer-clean: maintainer-clean-am + -rm -f ./$(DEPDIR)/libcfitsio_la-scalnull.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-simplerng.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-swapproc.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-trees.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-uncompr.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-wcssub.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-wcsutil.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-zcompress.Plo + -rm -f ./$(DEPDIR)/libcfitsio_la-zuncompress.Plo ++ -rm -f ./$(DEPDIR)/libcfitsio_la-zutil.Plo + -rm -f utilities/$(DEPDIR)/cookbook.Po + -rm -f utilities/$(DEPDIR)/fitscopy.Po + -rm -f utilities/$(DEPDIR)/fitsverify-ftverify.Po diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 0000000..fc4c1d2 --- /dev/null +++ b/patches/README.md @@ -0,0 +1,26 @@ +# Patches for cfitsio + +This directory contains patches for the cfitsio build. These patches +are applied before the library is compiled during the python package +build step. + +The patches were generated with the script `build_cfitsio_patches.py` by +Matthew Becker in December of 2018. + +## Adding New Patches + +To add new patches, you need to + +1. Make a copy of the file you want to patch. +2. Modify it. +3. Call `diff -u old_file new_file` to a get a unified format patch. +4. Make sure the paths in the patch at the top look like this + ``` + --- cfitsio/ 2018-03-01 10:28:51.000000000 -0600 + +++ cfitsio/ 2018-12-14 08:39:20.000000000 -0600 + ... + ``` + where `` and `` have a cfitsio version and + file that is being patched. + +5. Commit the patch file in the patches directory with the name `.patch`. diff --git a/patches/build_cfitsio_patches.py b/patches/build_cfitsio_patches.py new file mode 100644 index 0000000..5d7889c --- /dev/null +++ b/patches/build_cfitsio_patches.py @@ -0,0 +1,35 @@ +import os +import argparse + + +def get_args(): + parser = argparse.ArgumentParser() + parser.add_argument('--moddir', required=True, + help='directory containing modified files') + parser.add_argument('--dir', required=True, + help='directory containing unmodified files') + parser.add_argument('--patch-dir', required=True) + return parser.parse_args() + + +def main(): + args = get_args() + + os.makedirs(args.patch_dir, exist_ok=True) + + for root, _, files in os.walk(args.dir): + for fname in files: + src = os.path.join(args.dir, fname) + dst = os.path.join(args.moddir, fname) + patch = os.path.join(args.patch_dir, fname + '.patch') + os.system('diff -u %s %s > %s' % (src, dst, patch)) + with open(patch, 'rb') as fp: + buff = fp.read() + if len(buff) == 0: + os.remove(patch) + else: + print(fname) + break + + +main() diff --git a/patches/configure.ac.patch b/patches/configure.ac.patch new file mode 100644 index 0000000..75f6900 --- /dev/null +++ b/patches/configure.ac.patch @@ -0,0 +1,21 @@ +--- cfitsio-4.2.0/configure.ac 2022-10-31 14:40:23.000000000 -0400 ++++ cfitsio-4.2.0/configure.ac 2023-07-14 11:45:00.797390794 -0400 +@@ -170,11 +170,16 @@ AC_ARG_WITH( + [AS_HELP_STRING([--with-bzip2[[=PATH]]],[Enable bzip2 support. Optional path to the location of include/bzlib.h and lib/libbz2])], + [ if test "x$withval" != "xno"; then + if test "x$withval" = "xyes" ; then +- AC_CHECK_LIB([bz2],[main],[],[AC_MSG_ERROR(Unable to locate bz2 library needed when enabling bzip2 support; try specifying the path)]) ++ AC_CHECK_LIB( ++ [bz2], ++ [main], ++ [LIBS="$LIBS -lbz2"; AC_DEFINE(HAVE_LIBBZ2) AC_CHECK_HEADERS(bzlib.h,[AC_DEFINE(HAVE_BZIP2,1,[Define if you want bzip2 read support])])], ++ [] ++ ) + else + BZIP2_PATH="${withval}" ++ AC_CHECK_HEADERS(bzlib.h,[AC_DEFINE(HAVE_BZIP2,1,[Define if you want bzip2 read support])]) + fi +- AC_CHECK_HEADERS(bzlib.h,[AC_DEFINE(HAVE_BZIP2,1,[Define if you want bzip2 read support])]) + fi + ] + ) diff --git a/patches/configure.patch b/patches/configure.patch new file mode 100644 index 0000000..01c5372 --- /dev/null +++ b/patches/configure.patch @@ -0,0 +1,42 @@ +--- cfitsio-4.2.0/configure 2022-10-31 14:40:23.000000000 -0400 ++++ cfitsio-4.2.0/configure 2023-07-14 11:46:53.298055665 -0400 +@@ -15493,19 +15493,24 @@ fi + printf "%s\n" "$ac_cv_lib_bz2_main" >&6; } + if test "x$ac_cv_lib_bz2_main" = xyes + then : +- printf "%s\n" "#define HAVE_LIBBZ2 1" >>confdefs.h ++ LIBS="$LIBS -lbz2"; printf "%s\n" "#define HAVE_LIBBZ2 1" >>confdefs.h ++ for ac_header in bzlib.h ++do : ++ ac_fn_c_check_header_compile "$LINENO" "bzlib.h" "ac_cv_header_bzlib_h" "$ac_includes_default" ++if test "x$ac_cv_header_bzlib_h" = xyes ++then : ++ printf "%s\n" "#define HAVE_BZLIB_H 1" >>confdefs.h + +- LIBS="-lbz2 $LIBS" ++printf "%s\n" "#define HAVE_BZIP2 1" >>confdefs.h + +-else case e in #( +- e) as_fn_error $? "Unable to locate bz2 library needed when enabling bzip2 support; try specifying the path" "$LINENO" 5 ;; +-esac ++fi ++ ++done + fi + + else + BZIP2_PATH="${withval}" +- fi +- for ac_header in bzlib.h ++ for ac_header in bzlib.h + do : + ac_fn_c_check_header_compile "$LINENO" "bzlib.h" "ac_cv_header_bzlib_h" "$ac_includes_default" + if test "x$ac_cv_header_bzlib_h" = xyes +@@ -15517,6 +15522,7 @@ printf "%s\n" "#define HAVE_BZIP2 1" >>confdefs.h + fi + + done ++ fi + fi + + diff --git a/patches/fitsio2.h.patch b/patches/fitsio2.h.patch new file mode 100644 index 0000000..f56d7c6 --- /dev/null +++ b/patches/fitsio2.h.patch @@ -0,0 +1,21 @@ +--- cfitsio-4.2.0/fitsio2.h 2023-08-09 10:23:45.508392645 +0800 ++++ cfitsio-4.2.0/fitsio2.h 2023-08-09 10:29:44.960511085 +0800 +@@ -151,6 +151,18 @@ + # error "can't handle long size given by __riscv_xlen" + # endif + ++#elif defined(__loongarch__) ++ ++#define BYTESWAPPED TRUE ++ ++# if __loongarch_grlen == 32 ++# define LONGSIZE 32 ++# elif __loongarch_grlen == 64 ++# define LONGSIZE 64 ++# else ++# error "can't handle long size given by __loongarch_grlen" ++# endif ++ + /* ============================================================== */ + /* the following are all 32-bit byteswapped platforms */ + diff --git a/patches/getcold.c.patch b/patches/getcold.c.patch new file mode 100644 index 0000000..c55918c --- /dev/null +++ b/patches/getcold.c.patch @@ -0,0 +1,38 @@ +--- a/getcold.c ++++ b/getcold.c +@@ -1418,7 +1418,7 @@ int fffr4r8(float *input, /* I - array of values to be converted */ + nullarray[ii] = 1; + } + else /* it's an underflow */ +- output[ii] = 0; ++ output[ii] = (double) input[ii]; + } + else + output[ii] = (double) input[ii]; +@@ -1439,7 +1439,7 @@ int fffr4r8(float *input, /* I - array of values to be converted */ + nullarray[ii] = 1; + } + else /* it's an underflow */ +- output[ii] = zero; ++ output[ii] = input[ii] * scale + zero; + } + else + output[ii] = input[ii] * scale + zero; +@@ -1519,7 +1519,7 @@ int fffr8r8(double *input, /* I - array of values to be converted */ + } + } + else /* it's an underflow */ +- output[ii] = 0; ++ output[ii] = input[ii]; + } + else + output[ii] = input[ii]; +@@ -1544,7 +1544,7 @@ int fffr8r8(double *input, /* I - array of values to be converted */ + } + } + else /* it's an underflow */ +- output[ii] = zero; ++ output[ii] = input[ii] * scale + zero; + } + else + output[ii] = input[ii] * scale + zero; diff --git a/patches/getcole.c.patch b/patches/getcole.c.patch new file mode 100644 index 0000000..bc850e3 --- /dev/null +++ b/patches/getcole.c.patch @@ -0,0 +1,40 @@ +--- a/getcole.c ++++ b/getcole.c +@@ -1425,7 +1425,7 @@ int fffr4r4(float *input, /* I - array of values to be converted */ + } + } + else /* it's an underflow */ +- output[ii] = 0; ++ output[ii] = input[ii]; + } + else + output[ii] = input[ii]; +@@ -1450,7 +1450,7 @@ int fffr4r4(float *input, /* I - array of values to be converted */ + } + } + else /* it's an underflow */ +- output[ii] = (float) zero; ++ output[ii] = (float) (input[ii] * scale + zero); + } + else + output[ii] = (float) (input[ii] * scale + zero); +@@ -1549,8 +1549,8 @@ int fffr8r4(double *input, /* I - array of values to be converted */ + else + nullarray[ii] = 1; + } +- else /* it's an underflow */ +- output[ii] = 0; ++ else ++ output[ii] = (float) input[ii]; + } + else + { +@@ -1596,7 +1596,7 @@ int fffr8r4(double *input, /* I - array of values to be converted */ + output[ii] = FLT_MAX; + } + else +- output[ii] = (float) zero; ++ output[ii] = (float) (input[ii] * scale + zero); + } + } + else diff --git a/patches/imcompress.c.patch b/patches/imcompress.c.patch new file mode 100644 index 0000000..65a173f --- /dev/null +++ b/patches/imcompress.c.patch @@ -0,0 +1,213 @@ +--- a/imcompress.c ++++ b/imcompress.c +@@ -2217,6 +2217,17 @@ int imcomp_compress_tile (fitsfile *outfptr, + ffpclb(outfptr, (outfptr->Fptr)->cn_gzip_data, row, 1, + gzip_nelem, (unsigned char *) cbuf, status); + ++ /* we must zero out existing compressed data if it exists. */ ++ /* otherwise on read this data is read ahead of the gzipped */ ++ /* data and will cause a bug. */ ++ LONGLONG _test_nelemll, _test_offset; ++ ffgdesll(outfptr, (outfptr->Fptr)->cn_compressed, row, &_test_nelemll, &_test_offset, ++ status); ++ if (_test_nelemll) { ++ ffpclb(outfptr, (outfptr->Fptr)->cn_compressed, row, 1, ++ 0, NULL, status); ++ } ++ + free(cbuf); /* finished with this buffer */ + } + +@@ -6453,20 +6453,7 @@ int imcomp_decompress_tile (fitsfile *infptr, + { + pixlen = sizeof(short); + +- if ((infptr->Fptr)->quantize_level == NO_QUANTIZE) { +- /* the floating point pixels were losselessly compressed with GZIP */ +- /* Just have to copy the values to the output array */ +- +- if (tiledatatype == TINT) { +- fffr4i2((float *) idata, tilelen, bscale, bzero, nullcheck, +- *(short *) nulval, bnullarray, anynul, +- (short *) buffer, status); +- } else { +- fffr8i2((double *) idata, tilelen, bscale, bzero, nullcheck, +- *(short *) nulval, bnullarray, anynul, +- (short *) buffer, status); +- } +- } else if (tiledatatype == TINT) { ++ if (tiledatatype == TINT) { + if ((infptr->Fptr)->compress_type == PLIO_1 && actual_bzero == 32768.) { + /* special case where unsigned 16-bit integers have been */ + /* offset by +32768 when using PLIO */ +@@ -6500,26 +6487,17 @@ int imcomp_decompress_tile (fitsfile *infptr, + fffi1i2((unsigned char *)idata, tilelen, bscale, bzero, nullcheck, (unsigned char) tnull, + *(short *) nulval, bnullarray, anynul, + (short *) buffer, status); ++ } else { ++ fffi8i2((LONGLONG *) idata, tilelen, bscale, bzero, nullcheck, tnull, ++ *(short *) nulval, bnullarray, anynul, ++ (short *) buffer, status); + } + } + else if (datatype == TINT) + { + pixlen = sizeof(int); + +- if ((infptr->Fptr)->quantize_level == NO_QUANTIZE) { +- /* the floating point pixels were losselessly compressed with GZIP */ +- /* Just have to copy the values to the output array */ +- +- if (tiledatatype == TINT) { +- fffr4int((float *) idata, tilelen, bscale, bzero, nullcheck, +- *(int *) nulval, bnullarray, anynul, +- (int *) buffer, status); +- } else { +- fffr8int((double *) idata, tilelen, bscale, bzero, nullcheck, +- *(int *) nulval, bnullarray, anynul, +- (int *) buffer, status); +- } +- } else if (tiledatatype == TINT) ++ if (tiledatatype == TINT) + if ((infptr->Fptr)->compress_type == PLIO_1 && actual_bzero == 32768.) { + /* special case where unsigned 16-bit integers have been */ + /* offset by +32768 when using PLIO */ +@@ -6539,25 +6517,16 @@ int imcomp_decompress_tile (fitsfile *infptr, + fffi1int((unsigned char *)idata, tilelen, bscale, bzero, nullcheck, (unsigned char) tnull, + *(int *) nulval, bnullarray, anynul, + (int *) buffer, status); ++ else ++ fffi8int((LONGLONG *) idata, (long) tilelen, bscale, bzero, nullcheck, tnull, ++ *(int *) nulval, bnullarray, anynul, ++ (int *) buffer, status); + } + else if (datatype == TLONG) + { + pixlen = sizeof(long); + +- if ((infptr->Fptr)->quantize_level == NO_QUANTIZE) { +- /* the floating point pixels were losselessly compressed with GZIP */ +- /* Just have to copy the values to the output array */ +- +- if (tiledatatype == TINT) { +- fffr4i4((float *) idata, tilelen, bscale, bzero, nullcheck, +- *(long *) nulval, bnullarray, anynul, +- (long *) buffer, status); +- } else { +- fffr8i4((double *) idata, tilelen, bscale, bzero, nullcheck, +- *(long *) nulval, bnullarray, anynul, +- (long *) buffer, status); +- } +- } else if (tiledatatype == TINT) ++ if (tiledatatype == TINT) + if ((infptr->Fptr)->compress_type == PLIO_1 && actual_bzero == 32768.) { + /* special case where unsigned 16-bit integers have been */ + /* offset by +32768 when using PLIO */ +@@ -6577,6 +6546,11 @@ int imcomp_decompress_tile (fitsfile *infptr, + fffi1i4((unsigned char *)idata, tilelen, bscale, bzero, nullcheck, (unsigned char) tnull, + *(long *) nulval, bnullarray, anynul, + (long *) buffer, status); ++ else ++ fffi8i4((LONGLONG *) idata, tilelen, bscale, bzero, nullcheck, tnull, ++ *(long *) nulval, bnullarray, anynul, ++ (long *) buffer, status); ++ + } + else if (datatype == TFLOAT) + { +@@ -6745,20 +6719,7 @@ int imcomp_decompress_tile (fitsfile *infptr, + { + pixlen = sizeof(short); + +- if ((infptr->Fptr)->quantize_level == NO_QUANTIZE) { +- /* the floating point pixels were losselessly compressed with GZIP */ +- /* Just have to copy the values to the output array */ +- +- if (tiledatatype == TINT) { +- fffr4u2((float *) idata, tilelen, bscale, bzero, nullcheck, +- *(unsigned short *) nulval, bnullarray, anynul, +- (unsigned short *) buffer, status); +- } else { +- fffr8u2((double *) idata, tilelen, bscale, bzero, nullcheck, +- *(unsigned short *) nulval, bnullarray, anynul, +- (unsigned short *) buffer, status); +- } +- } else if (tiledatatype == TINT) ++ if (tiledatatype == TINT) + if ((infptr->Fptr)->compress_type == PLIO_1 && actual_bzero == 32768.) { + /* special case where unsigned 16-bit integers have been */ + /* offset by +32768 when using PLIO */ +@@ -6778,26 +6739,16 @@ int imcomp_decompress_tile (fitsfile *infptr, + fffi1u2((unsigned char *)idata, tilelen, bscale, bzero, nullcheck, (unsigned char) tnull, + *(unsigned short *) nulval, bnullarray, anynul, + (unsigned short *) buffer, status); ++ else ++ fffi8u2((LONGLONG *) idata, tilelen, bscale, bzero, nullcheck, tnull, ++ *(unsigned short *) nulval, bnullarray, anynul, ++ (unsigned short *) buffer, status); + } + else if (datatype == TUINT) + { + pixlen = sizeof(int); + +- if ((infptr->Fptr)->quantize_level == NO_QUANTIZE) { +- /* the floating point pixels were losselessly compressed with GZIP */ +- /* Just have to copy the values to the output array */ +- +- if (tiledatatype == TINT) { +- fffr4uint((float *) idata, tilelen, bscale, bzero, nullcheck, +- *(unsigned int *) nulval, bnullarray, anynul, +- (unsigned int *) buffer, status); +- } else { +- fffr8uint((double *) idata, tilelen, bscale, bzero, nullcheck, +- *(unsigned int *) nulval, bnullarray, anynul, +- (unsigned int *) buffer, status); +- } +- } else +- if (tiledatatype == TINT) ++ if (tiledatatype == TINT) + if ((infptr->Fptr)->compress_type == PLIO_1 && actual_bzero == 32768.) { + /* special case where unsigned 16-bit integers have been */ + /* offset by +32768 when using PLIO */ +@@ -6817,25 +6768,16 @@ int imcomp_decompress_tile (fitsfile *infptr, + fffi1uint((unsigned char *)idata, tilelen, bscale, bzero, nullcheck, (unsigned char) tnull, + *(unsigned int *) nulval, bnullarray, anynul, + (unsigned int *) buffer, status); ++ else ++ fffi8uint((LONGLONG *) idata, tilelen, bscale, bzero, nullcheck, tnull, ++ *(unsigned int *) nulval, bnullarray, anynul, ++ (unsigned int *) buffer, status); + } + else if (datatype == TULONG) + { + pixlen = sizeof(long); + +- if ((infptr->Fptr)->quantize_level == NO_QUANTIZE) { +- /* the floating point pixels were losselessly compressed with GZIP */ +- /* Just have to copy the values to the output array */ +- +- if (tiledatatype == TINT) { +- fffr4u4((float *) idata, tilelen, bscale, bzero, nullcheck, +- *(unsigned long *) nulval, bnullarray, anynul, +- (unsigned long *) buffer, status); +- } else { +- fffr8u4((double *) idata, tilelen, bscale, bzero, nullcheck, +- *(unsigned long *) nulval, bnullarray, anynul, +- (unsigned long *) buffer, status); +- } +- } else if (tiledatatype == TINT) ++ if (tiledatatype == TINT) + if ((infptr->Fptr)->compress_type == PLIO_1 && actual_bzero == 32768.) { + /* special case where unsigned 16-bit integers have been */ + /* offset by +32768 when using PLIO */ +@@ -6855,6 +6797,10 @@ int imcomp_decompress_tile (fitsfile *infptr, + fffi1u4((unsigned char *)idata, tilelen, bscale, bzero, nullcheck, (unsigned char) tnull, + *(unsigned long *) nulval, bnullarray, anynul, + (unsigned long *) buffer, status); ++ else ++ fffi8u4((LONGLONG *) idata, tilelen, bscale, bzero, nullcheck, tnull, ++ *(unsigned long *) nulval, bnullarray, anynul, ++ (unsigned long *) buffer, status); + } + else + *status = BAD_DATATYPE; diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..444b782 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,17 @@ +exclude = [ + ".git", + "build", + "dist", + "cfitsio-*", + "patches", + "zlib", + ".github", +] +line-length = 79 + +[lint] +select = ["E", "F", "W"] +preview = true + +[format] +quote-style = "preserve" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d185146 --- /dev/null +++ b/setup.py @@ -0,0 +1,485 @@ +# +# setup script for fitsio, using setuptools +# +# c.f. +# https://packaging.python.org/guides/distributing-packages-using-setuptools/ + +from __future__ import print_function +from setuptools import setup, Extension, find_packages +from setuptools.command.build_ext import build_ext + +import warnings +import tempfile +import tarfile +import sys +import os +import subprocess +from subprocess import PIPE +import glob +import shutil + +if "FITSIO_FAIL_ON_BAD_PATCHES" in os.environ: + if os.environ["FITSIO_FAIL_ON_BAD_PATCHES"].lower() in ["false", "0"]: + FITSIO_FAIL_ON_BAD_PATCHES = False + else: + FITSIO_FAIL_ON_BAD_PATCHES = True +else: + FITSIO_FAIL_ON_BAD_PATCHES = True + +if "--use-system-fitsio" in sys.argv: + del sys.argv[sys.argv.index("--use-system-fitsio")] + USE_SYSTEM_FITSIO = True +else: + USE_SYSTEM_FITSIO = False or "FITSIO_USE_SYSTEM_FITSIO" in os.environ + +if "--system-fitsio-includedir" in sys.argv or any( + a.startswith("--system-fitsio-includedir=") for a in sys.argv +): + if "--system-fitsio-includedir" in sys.argv: + ind = sys.argv.index("--system-fitsio-includedir") + SYSTEM_FITSIO_INCLUDEDIR = sys.argv[ind + 1] + del sys.argv[ind + 1] + del sys.argv[ind] + else: + for ind in range(len(sys.argv)): + if sys.argv[ind].startswith("--system-fitsio-includedir="): + break + SYSTEM_FITSIO_INCLUDEDIR = sys.argv[ind].split("=", 1)[1] + del sys.argv[ind] +else: + SYSTEM_FITSIO_INCLUDEDIR = os.environ.get( + "FITSIO_SYSTEM_FITSIO_INCLUDEDIR", + None, + ) + + +if "--system-fitsio-libdir" in sys.argv or any( + a.startswith("--system-fitsio-libdir=") for a in sys.argv +): + if "--system-fitsio-libdir" in sys.argv: + ind = sys.argv.index("--system-fitsio-libdir") + SYSTEM_FITSIO_LIBDIR = sys.argv[ind + 1] + del sys.argv[ind + 1] + del sys.argv[ind] + else: + for ind in range(len(sys.argv)): + if sys.argv[ind].startswith("--system-fitsio-libdir="): + break + SYSTEM_FITSIO_LIBDIR = sys.argv[ind].split("=", 1)[1] + del sys.argv[ind] +else: + SYSTEM_FITSIO_LIBDIR = os.environ.get( + "FITSIO_SYSTEM_FITSIO_LIBDIR", + None, + ) + + +def _print_msg(text): + print("\n" + "=" * 79 + f"\n{text}\n" + "=" * 79, flush=True) + + +class build_ext_subclass(build_ext): + cfitsio_version = '4.6.3' + cfitsio_dir = 'cfitsio-%s' % cfitsio_version + + def finalize_options(self): + build_ext.finalize_options(self) + + self.cfitsio_build_dir = os.path.join( + self.build_temp, self.cfitsio_dir + ) + self.cfitsio_zlib_dir = os.path.join(self.cfitsio_build_dir, 'zlib') + self.cfitsio_patch_dir = os.path.join(self.build_temp, 'patches') + + if USE_SYSTEM_FITSIO: + if SYSTEM_FITSIO_INCLUDEDIR is not None: + self.include_dirs.insert(0, SYSTEM_FITSIO_INCLUDEDIR) + if SYSTEM_FITSIO_LIBDIR is not None: + self.library_dirs.insert(0, SYSTEM_FITSIO_LIBDIR) + else: + # We defer configuration of the bundled cfitsio to build_extensions + # because we will know the compiler there. + self.include_dirs.insert(0, self.cfitsio_build_dir) + + def run(self): + # For extensions that require 'numpy' in their include dirs, + # replace 'numpy' with the actual paths + import numpy + + np_include = numpy.get_include() + + for extension in self.extensions: + if 'numpy' in extension.include_dirs: + idx = extension.include_dirs.index('numpy') + extension.include_dirs.insert(idx, np_include) + extension.include_dirs.remove('numpy') + + build_ext.run(self) + + def build_extensions(self): + if not USE_SYSTEM_FITSIO: + # Use the compiler for building python to build cfitsio + # for maximized compatibility. + + # turns out we need to set the include dirs here too + # directly for the compiler + self.compiler.include_dirs.insert(0, self.cfitsio_build_dir) + + CCold = self.compiler.compiler + if 'ccache' in CCold: + CC = [] + for val in CCold: + if val == 'ccache': + _print_msg("removing ccache from the compiler options") + continue + + CC.append(val) + else: + CC = None + + self.configure_cfitsio( + CC=CC, + ARCHIVE=self.compiler.archiver, + RANLIB=self.compiler.ranlib, + ) + + # If configure detected bzlib.h, we have to link to libbz2 + with open(os.path.join(self.cfitsio_build_dir, 'Makefile')) as fp: + _makefile = fp.read() + _have_bzip2 = False + _have_curl = False + for line in _makefile.splitlines(): + for _part in line.split("="): + for _eqpart in _part.split(): + if "-lbz2" in _eqpart: + _have_bzip2 = True + if "-lcurl" in _eqpart: + _have_curl = True + if _have_bzip2: + _print_msg( + "found -lbz2 in Makefile\n" + "linking Python extension to bzip2" + ) + self.compiler.add_library('bz2') + self.compiler.define_macro('FITSIO_HAS_BZIP2_SUPPORT') + else: + _print_msg( + "did not find -lbz2 in Makefile\n" + "bzip2 support is disabled" + ) + + if _have_curl: + _print_msg( + "found -lcurl in Makefile\n" + "linking Python extension to curl" + ) + self.compiler.add_library('curl') + self.compiler.define_macro('FITSIO_HAS_CURL_SUPPORT') + else: + _print_msg( + "did not find -lcurl in Makefile\n" + "curl support is disabled" + ) + + self.compile_cfitsio() + + # link against the .a library in cfitsio; + # It should have been a 'static' library of relocatable objects + # (-fPIC), since we use the python compiler flags + + link_objects = glob.glob( + os.path.join(self.cfitsio_build_dir, '*.o') + ) + + self.compiler.set_link_objects(link_objects) + + # Ultimate hack: append the .a files to the dependency list + # so they will be properly rebuild if cfitsio source is updated. + for ext in self.extensions: + ext.depends += link_objects + else: + self.compiler.add_library('cfitsio') + + # Check if system cfitsio was compiled with bzip2 and/or curl + if self.check_system_cfitsio_objects('bzip2'): + _print_msg( + "found bz2 symbol in system cfitsio library\n" + "linking Python extension to bzip2" + ) + self.compiler.add_library('bz2') + self.compiler.define_macro('FITSIO_HAS_BZIP2_SUPPORT') + else: + _print_msg( + "did not find bz2 symbol in system cfitsio library\n" + "bzip2 support is disabled" + ) + + if self.check_system_cfitsio_objects('curl_'): + _print_msg( + "found curl_ symbol in system cfitsio library\n" + "linking Python extension to curl" + ) + self.compiler.add_library('curl') + self.compiler.define_macro('FITSIO_HAS_CURL_SUPPORT') + else: + _print_msg( + "did not find curl_ symbol in system cfitsio library\n" + "curl support is disabled" + ) + + self.compiler.define_macro('FITSIO_USING_SYSTEM_FITSIO') + + self.compiler.add_library('z') + + # fitsio requires libm as well. + self.compiler.add_library('m') + + # call the original build_extensions + build_ext.build_extensions(self) + + def patch_cfitsio(self): + _print_msg("patching cfitsio") + + try: + subprocess.check_call(["patch", "-v"]) + except subprocess.CalledProcessError as e: + warnings.warn( + "`patch` command not found! " + "Some bugs in cfitsio may not be fixed! " + "See the patches we carry at " + "https://github.com/esheldon/fitsio/tree/master/patches." + ) + if FITSIO_FAIL_ON_BAD_PATCHES: + raise e + else: + return + + patches = glob.glob('%s/*.patch' % self.cfitsio_patch_dir) + for patch in patches: + fname = os.path.basename(patch.replace('.patch', '')) + try: + subprocess.check_call( + [ + "patch", + "-N", + "--dry-run", + "%s/%s" % (self.cfitsio_build_dir, fname), + patch, + ] + ) + except subprocess.CalledProcessError as e: + warnings.warn( + "Failed to apply patch: " + os.path.basename(patch) + ) + if FITSIO_FAIL_ON_BAD_PATCHES: + raise e + else: + subprocess.check_call( + [ + "patch", + "%s/%s" % (self.cfitsio_build_dir, fname), + patch, + ], + ) + + def configure_cfitsio(self, CC=None, ARCHIVE=None, RANLIB=None): + # prepare source code and run configure + def copy_update(dir1, dir2): + f1 = os.listdir(dir1) + for f in f1: + path1 = os.path.join(dir1, f) + path2 = os.path.join(dir2, f) + + if os.path.isdir(path1): + if not os.path.exists(path2): + os.makedirs(path2) + copy_update(path1, path2) + else: + if not os.path.exists(path2): + shutil.copy(path1, path2) + else: + stat1 = os.stat(path1) + stat2 = os.stat(path2) + if stat1.st_mtime > stat2.st_mtime: + shutil.copy(path1, path2) + + if not os.path.exists('build'): + os.makedirs('build') + + if not os.path.exists(self.cfitsio_build_dir): + os.makedirs(self.cfitsio_build_dir) + + if not os.path.exists(self.cfitsio_patch_dir): + os.makedirs(self.cfitsio_patch_dir) + + if sys.version_info.major >= 3 and sys.version_info.minor >= 12: + tar_kwargs = {"filter": "fully_trusted"} + else: + tar_kwargs = {} + + with tempfile.TemporaryDirectory() as tmpdir: + if os.path.exists(self.cfitsio_dir) and os.path.isdir( + self.cfitsio_dir + ): + _print_msg( + "using cfitsio source code from " + f"{self.cfitsio_dir} for debugging" + ) + copy_update( + self.cfitsio_dir, + self.cfitsio_build_dir, + ) + else: + with tarfile.open(self.cfitsio_dir + ".tar.gz", "r:gz") as tar: + tar.extractall(path=tmpdir, **tar_kwargs) + copy_update( + os.path.join(tmpdir, self.cfitsio_dir), + self.cfitsio_build_dir, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + with tarfile.open("zlib.tar.gz", "r:gz") as tar: + tar.extractall(path=tmpdir, **tar_kwargs) + copy_update( + os.path.join(tmpdir, "zlib"), self.cfitsio_build_dir + ) + + copy_update('patches', self.cfitsio_patch_dir) + + # we patch the source in the buil dir to avoid mucking with the repo + self.patch_cfitsio() + + makefile = os.path.join(self.cfitsio_build_dir, 'Makefile') + + if os.path.exists(makefile): + # Makefile already there + _print_msg("found Makefile so not running configure!") + return + else: + _print_msg("configuring cfitsio") + + # the latest cfitsio build system links its example + # programs (e.g., `cookbook`) against the shared library. + # when we use `-fvisibility=hidden` in the CFLAGS ( + # needed to hide the cfitsio symbols in the python `.so``), + # the linking against the shared library fails. + # so we disable shared libraries with (`--disable-shared``) + # and add `-fPIC` to the flags to ensure the python `.so` + # works properly later + args = [ + '--without-fortran', + '--disable-shared', + ] + our_cflags = "-fPIC -fvisibility=hidden" + + if "FITSIO_BZIP2_DIR" in os.environ: + if not os.environ["FITSIO_BZIP2_DIR"]: + args += ["--with-bzip2"] + else: + args += ['--with-bzip2="%s"' % os.environ["FITSIO_BZIP2_DIR"]] + else: + # let autoconf detect if we have bzip2 + args += ['--with-bzip2'] + + env = {} + env.update(os.environ) + + if CC is not None: + env["CC"] = ' '.join(CC[:1]) + env["CFLAGS"] = ' '.join(CC[1:]) + our_cflags + else: + if "CFLAGS" in os.environ: + env["CFLAGS"] = os.environ["CFLAGS"] + " " + our_cflags + else: + env["CFLAGS"] = our_cflags + + if ARCHIVE: + env["ARCHIVE"] = ' '.join(ARCHIVE) + if RANLIB: + env["RANLIB"] = ' '.join(RANLIB) + + res = subprocess.run( + ["sh", "./configure"] + args, + cwd=self.cfitsio_build_dir, + env=env, + ) + if res.returncode != 0: + with open( + os.path.join(self.cfitsio_build_dir, "config.log") + ) as fp: + logfile = fp.read() + raise ValueError( + "could not configure cfitsio %s: config.log:\n\n%s" + % ( + self.cfitsio_version, + logfile, + ) + ) + + def compile_cfitsio(self): + _print_msg("building cfitsio") + res = subprocess.run( + "make", + cwd=self.cfitsio_build_dir, + ) + if res.returncode != 0: + raise ValueError( + "could not compile cfitsio %s" % self.cfitsio_version + ) + + def check_system_cfitsio_objects(self, obj_name): + for lib_dir in self.library_dirs: + if os.path.isfile('%s/libcfitsio.a' % (lib_dir)): + res = subprocess.run( + ["nm", "-g", "%s/libcfitsio.a" % lib_dir], + stdout=PIPE, + stderr=PIPE, + ) + for line in res.stdout.decode("utf-8").splitlines(): + if obj_name in line: + return True + + return False + return False + + +sources = ["fitsio/fitsio_pywrap.c"] + +ext = Extension("fitsio._fitsio_wrap", sources, include_dirs=['numpy']) + +description = ( + "A full featured python library to read from and write to FITS files." +) + +with open(os.path.join(os.path.dirname(__file__), "README.md")) as fp: + long_description = fp.read() + +classifiers = [ + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: GNU General Public License (GPL)", + "Topic :: Scientific/Engineering :: Astronomy", + "Intended Audience :: Science/Research", +] + +setup( + name="fitsio", + description=description, + long_description=long_description, + long_description_content_type='text/markdown; charset=UTF-8; variant=GFM', + license="GPL", + classifiers=classifiers, + url="https://github.com/esheldon/fitsio", + author="Erin Scott Sheldon", + author_email="erin.sheldon@gmail.com", + setup_requires=['numpy>=1.7', 'setuptools-scm>=8'], + install_requires=['numpy>=1.7'], + packages=find_packages(), + python_requires=">=3.8", + include_package_data=True, + ext_modules=[ext], + cmdclass={"build_ext": build_ext_subclass}, + use_scm_version={ + "version_file": "fitsio/_version.py", + "version_file_template": "__version__ = '{version}'\n", + }, +) diff --git a/zlib.tar.gz b/zlib.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..f4b196bc5446a386b957235e7410c76d80bf100d GIT binary patch literal 114845 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 I>i{+f0OmMo&;S4c literal 0 HcmV?d00001 -- 2.30.2