diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 156514bc..ba116e90 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,10 +3,11 @@ name: Build on: push: branches: + - master + tags: - '*' pull_request: - branches: - - master + workflow_dispatch: jobs: job: @@ -14,9 +15,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - # Versions listed at https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json python-version: [ - "3.12", + '3.12', ] steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/check-changelog.yml b/.github/workflows/check-changelog.yml index b3093c55..762ce640 100644 --- a/.github/workflows/check-changelog.yml +++ b/.github/workflows/check-changelog.yml @@ -2,7 +2,14 @@ name: Check Changelog on: pull_request: - types: [assigned, opened, synchronize, reopened, labeled, unlabeled] + types: [ + assigned, + opened, + synchronize, + reopened, + labeled, + unlabeled, + ] branches: - master diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 26e02101..a0833fcc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,6 +1,10 @@ -name: Build and upload to PyPI +name: Upload to PyPI -on: [push, pull_request] +on: + push: + tags: + - '*' + workflow_dispatch: jobs: build_sdist_and_wheel: @@ -11,7 +15,7 @@ jobs: - uses: actions/setup-python@v6 name: Install Python with: - python-version: "3.12" + python-version: '3.12' - name: Install build run: python -m pip install build - name: Build sdist diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..c3e82a96 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,44 @@ +name: Run tests +on: + push: + branches: + - master + tags: + - '*' + pull_request: + schedule: + # run every Tuesday at 5am UTC + - cron: '0 5 * * 2' + workflow_dispatch: + +jobs: + test: + strategy: + matrix: + os: [ + ubuntu-latest, + macos-latest, + ] + python-version: [ + '3.12', + ] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Install tox + run: | + uv tool install --python ${{ matrix.python-version }} tox --with tox-gh + - name: Run tests + run: tox + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5 + with: + report_type: "test_results" + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/CHANGES.rst b/CHANGES.rst index c844eabe..06604236 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,7 @@ 4.0.0 (Unreleased) ================== +- Added initial test suite, which checks the various CASA tasks and arguments used throughout the pipeline (#324) - Bump actions/upload-artifact from 6 to 7 (#313) -- Update casaplotms requirement from >=2.7.4 to >=2.8.2 (#2.8.2) +- Bump casaplotms requirement from >=2.7.4 to >=2.8.2 (#320) - Initial pip-installable version (#292) diff --git a/phangsPipeline/__init__.py b/phangsPipeline/__init__.py index cff8b067..d1d7100e 100644 --- a/phangsPipeline/__init__.py +++ b/phangsPipeline/__init__.py @@ -12,7 +12,6 @@ __version__ = "dev" from .handlerAlmaDownload import AlmaDownloadHandler -from .handlerDerived import DerivedHandler from .handlerKeys import KeyHandler from .handlerRelease import ReleaseHandler from .phangsLogger import setup_logger @@ -44,4 +43,7 @@ # Modules that require spectral-cube to be installed if spectral_cube_enabled: - __all__.extend(["DerivedHandler"]) + from .handlerDerived import DerivedHandler + __all__.extend([ + "DerivedHandler", + ]) diff --git a/pyproject.toml b/pyproject.toml index fab25d37..a1ba4f81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,17 @@ casa = [ "pipeline@git+https://open-bitbucket.nrao.edu/scm/pipe/pipeline.git", ] +# Optional packages for the test suite +test = [ + "pytest-cov >= 7.1.0", +] +test-casa = [ + "zenodo-get >= 3.0.0", +] +test-ruff = [ + "ruff >= 0.15.10", +] + [project.urls] "Homepage" = "https://github.com/phangsTeam/phangs_imaging_scripts" "Bug Reports" = "https://github.com/phangsTeam/phangs_imaging_scripts/issues" @@ -68,6 +79,25 @@ exclude = [ "scripts/*", ] +# Omit in coverage. Notably here, we exclude +# the CASA analysisutils +[tool.coverage.run] +omit = [ + "docs/*", + "phangs-alma_keys/*", + "tests/*", + "*/analysis_scripts/*", +] + +[tool.coverage.report] +exclude_lines = [ + 'except ImportError', + 'except ModuleNotFoundError', + 'except \(ImportError, ModuleNotFoundError\)', + 'raise AssertionError', + 'raise NotImplementedError', +] + [build-system] requires = [ "setuptools >= 80.9.0", diff --git a/pytest.toml b/pytest.toml new file mode 100644 index 00000000..d0bed0e0 --- /dev/null +++ b/pytest.toml @@ -0,0 +1,13 @@ +[pytest] +minversion = "9.0" +addopts = [ + "-ra", + "-q", + "--strict-markers", +] +markers = [ + "casa: marks tests as requiring CASA install (deselect with '-m \"not casa\"')", +] +testpaths = [ + "tests", +] diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..17ef163c --- /dev/null +++ b/ruff.toml @@ -0,0 +1,33 @@ +extend = "pyproject.toml" + +extend-exclude = [ + ".github", + ".tmp", + "phangs-alma_keys", + "phangsPipelineTests", + "scripts", + "tests/casa_config_files/*", + "run_pipeline_phangs-alma.py", +] + +line-length = 100 + +target-version = "py312" + +[format] +quote-style = "double" +indent-style = "space" +docstring-code-format = true + +[lint.pydocstyle] +convention = "numpy" + +[lint.per-file-ignores] +"phangsPipeline/__init__.py" = [ + "E402", # module-level import not at top of file + "F401", # unused import +] + +"tests/test_*_imports.py" = [ + "F401", # unused import +] \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..cf63f13e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +import os +import sys + +# Add the analysisUtils directory to PATH +envdir = os.environ.get("ENVDIR", os.getcwd()) +au_dir = os.path.join(envdir, "analysis_scripts") +sys.path.append(au_dir) diff --git a/tests/casa_config_files/config.py b/tests/casa_config_files/config.py new file mode 100644 index 00000000..7445a3a7 --- /dev/null +++ b/tests/casa_config_files/config.py @@ -0,0 +1,8 @@ +import os + +casadir = os.environ.get("CASADIR", os.getcwd()) + +datapath = [os.path.join(casadir, "data")] +measurespath = datapath[0] +measures_auto_update = True +data_auto_update = True diff --git a/tests/casa_config_files/startup.py b/tests/casa_config_files/startup.py new file mode 100644 index 00000000..7cef849e --- /dev/null +++ b/tests/casa_config_files/startup.py @@ -0,0 +1,10 @@ +import os +import sys + +envdir = os.environ.get("ENVDIR", os.getcwd()) + +au_dir = os.path.join(envdir, "analysis_scripts") +sys.path.append(au_dir) + +import analysisUtils as au +aU = au diff --git a/tests/test_casa_imports.py b/tests/test_casa_imports.py new file mode 100644 index 00000000..3e2c3361 --- /dev/null +++ b/tests/test_casa_imports.py @@ -0,0 +1,515 @@ +import pytest +import sys +from inspect import getfullargspec + +try: + import analysisUtils as au +except ModuleNotFoundError: + au = None + +try: + import casatasks +except ModuleNotFoundError: + casatasks = None + + +def check_args_exist(in_args, function): + """Check if arguments exist within a function. + + Args: + in_args (list): list of expected arguments + function (function): function to check for arguments + """ + + func_args = getfullargspec(function).args + all_args_exist = set(in_args).issubset(set(func_args)) + + return all_args_exist + + +class TestCASAImports: + """Suite of tests for various CASA imports.""" + + casa_imports = [ + "casashell", + "casaplotms", + pytest.param( + "casaviewer", + marks=pytest.mark.xfail( + sys.platform == "darwin", + reason="casaviewer not available on Mac OS X", + ), + ), + "casatasks.private.sdint_helper", + ] + + @pytest.mark.casa + @pytest.mark.parametrize("casa_module", casa_imports) + def test_import_casa( + self, + casa_module, + ): + """Test CASA imports. + + Args: + casa_module: CASA module to import + """ + + success = False + try: + __import__(casa_module) + success = True + except ModuleNotFoundError: + pass + + assert success + + +class TestCASATaskArguments: + """Suite of tests for testing CASA task arguments exists.""" + + casatask_funcs = [ + ( + "concat", + [ + "vis", + "dirtol", + "concatvis", + "copypointing", + "freqtol", + "respectname", + ], + ), + ( + "exportfits", + [ + "bitpix", + "dropdeg", + "dropstokes", + "fitsimage", + "imagename", + "overwrite", + "velocity", + ], + ), + ( + "feather", + [ + "highres", + "imagename", + "lowpassfiltersd", + "lowres", + "sdfactor", + ], + ), + ( + "flagcmd", + [ + "action", + "inpmode", + "plotfile", + "useapplied", + "vis", + ], + ), + ( + "flagdata", + [ + "action", + "antenna", + "mode", + "spw", + "vis", + ], + ), + ( + "gencal", + [ + "caltable", + "caltype", + "vis", + ], + ), + ( + "imhead", + [ + "mode", + ], + ), + ( + "immath", + [ + "expr", + "imagemd", + "imagename", + "mode", + "outfile", + "stokes", + ], + ), + ( + "impbcor", + [ + "cutoff", + "imagename", + "mode", + "outfile", + "pbimage", + ], + ), + ( + "importasdm", + [ + "asis", + "bdfflags", + "process_caldevice", + "with_pointing_correction", + ], + ), + ( + "importfits", + [ + "defaultaxes", + "defaultaxesvalues", + "fitsimage", + "imagename", + "overwrite", + "zeroblanks", + ], + ), + ( + "imrebin", + [ + "crop", + "dropdeg", + "factor", + "imagename", + "outfile", + "overwrite", + ], + ), + ( + "imsmooth", + [ + "major", + "minor", + "outfile", + "overwrite", + "pa", + "targetres", + "imagename", + ], + ), + ( + "imstat", + [ + "imagename", + "mask", + ], + ), + ( + "imsubimage", + [ + "box", + "chans", + "dropdeg", + "imagename", + "mask", + "outfile", + ], + ), + ( + "imtrans", + [ + "imagename", + "order", + "outfile", + ], + ), + ( + "imval", + [ + "box", + "stokes", + ], + ), + ( + "listobs", + [ + "listfile", + "vis", + ], + ), + ( + "mstransform", + [ + "datacolumn", + "outputvis", + "spw", + "vis", + ], + ), + ( + "plotbandpass", + [ + "buildpdf", + "caltable", + "chanrange", + "field", + "figfile", + "interactive", + "overlay", + "pwv", + "showatm", + "showfdm", + "subplot", + "xaxis", + "yaxis", + ], + ), + ( + "sdbaseline", + [ + "blfunc", + "datacolumn", + "infile", + "maskmode", + "order", + "outfile", + "overwrite", + "spw", + ], + ), + ( + "sdcal", + [ + "calmode", + "infile", + "outfile", + "overwrite", + "spw", + "spwmap", + ], + ), + ( + "split", + [ + "antenna", + "datacolumn", + "field", + "intent", + "keepflags", + "outputvis", + "spw", + "timebin", + "vis", + "width", + ], + ), + ( + "statwt", + [ + "chanbin", + "datacolumn", + "excludechans", + "fitspw", + "slidetimebin", + "statalg", + "timebin", + "vis", + ], + ), + ( + "tsdimaging", + [ + "cell", + "convsupport", + "field", + "gridfunction", + "imsize", + "infiles", + "mode", + "nchan", + "outfile", + "outframe", + "overwrite", + "phasecenter", + "restfreq", + "start", + "veltype", + "width", + ], + ), + ( + "uvcontsub", + [ + "fitmethod", + "fitorder", + "fitspec", + "outputvis", + "vis", + ], + ), + ( + "visstat", + [ + "axis", + "spw", + "vis", + ], + ), + ] + + @pytest.mark.casa + @pytest.mark.parametrize("func,in_args", casatask_funcs) + def test_casatask_args( + self, + func, + in_args, + ): + """Check arguments exist within casatask function. + + Args: + func: function to check for arguments + in_args (list): list of expected arguments + """ + + if casatasks is None: + raise ModuleNotFoundError("Could not import casatasks") + + casatask_func = getattr(casatasks, func) + + success = check_args_exist(in_args, casatask_func) + + assert success + + +class TestAnalysisUtilsImports: + """Suite of tests for various AnalysisUtils imports.""" + + au_funcs = [ + "Atmcal", + "MAD", + "ValueMapping", + "angularSeparationRadians", + "clearPointingTable", + "computeAzElFromRADecMJD", + "computeRADecFromAzElMJD", + "createCasaTool", + "dateStringToMJD", + "getAntennaPadXYZ", + "getBaselineStats", + "getChanWidths", + "getObservationStart", + "getObservationStartDate", + "getOffSourceTimes", + "getRADecForField", + "getRADecForSource", + "getScienceSpws", + "lsrkToTopo", + "mjdSecondsListToDateTime", + "mjdSecondsToMJDandUT", + "parseTimerangeArgument", + "pickCellSize", + "radec2rad", + "stuffForScienceDataReduction", + "timeOnSource", + ] + + @pytest.mark.casa + @pytest.mark.parametrize("func", au_funcs) + def test_import_au_func( + self, + func, + ): + """Test analysisUtils import. + + Args: + func: function to check imports + """ + + if au is None: + raise ModuleNotFoundError("Could not import analysisUtils") + + if hasattr(au, func): + success = True + else: + success = False + + assert success + + +class TestAnalysisUtilsArguments: + """Suite of tests for testing analysisUtils function arguments exists.""" + + au_funcs = [ + ( + "angularSeparationRadians", + [ + "returnComponents", + ], + ), + ( + "computeAzElFromRADecMJD", + [ + "verbose", + ], + ), + ( + "computeRADecFromAzElMJD", + [ + "cofa", + "frame", + "my_metool", + "nutationCorrection", + "refractionCorrection", + "verbose", + ], + ), + ( + "getRADecForField", + [ + "forcePositiveRA", + "usemstool", + ], + ), + ( + "getScienceSpws", + [ + "intent", + "returnFreqRanges", + ], + ), + ( + "pickCellSize", + [ + "imsize", + "intent", + "npix", + "pblevel", + ], + ), + ] + + @pytest.mark.casa + @pytest.mark.parametrize("func,in_args", au_funcs) + def test_analysisutils_args( + self, + func, + in_args, + ): + """Check arguments exist within analysisUtils function. + + Args: + func: function to check for arguments + in_args (list): list of expected arguments + """ + + if au is None: + raise ModuleNotFoundError("Could not import analysisUtils") + + au_func = getattr(au, func) + + success = check_args_exist(in_args, au_func) + + assert success diff --git a/tests/test_pipeline_imports.py b/tests/test_pipeline_imports.py new file mode 100644 index 00000000..68352fc3 --- /dev/null +++ b/tests/test_pipeline_imports.py @@ -0,0 +1,73 @@ +import pytest + +import phangsPipeline + + +def _import_handler( + handler, +): + """Import handler. + + Args: + handler: Name for Handler to import + """ + + if hasattr(phangsPipeline, handler): + success = True + else: + success = False + + return success + + +class TestPipelineImports: + """Suite of tests for various pipeline imports.""" + + # Some handlers need CASA, some don't + non_casa_handlers = [ + "AlmaDownloadHandler", + "KeyHandler", + "ReleaseHandler", + "DerivedHandler", + "setup_logger", + ] + + casa_handlers = [ + "ImagingChunkedHandler", + "ImagingHandler", + "PostProcessHandler", + "SingleDishHandler", + "TestImagingHandler", + "VisHandler", + ] + + @pytest.mark.parametrize("handler", non_casa_handlers) + def test_import_non_casa_handler( + self, + handler, + ): + """Test non-CASA Handler import. + + Args: + handler: Name for Handler to import + """ + + success = _import_handler(handler) + + assert success + + @pytest.mark.casa + @pytest.mark.parametrize("handler", casa_handlers) + def test_import_casa_handler( + self, + handler, + ): + """Test CASA Handler import. + + Args: + handler: Name for Handler to import + """ + + success = _import_handler(handler) + + assert success diff --git a/tox.ini b/tox.ini index 72344056..4ae0b837 100644 --- a/tox.ini +++ b/tox.ini @@ -1,95 +1,86 @@ [tox] envlist = - py{36,37,38}-test{,-alldeps,-devdeps}{,-cov} - py{36,37,38}-test-numpy{116,117,118} - py{36,37,38}-test-astropy{30,40,lts} - build_docs - linkcheck - codestyle + py3{12}{,-casa}{,-oldestdeps,-cov} + lint +; docs requires = - setuptools >= 30.3.0 - pip >= 19.3.1 + tox-uv isolated_build = true -indexserver = - NIGHTLY = https://pypi.anaconda.org/scipy-wheels-nightly/simple +[gh] +python = + 3.12 = py312, lint + +# Run the main test suite [testenv] -# Suppress display of matplotlib plots generated during docs build -setenv = MPLBACKEND=agg +set_env = -# Pass through the following environment variables which may be needed for the CI -passenv = HOME WINDIR LC_ALL LC_CTYPE CC CI TRAVIS + # Set various CASA paths + CASASITECONFIG = {toxinidir}{/}.tmp{/}{envname}{/}.casa{/}config.py + CASADIR = {toxinidir}{/}.tmp{/}.casa + ENVDIR = {toxinidir}{/}.tmp{/}{envname} + CASACONFIGDIR = {toxinidir}{/}tests{/}casa_config_files -# Run the tests in a temporary directory to make sure that we don't import -# this package from the source tree -changedir = .tmp/{envname} + # Set platform + PLATFORM = unknown + linux: PLATFORM = linux + darwin: PLATFORM = mac + win32: PLATFORM = windows -# tox environments are constructed with so-called 'factors' (or terms) -# separated by hyphens, e.g. test-devdeps-cov. Lines below starting with factor: -# will only take effect if that factor is included in the environment name. To -# see a list of example environments that can be run, along with a description, -# run: -# -# tox -l -v -# +allowlist_externals = + echo + cp + mkdir + rm + tar + uvx description = run tests - alldeps: with all optional dependencies - devdeps: with the latest developer version of key dependencies - oldestdeps: with the oldest supported version of key dependencies - cov: and test coverage - numpy116: with numpy 1.16.* - numpy117: with numpy 1.17.* - numpy118: with numpy 1.18.* - astropy30: with astropy 3.0.* - astropy40: with astropy 4.0.* - astropylts: with the latest astropy LTS - -# The following provides some specific pinnings for key packages + casa: with CASA packages enabled + oldestdeps: with the oldest supported dependencies + cov: with coverage deps = + oldestdeps: minimum_dependencies +extras = + casa: casa + test + casa: test-casa +commands = - cov: coverage - numpy116: numpy==1.16.* - numpy117: numpy==1.17.* - numpy118: numpy==1.18.* + echo Running on {env:PLATFORM} - astropy30: astropy==3.0.* - astropy40: astropy==4.0.* - astropylts: astropy==4.0.* + # If using CASA, create a temp directory to install data + casa: rm -rf {env:ENVDIR} + casa: mkdir -p {env:ENVDIR} - devdeps: :NIGHTLY:numpy - devdeps: git+https://github.com/astropy/astropy.git#egg=astropy + # Move CASA startup files + casa: mkdir -p {env:ENVDIR}{/}.casa + casa: cp {env:CASACONFIGDIR}{/}startup.py {env:ENVDIR}{/}.casa{/} + casa: cp {env:CASACONFIGDIR}{/}config.py {env:ENVDIR}{/}.casa{/} -# The following indicates which extras_require from setup.cfg will be installed -extras = - test - alldeps: all + # Download CASA data + casa: mkdir -p {env:CASADIR}{/}data + casa: python -m casaconfig --update-all + casa: python -m casaconfig --current-data -commands = - pip freeze - !cov: pytest --pyargs phangsPipeline {toxinidir}/docs {posargs} - cov: pytest --pyargs phangsPipeline {toxinidir}/docs --cov phangsPipeline --cov-config={toxinidir}/setup.cfg {posargs} - cov: coverage xml -o {toxinidir}/coverage.xml + # Download and extract the latest analysisUtils + casa: uvx zenodo_get 10.5281/zenodo.7502159 -o {env:ENVDIR} + casa: tar -xf {env:ENVDIR}{/}analysis_scripts.tar -C {env:ENVDIR}{/} -[testenv:build_docs] -changedir = docs -description = invoke sphinx-build to build the HTML docs -extras = docs -commands = - pip freeze - sphinx-build -W -b html . _build/html + {list_dependencies_command} -[testenv:linkcheck] -changedir = docs -description = check the links in the HTML docs -extras = docs -commands = - pip freeze - sphinx-build -W -b linkcheck . _build/html + # Run tests + !casa: pytest -m "not casa" \ + casa: pytest \ + cov: --cov --cov-branch --cov-report=xml:{env:PLATFORM}-{envname}-coverage.xml \ + cov: --junitxml={env:PLATFORM}-{envname}-junit.xml -o junit_family=legacy \ + {posargs} -[testenv:codestyle] -skip_install = true -changedir = . -description = check code style, e.g. with flake8 -deps = flake8 -commands = flake8 phangsPipeline --count --max-line-length=100 +# Run lint +[testenv:lint] +description = run linters +extras = + test-ruff +# --exit-zero here to not fail, since there's currently a +# lot of tidying to do +commands = ruff check {posargs} --exit-zero