From 3d14e70a1169d9097398d419dcb7e5b7ec67fea3 Mon Sep 17 00:00:00 2001 From: Matt Savoie Date: Wed, 31 Jul 2024 17:30:37 -0600 Subject: [PATCH] DAS-2180 extract lib (#28) --- ...h_docker_image.yml => publish_release.yml} | 35 ++- .github/workflows/run_lib_tests.yml | 45 +++ .../{run_tests.yml => run_service_tests.yml} | 10 +- .../workflows/run_tests_on_pull_requests.yml | 9 +- CHANGELOG.md | 13 +- README.md | 258 ++++++++++++++---- docker/service.Dockerfile | 7 +- docker/service_version.txt | 2 +- harmony_browse_image_generator/exceptions.py | 36 --- .../__init__.py | 0 .../__main__.py | 11 +- .../adapter.py | 27 +- harmony_service/exceptions.py | 17 ++ .../utilities.py | 7 +- hybig/__init__.py | 5 + .../browse.py | 122 ++++++++- hybig/browse_utility.py | 34 +++ .../color_utility.py | 6 +- .../crs.py | 8 +- hybig/exceptions.py | 17 ++ .../sizes.py | 14 +- pip_requirements.txt | 6 +- pyproject.toml | 52 ++++ tests/pip_test_requirements.txt | 7 +- tests/run_tests.sh | 10 +- tests/test_code_format.py | 18 +- tests/{ => test_service}/test_adapter.py | 34 +-- .../unit/test_adapter_unit.py} | 6 +- tests/unit/test_browse.py | 130 +++++++-- tests/unit/test_color_utility.py | 36 +-- tests/unit/test_crs.py | 10 +- tests/unit/test_sizes.py | 26 +- tests/unit/test_utilities.py | 10 +- 33 files changed, 755 insertions(+), 273 deletions(-) rename .github/workflows/{publish_docker_image.yml => publish_release.yml} (73%) create mode 100644 .github/workflows/run_lib_tests.yml rename .github/workflows/{run_tests.yml => run_service_tests.yml} (85%) delete mode 100644 harmony_browse_image_generator/exceptions.py rename {harmony_browse_image_generator => harmony_service}/__init__.py (100%) rename {harmony_browse_image_generator => harmony_service}/__main__.py (71%) rename {harmony_browse_image_generator => harmony_service}/adapter.py (89%) create mode 100644 harmony_service/exceptions.py rename {harmony_browse_image_generator => harmony_service}/utilities.py (89%) create mode 100644 hybig/__init__.py rename {harmony_browse_image_generator => hybig}/browse.py (74%) create mode 100644 hybig/browse_utility.py rename {harmony_browse_image_generator => hybig}/color_utility.py (97%) rename {harmony_browse_image_generator => hybig}/crs.py (93%) create mode 100644 hybig/exceptions.py rename {harmony_browse_image_generator => hybig}/sizes.py (97%) create mode 100644 pyproject.toml rename tests/{ => test_service}/test_adapter.py (94%) rename tests/{unit/test_adapter.py => test_service/unit/test_adapter_unit.py} (96%) diff --git a/.github/workflows/publish_docker_image.yml b/.github/workflows/publish_release.yml similarity index 73% rename from .github/workflows/publish_docker_image.yml rename to .github/workflows/publish_release.yml index 772f259..16901f1 100644 --- a/.github/workflows/publish_docker_image.yml +++ b/.github/workflows/publish_release.yml @@ -1,12 +1,13 @@ # This workflow will run when changes are detected in the `main` branch, which # must include an update to the `docker/service_version.txt` file. The workflow # can also be manually triggered by a repository maintainer. This workflow will -# first trigger the reusable workflow in `.github/workflows/run_tests.yml`, +# first trigger the reusable workflow in `.github/workflows/run_service_tests.yml`, # which runs the `unittest` suite. If that workflow is successful, the latest -# version of the service Docker image is pushed to ghcr.io, a tag is added to -# the latest git commit, and a GitHub release is created with the release notes -# from the latest version of HyBIG. -name: Publish Harmony Browse Image Generator (HyBIG) Docker image +# version of the service Docker image is pushed to ghcr.io, a library package +# is built and published to PyPI, a tag is added to the latest git commit, and +# a GitHub release is created with the release notes from the latest version of +# HyBIG. +name: Publish Harmony Browse Image Generator (HyBIG) on: push: @@ -19,11 +20,14 @@ env: REGISTRY: ghcr.io jobs: - run_tests: - uses: ./.github/workflows/run_tests.yml + run_service_tests: + uses: ./.github/workflows/run_service_tests.yml - build_and_publish_image: - needs: run_tests + run_lib_tests: + uses: ./.github/workflows/run_lib_tests.yml + + build_and_publish: + needs: [run_service_tests, run_lib_tests] runs-on: ubuntu-latest environment: release permissions: @@ -36,7 +40,7 @@ jobs: steps: - name: Checkout harmony-browse-image-generator repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: lfs: true @@ -74,6 +78,17 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Build hybig-py package + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + - name: Publish GitHub release uses: ncipollo/release-action@v1 with: diff --git a/.github/workflows/run_lib_tests.yml b/.github/workflows/run_lib_tests.yml new file mode 100644 index 0000000..74ac0d8 --- /dev/null +++ b/.github/workflows/run_lib_tests.yml @@ -0,0 +1,45 @@ +# This workflow will run the appropriate library tests across a python matrix of versions. +name: Run Python library tests + +on: + workflow_call + +jobs: + build_and_test_lib: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.11'] + + steps: + - name: Checkout harmony-browse-image-generator repository + uses: actions/checkout@v4 + with: + lfs: true + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install GDAL + run: | + # Install packaged version of GDAL. + sudo apt-get update + sudo apt-get install -y libgdal-dev + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + pip install -r pip_requirements.txt + # Use the gdal version that was installed in the previous step. This + # is not the same GDAL as installed in the docker images but in the + # end we're only using osgeo's ColorPalette + pip install GDAL==$(gdal-config --version) + pip install -r tests/pip_test_requirements.txt + + - name: Run science tests while excluding the service tests. + run: | + pytest tests --ignore tests/test_service diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_service_tests.yml similarity index 85% rename from .github/workflows/run_tests.yml rename to .github/workflows/run_service_tests.yml index 78a72f6..1568825 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_service_tests.yml @@ -3,20 +3,20 @@ # test results and code coverage as artefacts. It will be called by the # workflow that run tests against new PRs and as a first step in the workflow # that publishes new Docker images. -name: Run Python unit tests +name: Run Python tests on: workflow_call jobs: - build_and_test: + build_and_test_service: runs-on: ubuntu-latest strategy: fail-fast: false steps: - name: Checkout harmony-browse-image-generator repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: lfs: true @@ -30,13 +30,13 @@ jobs: run: ./bin/run-test - name: Archive test results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Test results path: test-reports/ - name: Archive coverage report - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Coverage report path: coverage/* diff --git a/.github/workflows/run_tests_on_pull_requests.yml b/.github/workflows/run_tests_on_pull_requests.yml index a948fd1..279a41e 100644 --- a/.github/workflows/run_tests_on_pull_requests.yml +++ b/.github/workflows/run_tests_on_pull_requests.yml @@ -1,5 +1,5 @@ # This workflow will run when a PR is opened against the `main` branch. It will -# trigger the reusable workflow in `.github/workflows/run_tests.yml`, which +# trigger the reusable workflow in `.github/workflows/run_service_tests.yml`, which # builds the service and test Docker images, and runs the `unittest` suite in a # Docker container built from the test image. name: Run Python unit tests for pull requests against main @@ -9,5 +9,8 @@ on: branches: [ main ] jobs: - build_and_test: - uses: ./.github/workflows/run_tests.yml + build_and_test_service: + uses: ./.github/workflows/run_service_tests.yml + + run_lib_tests: + uses: ./.github/workflows/run_lib_tests.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index baf9a82..bf43837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ HyBIG follows semantic versioning. All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). +## [v2.0.0] - 2024-07-19 + +**DAS-2180** - Adds pip installable library. + +This release is a refactor that extracts browse image generation logic from the +harmony service code. There are no user visible changes to the existing +functionality. The new library, +[hybig-py](https://pypi.org/project/hybig-py/), provides the `create_browse` +function to generate browse images, see the README.md for details. + ## [v1.2.2] - 2024-06-18 ### Changed @@ -52,7 +62,8 @@ outlined by the NASA open-source guidelines. For more information on internal releases prior to NASA open-source approval, see legacy-CHANGELOG.md. -[unreleased]:https://github.com/nasa/harmony-browse-image-generator/compare/1.2.2..HEAD +[unreleased]:https://github.com/nasa/harmony-browse-image-generator/compare/2.0.0..HEAD +[v2.0.0]:https://github.com/nasa/harmony-browse-image-generator/compare/1.2.2..2.0.0 [v1.2.2]: https://github.com/nasa/harmony-browse-image-generator/compare/1.2.1..1.2.2 [v1.2.1]: https://github.com/nasa/harmony-browse-image-generator/compare/1.2.0..1.2.1 [v1.2.0]: https://github.com/nasa/harmony-browse-image-generator/compare/1.1.0..1.2.0 diff --git a/README.md b/README.md index 962faa8..2747d52 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Harmony Browse Image Generator (HyBIG). -This Harmony backend service is designed to produce browse imagery, with -default behaviour to produce browse imagery that is compatible with the NASA -Global Image Browse Services ([GIBS](https://www.earthdata.nasa.gov/eosdis/science-system-description/eosdis-components/gibs)). +This repository contains code designed to produce browse imagery. Its default behaviour +produces images compatible with the NASA Global Image Browse +Services ([GIBS](https://www.earthdata.nasa.gov/eosdis/science-system-description/eosdis-components/gibs)). -This means that defaults for images are selected to match the visualization -generation requirements and recommendations put forth in the GIBS Interface -Control Document (ICD), which can be found on [Earthdata +This means that default parameters for images are selected to match the +visualization generation requirements and recommendations put forth in the GIBS +Interface Control Document (ICD), which can be found on [Earthdata Wiki](https://wiki.earthdata.nasa.gov/display/GITC/Ingest+Delivery+Methods) along with [additional GIBS documentation](https://nasa-gibs.github.io/gibs-api-docs/). @@ -16,6 +16,126 @@ images. Scientific parameter raster data as well as RGB[A] raster images can be converted to browse PNGs. These browse images undergo transformation by reprojection, tiling and coloring to seamlessly integrate with GIBS. +The repository contains code and infrastructure to support both the HyBIG +Service as well as `hybig-py`. The HyBIG Service is packaged as a Docker +container that is deployed to [NASA's +Harmony](https://harmony.earthdata.nasa.gov/) system. The business logic is +contained in the [`hybig-py` library](https://pypi.org/project/hybig-py/) which +exposes functions to generate browse images in python scripts. + +### hybig-py + +The browse image generation logic is packaged in the hybig-py +library. Currently, a single function, `create_browse` is exposed to the user. + +```python +def create_browse( + source_tiff: str, + params: dict = None, + palette: str | ColorPalette | None = None, + logger: Logger = None, +) -> list[tuple[Path, Path, Path]]: + """Create browse imagery from an input geotiff. + + This is the exposed library function to allow users to create browse images + from the hybig-py library. It parses the input params and constructs the + correct Harmony input structure [Message.Format] to call the service's + entry point create_browse_imagery. + + Output images are created and deposited into the input GeoTIFF's directory. + + Args: + source_tiff: str, location of the input geotiff to process. + + params: [dict | None], A dictionary with the following keys: + + mime: [str], MIME type of the output image (default: 'image/png'). + any string that contains 'jpeg' will return a jpeg image, + otherwise create a png. + + crs: [dict | None], Target image's Coordinate Reference System. + A dictionary with 'epsg', 'proj4' or 'wkt' key. + + scale_extent: [dict | None], Scale Extents for the image. This dictionary + contains "x" and "y" keys each whose value which is a dictionary + of "min", "max" values in the same units as the crs. + e.g.: { "x": { "min": 0.5, "max": 125 }, + "y": { "min": 52, "max": 75.22 } } + + scale_size: [dict | None], Scale sizes for the image. The dictionary + contains "x" and "y" keys with the horizontal and veritcal + resolution in the same units as the crs. + e.g.: { "x": 10, "y": 10 } + + height: [int | None], height of the output image in gridcells. + + width: [int | none], width of the output image in gridcells. + + palette: [str | ColorPalette | none], either a URL to a remote color palette + that is fetched and loaded or a ColorPalette object used to color + the output browse image. If not provided, a grayscale image is + generated. + + logger: [Logger | None], a configured Logger object. If None a default + logger will be used. + + Note: + if supplied, scale_size, scale_extent, height and width must be + internally consistent. To define a valid output grid: + * Specify scale_extent and 1 of: + * height and width + * scale_sizes (in the x and y horizontal spatial dimensions) + * Specify all three of the above, but ensure values are consistent + with one another, noting that: + scale_size.x = (scale_extent.x.max - scale_extent.x.min) / width + scale_size.y = (scale_extent.y.max - scale_extent.y.min) / height + + Returns: + List of 3-element tuples. These are the file paths of: + - The output browse image + - Its associated ESRI world file (containing georeferencing information) + - The auxiliary XML file (containing duplicative georeferencing information) + + + Example Usage: + results = create_browse( + "/path/to/geotiff", + { + "mime": "image/png", + "crs": {"epsg": "EPSG:4326"}, + "scale_extent": { + "x": {"min": -180, "max": 180}, + "y": {"min": -90, "max": 90}, + }, + "scale_size": {"x": 10, "y": 10}, + }, + "https://remote-colortable", + logger, + ) + + """ +``` + +### library installation + +The hybig-py library can be installed from PyPI but has a prerequisite +dependency requirement on the GDAL libraries. Ensure you have an environment +with the libraries available. You can check on Linux/macOS: +```bash +gdal-config --version +``` +on windows (if GDAL is in your PATH): +```bash +gdalinfo --version +``` + +Once verified, you can simply install the libary: + +```bash +pip install hybig-py +``` + + ### Reprojection GIBS expects to receive images in one of three Coordinate Reference System (CRS) projections. @@ -132,7 +252,8 @@ also with units of degrees. |- 📂 bin |- 📂 docker |- 📂 docs -|- 📂 harmony_browse_image_generator +|- 📂 hybig +|- 📂 harmony_service |- 📂 tests |- CHANGELOG.md |- CONTRIBUTING.md @@ -142,28 +263,34 @@ also with units of degrees. |- legacy-CHANGELOG.md |- pip_requirements.txt |- pip_requirements_skip_snyk.txt +|- pyproject.toml + ``` * `bin` - A directory containing utility scripts to build the service and test - images. A script to extract the release notes for the most recent service - version, as contained in `CHANGELOG.md` is also in this directory. + images. A script to extract the release notes for the most recent version, as + contained in `CHANGELOG.md` is also in this directory. * `docker` - A directory containing the Dockerfiles for the service and test images. It also contains `service_version.txt`, which contains the semantic - version number of the service image. Any time an update is made that should - have an accompanying service image release, this file should be updated. + version number of the library and service image. Update this file with a new + version to trigger a release. * `docs` - A directory with example usage notebooks. -* `harmony_browse_image_generator` - A directory containing Python source code - for the HyBIG. `adapter.py` contains the `BrowseImageGeneratorAdapter` - class that is invoked by calls to the service. +* `hybig` - A directory containing Python source code for the HyBIG library. + This directory contains the business logic for generating GIBS compatible + browse images. + +* `harmony_service` - A directory containing the Harmony Service specific + python code. `adapter.py` contains the `BrowseImageGeneratorAdapter` class + that is invoked by calls to the Harmony service. * `tests` - A directory containing the service unit test suite. * `CHANGELOG.md` - This file contains a record of changes applied to each new - release of a service Docker image. Any release of a new service version - should have a record of what was changed in this file. + release of HyBIG. Any release of a new version should have a record + of what was changed in this file. * `CONTRIBUTING.md` - This file contains guidance for making contributions to HyBIG, including recommended git best practices. @@ -171,9 +298,11 @@ also with units of degrees. * `LICENSE` - Required for distribution under NASA open-source approval. Details conditions for use, reproduction and distribution. -* `README.md` - This file, containing guidance on developing the service. +* `README.md` - This file, containing guidance on developing the library and + service. -* `dev-requirements.txt` - list of packages required for service development. +* `dev-requirements.txt` - list of packages required for library and service + development. * `legacy-CHANGELOG.md` - Notes for each version that was previously released internally to EOSDIS, prior to open-source publication of the code and Docker @@ -187,17 +316,24 @@ also with units of degrees. naive and cannot pre-install required libraries so that `pip install GDAL` fails and we have no work around. +* `pyproject.toml` - Configuration file used by packaging tools, as well as + other tools such as linters, type checkers, etc. + ## Local development: -Local testing of service functionality is best achieved via a local instance of +Local testing of service functionality can be achieved via a local instance of [Harmony](https://github.com/nasa/harmony). Please see instructions there regarding creation of a local Harmony instance. -If testing small functions locally that do not require inputs from the main -Harmony application, it is recommended that you create a Python virtual -environment, and then install the necessary dependencies for the -service within that environment via conda and pip then install the pre-commit hooks. +For local development and testing of library modifications or small functions +independent of the main Harmony application: + +1. Create a Python virtual environment +1. Ensure GDAL libraries are accessable in the virtual environment. +1. Install the dependencies in `pip_requirements.txt`, `pip_requirements_skip_snyk.txt` and `dev-requirements.txt` +1. Install the pre-commit hooks. + ``` > conda create --name hybig-env python==3.11 @@ -227,56 +363,72 @@ Currently, the `unittest` suite is run automatically within a GitHub workflow as part of a CI/CD pipeline. These tests are run for all changes made in a PR against the `main` branch. The tests must pass in order to merge the PR. -The unit tests are also run prior to publication of a new Docker image, when -commits including changes to `docker/service_version.txt` are merged into the -`main` branch. If these unit tests fail, the new version of the Docker image -will not be published. +Unit tests are executed automatically by github actions on each Pull Request. + ## Versioning: -Service Docker images for HyBIG adhere to semantic version numbers: -major.minor.patch. +Docker service images and the hybig-py package library adhere to semantic +version numbers: major.minor.patch. * Major increments: These are non-backwards compatible API changes. * Minor increments: These are backwards compatible API changes. * Patch increments: These updates do not affect the API to the service. -When publishing a new Docker image for the service, two files need to be -updated: - -* `CHANGELOG.md` - Notes should be added to capture the changes to the service. -* `docker/service_version.txt` - The semantic version number should be updated. - ## CI/CD: -The CI/CD for HyBIG is contained in GitHub workflows in the +The CI/CD for HyBIG is run on github actions with the workflows in the `.github/workflows` directory: -* `run_tests.yml` - A reusable workflow that builds the service and test Docker - images, then runs the Python unit test suite in an instance of the test - Docker container. +* `run_lib_tests.yml` - A reusable workflow that tests the library functions + against the supported python versions. +* `run_service_tests.yml` - A reusable workflow that builds the service and + test Docker images, then runs the Python unit test suite in an instance of + the test Docker container. * `run_tests_on_pull_requests.yml` - Triggered for all PRs against the `main` - branch. It runs the workflow in `run_tests.yml` to ensure all tests pass for - the new code. + branch. It runs the workflow in `run_service_tests.yml` and + `run_lib_tests.yml` to ensure all tests pass for the new code. * `publish_docker_image.yml` - Triggered either manually or for commits to the `main` branch that contain changes to the `docker/service_version.txt` file. +* `publish_to_pypi.yml` - Triggered either manually or for commits to the + `main` branch that contain changes to the `docker/service_version.txt`file. +* `publish_release.yml` - workflow runs automatically when there is a change to + the `docker/service_version.txt` file on the main branch. This workflow will: + * Run the full unit test suite, to prevent publication of broken code. + * Extract the semantic version number from `docker/service_version.txt`. + * Extract the released notes for the most recent version from `CHANGELOG.md`. + * Build and deploy a this service's docker image to `ghcr.io`. + * Build the library package to be published to PyPI. + * Publish the package to PyPI. + * Publish a GitHub release under the semantic version number, with associated + git tag. + -The `publish_docker_image.yml` workflow will: +## Releasing -* Run the full unit test suite, to prevent publication of broken code. -* Extract the semantic version number from `docker/service_version.txt`. -* Extract the released notes for the most recent version from `CHANGELOG.md`. -* Create a GitHub release that will also tag the related git commit with the - semantic version number. -* Build and deploy a this service's docker image to `ghcr.io`. +A release consists of a new version hybig-py library published to PyPI and a +new Docker service image published to github's container repository. -Before triggering a release, ensure both the `docker/service_version.txt` and -`CHANGELOG.md` files are updated. The `CHANGELOG.md` file requires a specific -format for a new release, as it looks for the following string to define the -newest release of the code (starting at the top of the file). +A release is made automatically when a commit to the main branch contains a +changes in the `docker/service_version.txt` file, see the [publish_release](#release-workflow) workflow in the CI/CD section above. + +Before merging a PR that will trigger a release, ensure these two files are updated: + +* `CHANGELOG.md` - Notes should be added to capture the changes to the service. +* `docker/service_version.txt` - The semantic version number should be updated. + +The `CHANGELOG.md` file requires a specific format for a new release, as it +looks for the following string to define the newest release of the code +(starting at the top of the file). + +``` +## [vX.Y.Z] - YYYY-MM-DD +``` +Where the markdown reference needs to be updated at the bottom of the file following the existing pattern. ``` -## vX.Y.Z - YYYY-MM-DD +[unreleased]:https://github.com/nasa/harmony-browse-image-generator/compare/X.Y.Z..HEAD +[vX.Y.Z]:https://github.com/nasa/harmony-browse-image-generator/compare/X.Y.Y..X.Y.Z ``` ### pre-commit hooks: diff --git a/docker/service.Dockerfile b/docker/service.Dockerfile index cad007d..660c591 100644 --- a/docker/service.Dockerfile +++ b/docker/service.Dockerfile @@ -11,6 +11,8 @@ # 2023-04-04: Updated for HyBIG. # 2023-04-23: Updated conda clean and pip install to keep Docker image slim. # 2024-06-18: Updates to remove conda dependency. +# 2024-07-30: Updates to handle separate service an science code directories +# and updates the entrypoint of the new service container # ############################################################################### FROM python:3.11 @@ -28,10 +30,11 @@ RUN pip install --no-input --no-cache-dir \ -r pip_requirements_skip_snyk.txt # Copy service code. -COPY ./harmony_browse_image_generator harmony_browse_image_generator +COPY ./harmony_service harmony_service +COPY ./hybig hybig # Set GDAL related environment variables. ENV CPL_ZIP_ENCODING=UTF-8 # Configure a container to be executable via the `docker run` command. -ENTRYPOINT ["python", "-m", "harmony_browse_image_generator"] +ENTRYPOINT ["python", "-m", "harmony_service"] diff --git a/docker/service_version.txt b/docker/service_version.txt index 23aa839..227cea2 100644 --- a/docker/service_version.txt +++ b/docker/service_version.txt @@ -1 +1 @@ -1.2.2 +2.0.0 diff --git a/harmony_browse_image_generator/exceptions.py b/harmony_browse_image_generator/exceptions.py deleted file mode 100644 index 4e9170f..0000000 --- a/harmony_browse_image_generator/exceptions.py +++ /dev/null @@ -1,36 +0,0 @@ -""" Module defining custom exceptions, designed to return user-friendly error - messaging to the end-user. - -""" - -from harmony.util import HarmonyException - -SERVICE_NAME = 'harmony-browse-image-generator' - - -class HyBIGError(HarmonyException): - """Base service exception.""" - - def __init__(self, message=None): - super().__init__(message, SERVICE_NAME) - - -class HyBIGNoColorInformation(HarmonyException): - """Used to describe missing color information.""" - - def __init__(self, message=None): - super().__init__(message, SERVICE_NAME) - - -class HyBIGInvalidMessageError(HarmonyException): - """Input Harmony Message could not be used as presented.""" - - def __init__(self, message=None): - super().__init__(message, SERVICE_NAME) - - -class HyBIGValueError(HarmonyException): - """Input was incorrect for the routine.""" - - def __init__(self, message=None): - super().__init__(message, SERVICE_NAME) diff --git a/harmony_browse_image_generator/__init__.py b/harmony_service/__init__.py similarity index 100% rename from harmony_browse_image_generator/__init__.py rename to harmony_service/__init__.py diff --git a/harmony_browse_image_generator/__main__.py b/harmony_service/__main__.py similarity index 71% rename from harmony_browse_image_generator/__main__.py rename to harmony_service/__main__.py index 60ad288..5188729 100644 --- a/harmony_browse_image_generator/__main__.py +++ b/harmony_service/__main__.py @@ -1,19 +1,16 @@ -""" Run the Harmony Browse Image Generator Adapter via the Harmony CLI. """ +"""Run the Harmony Browse Image Generator Adapter via the Harmony CLI.""" from argparse import ArgumentParser from sys import argv from harmony import is_harmony_cli, run_cli, setup_cli -from harmony_browse_image_generator.adapter import BrowseImageGeneratorAdapter -from harmony_browse_image_generator.exceptions import SERVICE_NAME +from .adapter import BrowseImageGeneratorAdapter +from .exceptions import SERVICE_NAME def main(arguments: list[str]): - """Parse command line arguments and invoke the appropriate method to - respond to them - - """ + """Parse command line arguments and invoke the appropriate method.""" parser = ArgumentParser( prog=SERVICE_NAME, description='Run Harmony Browse Image Generator.' ) diff --git a/harmony_browse_image_generator/adapter.py b/harmony_service/adapter.py similarity index 89% rename from harmony_browse_image_generator/adapter.py rename to harmony_service/adapter.py index 5d6bdf3..0f4700a 100644 --- a/harmony_browse_image_generator/adapter.py +++ b/harmony_service/adapter.py @@ -1,9 +1,9 @@ -""" `HarmonyAdapter` for Harmony Browse Image Generator (HyBIG). +"""`HarmonyAdapter` for Harmony Browse Image Generator (HyBIG). - The class in this file is the top level of abstraction for a service that - will accept a GeoTIFF input and create a browse image (PNG/JPEG) and - accompanying ESRI world file. By default, this service will aim to create - Global Imagery Browse Services (GIBS) compatible browse imagery. +The class in this file is the top level of abstraction for a service that +will accept a GeoTIFF input and create a browse image (PNG/JPEG) and +accompanying ESRI world file. By default, this service will aim to create +Global Imagery Browse Services (GIBS) compatible browse imagery. """ @@ -23,21 +23,18 @@ from harmony.util import bbox_to_geometry, download, generate_output_filename, stage from pystac import Asset, Catalog, Item -from harmony_browse_image_generator.browse import create_browse_imagery -from harmony_browse_image_generator.color_utility import get_color_palette_from_item -from harmony_browse_image_generator.exceptions import HyBIGInvalidMessageError -from harmony_browse_image_generator.utilities import ( +from harmony_service.exceptions import HyBIGInvalidMessageError, HyBIGServiceError +from harmony_service.utilities import ( get_asset_name, get_file_mime_type, get_tiled_file_extension, ) +from hybig.browse import create_browse_imagery +from hybig.color_utility import get_color_palette_from_item class BrowseImageGeneratorAdapter(BaseHarmonyAdapter): - """This class extends the BaseHarmonyAdapter class from the - harmony-service-lib package to implement HyBIG operations. - - """ + """HyBIG extension to the harmony-service-lib BaseHarmonyAdapter.""" def invoke(self) -> Catalog: """Adds validation to process_item based invocations.""" @@ -91,7 +88,6 @@ def get_asset_from_item(self, item: Item) -> Asset: def process_item(self, item: Item, source: HarmonySource) -> Item: """Processes a single input STAC item.""" - try: working_directory = mkdtemp() results = item.clone() @@ -144,7 +140,7 @@ def process_item(self, item: Item, source: HarmonySource) -> Item: except Exception as exception: self.logger.exception(exception) - raise exception + raise HyBIGServiceError from exception finally: rmtree(working_directory) @@ -155,7 +151,6 @@ def stage_output(self, transformed_file: Path, input_file: str) -> str: message. """ - ext = get_tiled_file_extension(transformed_file) output_file_name = generate_output_filename(input_file, ext=ext) diff --git a/harmony_service/exceptions.py b/harmony_service/exceptions.py new file mode 100644 index 0000000..245f177 --- /dev/null +++ b/harmony_service/exceptions.py @@ -0,0 +1,17 @@ +"""Module defining harmony service errors raised by HyBIG service.""" + +from harmony.util import HarmonyException + +SERVICE_NAME = 'harmony-browse-image-generator' + + +class HyBIGServiceError(HarmonyException): + """Base service exception.""" + + def __init__(self, message=None): + """All service errors are assocated with SERVICE_NAME.""" + super().__init__(message=message, category=SERVICE_NAME) + + +class HyBIGInvalidMessageError(HyBIGServiceError): + """Input Harmony Message could not be used as presented.""" diff --git a/harmony_browse_image_generator/utilities.py b/harmony_service/utilities.py similarity index 89% rename from harmony_browse_image_generator/utilities.py rename to harmony_service/utilities.py index f8640d8..cb7e49f 100644 --- a/harmony_browse_image_generator/utilities.py +++ b/harmony_service/utilities.py @@ -1,4 +1,4 @@ -""" Module containing utility functionality. """ +"""Module containing service utility functionality.""" import re from mimetypes import guess_type as guess_mime_type @@ -15,9 +15,9 @@ def get_tiled_file_extension(file_name: Path) -> str: - """Return the correct extention to add to a staged file. + """Return the correct extension to add to a staged file. - Harmony's generate output filename can drop an extention incorrectly, so we + Harmony's generate output filename can drop an extension incorrectly, so we generate the correct one to pass in. """ @@ -34,7 +34,6 @@ def get_asset_name(name: str, url: str) -> str: dictionary. """ - tiled_pattern = r"\.(r\d+c\d+)\." tile_id = re.search(tiled_pattern, url) if tile_id is not None: diff --git a/hybig/__init__.py b/hybig/__init__.py new file mode 100644 index 0000000..85c0b32 --- /dev/null +++ b/hybig/__init__.py @@ -0,0 +1,5 @@ +"""Package containing core functionality for browse image generation.""" + +from .browse import create_browse + +__all__ = ['create_browse'] diff --git a/harmony_browse_image_generator/browse.py b/hybig/browse.py similarity index 74% rename from harmony_browse_image_generator/browse.py rename to hybig/browse.py index a867a60..dedac30 100644 --- a/harmony_browse_image_generator/browse.py +++ b/hybig/browse.py @@ -2,7 +2,7 @@ import re from itertools import zip_longest -from logging import Logger +from logging import Logger, getLogger from pathlib import Path import matplotlib @@ -22,7 +22,8 @@ from rioxarray import open_rasterio from xarray import DataArray -from harmony_browse_image_generator.color_utility import ( +from hybig.browse_utility import get_harmony_message_from_params +from hybig.color_utility import ( NODATA_IDX, NODATA_RGBA, OPAQUE, @@ -31,16 +32,117 @@ TRANSPARENT_RGBA, all_black_color_map, get_color_palette, + palette_from_remote_colortable, remove_alpha, ) -from harmony_browse_image_generator.exceptions import HyBIGError -from harmony_browse_image_generator.sizes import ( +from hybig.exceptions import HyBIGError +from hybig.sizes import ( GridParams, create_tiled_output_parameters, get_target_grid_parameters, ) +def create_browse( + source_tiff: str, + params: dict = None, + palette: str | ColorPalette | None = None, + logger: Logger = None, +) -> list[tuple[Path, Path, Path]]: + """Create browse imagery from an input geotiff. + + This is the exposed library function to allow users to create browse images + from the hybig-py library. It parses the input params and constructs the + correct Harmony input structure [Message.Format] to call the service's + entry point create_browse_imagery. + + Output images are created and deposited into the input GeoTIFF's directory. + + Args: + source_tiff: str, location of the input geotiff to process. + + params: [dict | None], A dictionary with the following keys: + + mime: [str], MIME type of the output image (default: 'image/png'). + any string that contains 'jpeg' will return a jpeg image, + otherwise create a png. + + crs: [dict | None], Target image's Coordinate Reference System. + A dictionary with 'epsg', 'proj4' or 'wkt' key. + + scale_extent: [dict | None], Scale Extents for the image. This dictionary + contains "x" and "y" keys each whose value which is a dictionary + of "min", "max" values in the same units as the crs. + e.g.: { "x": { "min": 0.5, "max": 125 }, + "y": { "min": 52, "max": 75.22 } } + + scale_size: [dict | None], Scale sizes for the image. The dictionary + contains "x" and "y" keys with the horizontal and veritcal + resolution in the same units as the crs. + e.g.: { "x": 10, "y": 10 } + + height: [int | None], height of the output image in gridcells. + + width: [int | none], width of the output image in gridcells. + + palette: [str | ColorPalette | none], either a URL to a remote color palette + that is fetched and loaded or a ColorPalette object used to color + the output browse image. If not provided, a grayscale image is + generated. + + logger: [Logger | None], a configured Logger object. If None a default + logger will be used. + + Note: + if supplied, scale_size, scale_extent, height and width must be + internally consistent. To define a valid output grid: + * Specify scale_extent and 1 of: + * height and width + * scale_sizes (in the x and y horizontal spatial dimensions) + * Specify all three of the above, but ensure values are consistent + with one another, noting that: + scale_size.x = (scale_extent.x.max - scale_extent.x.min) / width + scale_size.y = (scale_extent.y.max - scale_extent.y.min) / height + + Returns: + List of 3-element tuples. These are the file paths of: + - The output browse image + - Its associated ESRI world file (containing georeferencing information) + - The auxiliary XML file (containing duplicative georeferencing information) + + + Example Usage: + results = create_browse( + "/path/to/geotiff", + { + "mime": "image/png", + "crs": {"epsg": "EPSG:4326"}, + "scale_extent": { + "x": {"min": -180, "max": 180}, + "y": {"min": -90, "max": 90}, + }, + "scale_size": {"x": 10, "y": 10}, + }, + "https://remote-colortable", + logger, + ) + + """ + harmony_message = get_harmony_message_from_params(params) + + if logger is None: + logger = getLogger('hybig-py') + + if isinstance(palette, str): + color_palette = palette_from_remote_colortable(palette) + else: + color_palette = palette + + return create_browse_imagery( + harmony_message, source_tiff, HarmonySource({}), color_palette, logger + ) + + def create_browse_imagery( message: HarmonyMessage, input_file_path: str, @@ -50,8 +152,9 @@ def create_browse_imagery( ) -> list[tuple[Path, Path, Path]]: """Create browse image from input geotiff. - Take input browse image and return a 2-element tuple for the file paths - of the output browse image and its associated ESRI world file. + Take input browse image and return a 3-element tuple for the file paths of + the output browse image, its associated ESRI world file and the auxilary + xml file. """ output_driver = image_driver(message.format.mime) @@ -240,7 +343,7 @@ def prepare_raster_for_writing( def palettize_raster(raster: ndarray) -> tuple[ndarray, dict]: - """convert an RGB or RGBA image into a 1band image and palette. + """Convert an RGB or RGBA image into a 1band image and palette. Converts a 3 or 4 band np raster into a PIL image. Quantizes the image into a 1band raster with palette @@ -271,7 +374,8 @@ def add_alpha( alpha: ndarray | None, quantized_array: ndarray, color_map: dict ) -> tuple[ndarray, dict]: """If the input data had alpha values, manually set the quantized_image - index to the transparent index in those places.""" + index to the transparent index in those places. + """ if alpha is not None and np.any(alpha != OPAQUE): # Set any alpha to the transparent index value quantized_array = np.where(alpha != OPAQUE, TRANSPARENT_IDX, quantized_array) @@ -294,7 +398,7 @@ def get_color_map_from_image(image: Image) -> dict: def get_aux_xml_filename(image_filename: Path) -> Path: - """get aux.xml filenames.""" + """Get aux.xml filenames.""" return image_filename.with_suffix(image_filename.suffix + '.aux.xml') diff --git a/hybig/browse_utility.py b/hybig/browse_utility.py new file mode 100644 index 0000000..6dbd8cd --- /dev/null +++ b/hybig/browse_utility.py @@ -0,0 +1,34 @@ +"""Module containing utility functionality for browse generation.""" + +from harmony.message import Message as HarmonyMessage + + +def get_harmony_message_from_params(params: dict | None) -> HarmonyMessage: + """Constructs a harmony message from the input parms. + + We have to create a harmony message to pass to the create_browse_imagery + function so that both the library and service calls are identical. + + """ + if params is None: + params = {} + mime = params.get('mime', 'image/png') + crs = params.get('crs', None) + scale_extent = params.get('scale_extent', None) + scale_size = params.get('scale_size', None) + height = params.get('height', None) + width = params.get('width', None) + + return HarmonyMessage( + { + "format": { + "mime": mime, + "crs": crs, + "srs": crs, + "scaleExtent": scale_extent, + "scaleSize": scale_size, + "height": height, + "width": width, + }, + } + ) diff --git a/harmony_browse_image_generator/color_utility.py b/hybig/color_utility.py similarity index 97% rename from harmony_browse_image_generator/color_utility.py rename to hybig/color_utility.py index ce62d07..9b81c29 100644 --- a/harmony_browse_image_generator/color_utility.py +++ b/hybig/color_utility.py @@ -5,8 +5,6 @@ """ -from typing import TYPE_CHECKING - import numpy as np import requests from harmony.message import Source as HarmonySource @@ -14,7 +12,7 @@ from pystac import Item from rasterio.io import DatasetReader -from harmony_browse_image_generator.exceptions import ( +from hybig.exceptions import ( HyBIGError, HyBIGNoColorInformation, ) @@ -32,7 +30,7 @@ def remove_alpha(raster: np.ndarray) -> tuple[np.ndarray, np.ndarray, None]: - """remove alpha layer when it exists.""" + """Remove alpha layer when it exists.""" if raster.shape[0] == 4: return raster[0:3, :, :], raster[3, :, :] return raster, None diff --git a/harmony_browse_image_generator/crs.py b/hybig/crs.py similarity index 93% rename from harmony_browse_image_generator/crs.py rename to hybig/crs.py index efd8686..42ef015 100644 --- a/harmony_browse_image_generator/crs.py +++ b/hybig/crs.py @@ -17,7 +17,7 @@ from rasterio.crs import CRS from xarray import DataArray -from harmony_browse_image_generator.exceptions import HyBIGInvalidMessageError +from hybig.exceptions import HyBIGValueError # These are the CRSs that GIBS will accept as input. When the user hasn't # directly specified an output CRS, the code will attempt to choose the best @@ -49,7 +49,7 @@ def choose_crs_from_srs(srs: SRS): prefer epsg to wkt prefer wkt to proj4 - Raise an InvalidMessage error if the harmony SRS cannot be converted to a + Raise HyBIGValueError if the harmony SRS cannot be converted to a rasterio CRS for any reason. """ @@ -60,9 +60,7 @@ def choose_crs_from_srs(srs: SRS): return CRS.from_string(srs.wkt) return CRS.from_string(srs.proj4) except Exception as exception: - raise HyBIGInvalidMessageError( - f'Bad input SRS: {str(exception)}' - ) from exception + raise HyBIGValueError(f'Bad input SRS: {str(exception)}') from exception def is_preferred_crs(crs: CRS) -> bool: diff --git a/hybig/exceptions.py b/hybig/exceptions.py new file mode 100644 index 0000000..dd277ea --- /dev/null +++ b/hybig/exceptions.py @@ -0,0 +1,17 @@ +"""Module defining custom exceptions.""" + + +class HyBIGError(Exception): + """Base error class for exceptions rasied by HyBIG library.""" + + def __init__(self, message=None): + """All HyBIG errors have a message field.""" + self.message = message + + +class HyBIGNoColorInformation(HyBIGError): + """Used to describe missing color information.""" + + +class HyBIGValueError(HyBIGError): + """Input was incorrect for the routine.""" diff --git a/harmony_browse_image_generator/sizes.py b/hybig/sizes.py similarity index 97% rename from harmony_browse_image_generator/sizes.py rename to hybig/sizes.py index f6e9473..6464f9a 100644 --- a/harmony_browse_image_generator/sizes.py +++ b/hybig/sizes.py @@ -15,21 +15,15 @@ from affine import Affine from harmony.message import Message from harmony.message_utility import has_dimensions, has_scale_extents, has_scale_sizes -from pyproj import Transformer -from pyproj.crs import CRS as pyCRS # pylint: disable-next=no-name-in-module from rasterio.crs import CRS from rasterio.transform import AffineTransformer, from_bounds, from_origin from xarray import DataArray -from harmony_browse_image_generator.crs import ( - PREFERRED_CRS, - choose_best_crs_from_metadata, +from hybig.crs import ( choose_target_crs, - is_preferred_crs, ) -from harmony_browse_image_generator.exceptions import HyBIGValueError class GridParams(TypedDict): @@ -281,7 +275,7 @@ def create_tiled_output_parameters( def compute_tile_dimensions(origins: list[int]) -> list[int]: - """return a list of tile dimensions. + """Return a list of tile dimensions. From a list of origin locations, return the dimension for each tile. @@ -290,7 +284,7 @@ def compute_tile_dimensions(origins: list[int]) -> list[int]: def compute_tile_boundaries(target_size: int, full_size: int) -> list[int]: - """returns a list of boundary cells. + """Returns a list of boundary cells. The returned boundary cells are the column [or row] values for each of the output tiles. They should always start at 0, and end at the full_size @@ -308,7 +302,7 @@ def compute_tile_boundaries(target_size: int, full_size: int) -> list[int]: def get_cells_per_tile() -> int: - """optimum cells per tile. + """Optimum cells per tile. From discussions this is chosen to be 4096, so that any image that is tiled will end up with 4096x4096 gridcell tiles. diff --git a/pip_requirements.txt b/pip_requirements.txt index cbaf9cd..2642ee3 100644 --- a/pip_requirements.txt +++ b/pip_requirements.txt @@ -1,8 +1,8 @@ harmony-service-lib~=1.0.27 -pystac~=0.5.6 matplotlib==3.9.0 -rasterio==1.3.10 -rioxarray==0.15.5 numpy==1.26.4 pillow==10.3.0 pyproj==3.6.1 +pystac~=0.5.6 +rasterio==1.3.10 +rioxarray==0.15.5 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e1ea909 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[project] +name = "hybig-py" +dynamic = ["dependencies", "version"] + +authors = [ + {name="Matt Savoie", email="savoie@colorado.edu"}, + {name="Owen Littlejohns", email="owen.m.littlejohns@nasa.gov"}, +] + +maintainers = [ + {name="Matt Savoie", email="savoie@colorado.edu"}, + {name="Owen Littlejohns", email="owen.m.littlejohns@nasa.gov"}, +] + +description = "Python package designed to produce browse imagery compatible with NASA's Global Image Browse Services (GIBS)." + +readme = "README.md" +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", +] + +[project.urls] +Homepage = "https://github.com/nasa/harmony-browse-image-generator" +Issues = "https://github.com/nasa/harmony-browse-image-generator/issues" + +[build-system] +requires = ["hatchling", "hatch-requirements-txt"] +build-backend = "hatchling.build" + +[tool.hatch.metadata.hooks.requirements_txt] +files = [ + "pip_requirements.txt", + "pip_requirements_skip_snyk.txt" +] +[tool.hatch.version] +path = "docker/service_version.txt" +pattern= '^v?(?P.*)$' + + +[tool.hatch.build.targets.sdist] +include = [ + "hybig/*.py" +] +exclude = [ + ".*", +] + +[tool.hatch.build.targets.wheel] +packages=["hybig"] diff --git a/tests/pip_test_requirements.txt b/tests/pip_test_requirements.txt index 0cf95be..f9d53bd 100644 --- a/tests/pip_test_requirements.txt +++ b/tests/pip_test_requirements.txt @@ -1,4 +1,5 @@ -coverage~=7.2.2 -pycodestyle~=2.10.0 -pylint~=2.17.2 +coverage~=7.6.0 +pycodestyle~=2.12.0 +pylint~=3.2.6 +pytest~=8.3.1 unittest-xml-reporting~=3.2.0 diff --git a/tests/run_tests.sh b/tests/run_tests.sh index a821522..ecc70c5 100755 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -8,6 +8,10 @@ # 2020-05-07: Adapted from SwotRepr project. # 2022-01-03: Removed safety checks, as these are now run in Snyk. # 2023-04-04: Updated for use with the Harmony Browse Image Generator (HyBIG). +# 2024-07-30: Changes coverage to use pytest and output a unified +# result. xmlrunner was unable to handle finding the tests in the separate +# locations. Also use a relative path to the html output so that coverages can be +# run outside of docker. # ############################################################################### @@ -17,7 +21,7 @@ STATUS=0 export HDF5_DISABLE_VERSION_CHECK=1 # Run the standard set of unit tests, producing JUnit compatible output -coverage run -m xmlrunner discover tests -o tests/reports +coverage run -m pytest tests --junitxml=tests/reports/test-results-"$(date +'%Y%m%d%H%M%S')".xml RESULT=$? if [ "$RESULT" -ne "0" ]; then @@ -28,14 +32,14 @@ fi echo "\n" echo "Test Coverage Estimates" coverage report --omit="tests/*" -coverage html --omit="tests/*" -d /home/tests/coverage +coverage html --omit="tests/*" -d tests/coverage # Run pylint # Ignored errors/warnings: # W1203 - use of f-strings in log statements. This warning is leftover from # using ''.format() vs % notation. For more information, see: # https://github.com/PyCQA/pylint/issues/2354#issuecomment-414526879 -pylint harmony_browse_image_generator --disable=W1203 +pylint hybig harmony_service --disable=W1203 RESULT=$? RESULT=$((3 & $RESULT)) diff --git a/tests/test_code_format.py b/tests/test_code_format.py index 27f825b..0c2a3c8 100644 --- a/tests/test_code_format.py +++ b/tests/test_code_format.py @@ -1,3 +1,6 @@ +"""Ensure code formatting.""" + +from itertools import chain from pathlib import Path from unittest import TestCase @@ -19,15 +22,14 @@ class TestCodeFormat(TestCase): from PEP8 for these errors. """ - @classmethod - def setUpClass(cls): - cls.python_files = Path('harmony_browse_image_generator').rglob('*.py') - def test_pycodestyle_adherence(self): - """Ensure all code in the `harmony_browse_image_generator` directory - adheres to PEP8 defined standard. + """Check files for PEP8 compliance.""" + python_files = chain( + Path('hybig').rglob('*.py'), + Path('harmony_service').rglob('*.py'), + Path('tests').rglob('*.py'), + ) - """ style_guide = StyleGuide(ignore=['E501', 'W503', 'E203', 'E701']) - results = style_guide.check_files(self.python_files) + results = style_guide.check_files(python_files) self.assertEqual(results.total_errors, 0, 'Found code style issues.') diff --git a/tests/test_adapter.py b/tests/test_service/test_adapter.py similarity index 94% rename from tests/test_adapter.py rename to tests/test_service/test_adapter.py index 454e23d..e078259 100644 --- a/tests/test_adapter.py +++ b/tests/test_service/test_adapter.py @@ -1,4 +1,4 @@ -""" End-to-end tests of the Harmony Browse Image Generator (HyBIG). """ +"""End-to-end tests of the Harmony Browse Image Generator (HyBIG).""" from pathlib import Path from shutil import copy, rmtree @@ -14,8 +14,8 @@ from rasterio.warp import Resampling from rioxarray import open_rasterio -from harmony_browse_image_generator.adapter import BrowseImageGeneratorAdapter -from harmony_browse_image_generator.browse import ( +from harmony_service.adapter import BrowseImageGeneratorAdapter +from hybig.browse import ( convert_mulitband_to_raster, prepare_raster_for_writing, ) @@ -23,7 +23,7 @@ class TestAdapter(TestCase): - """A class testing the harmony_browse_image_generator.adapter module.""" + """A class testing the harmony_service.adapter module.""" @classmethod def setUpClass(cls): @@ -32,7 +32,7 @@ def setUpClass(cls): cls.granule_url = 'https://www.example.com/input.tiff' cls.input_stac = create_stac(Granule(cls.granule_url, 'image/tiff', ['data'])) cls.staging_location = 's3://example-bucket' - cls.fixtures = Path(__file__).resolve().parent / 'fixtures' + cls.fixtures = Path(__file__).resolve().parent.parent / 'fixtures' cls.red_tif_fixture = cls.fixtures / 'red.tif' cls.user = 'blightyear' @@ -98,11 +98,11 @@ def assert_expected_output_catalog( }, ) - @patch('harmony_browse_image_generator.browse.reproject') - @patch('harmony_browse_image_generator.adapter.rmtree') - @patch('harmony_browse_image_generator.adapter.mkdtemp') - @patch('harmony_browse_image_generator.adapter.download') - @patch('harmony_browse_image_generator.adapter.stage') + @patch('hybig.browse.reproject') + @patch('harmony_service.adapter.rmtree') + @patch('harmony_service.adapter.mkdtemp') + @patch('harmony_service.adapter.download') + @patch('harmony_service.adapter.stage') def test_valid_request_jpeg( self, mock_stage, mock_download, mock_mkdtemp, mock_rmtree, mock_reproject ): @@ -137,7 +137,7 @@ def test_valid_request_jpeg( mock_mkdtemp.return_value = self.temp_dir def move_tif(*args, **kwargs): - """copy fixture tiff to download location.""" + """Copy fixture tiff to download location.""" copy(self.red_tif_fixture, expected_downloaded_file) return expected_downloaded_file @@ -333,11 +333,11 @@ def move_tif(*args, **kwargs): # Ensure container clean-up was requested: mock_rmtree.assert_called_once_with(self.temp_dir) - @patch('harmony_browse_image_generator.browse.reproject') - @patch('harmony_browse_image_generator.adapter.rmtree') - @patch('harmony_browse_image_generator.adapter.mkdtemp') - @patch('harmony_browse_image_generator.adapter.download') - @patch('harmony_browse_image_generator.adapter.stage') + @patch('hybig.browse.reproject') + @patch('harmony_service.adapter.rmtree') + @patch('harmony_service.adapter.mkdtemp') + @patch('harmony_service.adapter.download') + @patch('harmony_service.adapter.stage') def test_valid_request_png( self, mock_stage, mock_download, mock_mkdtemp, mock_rmtree, mock_reproject ): @@ -372,7 +372,7 @@ def test_valid_request_png( mock_mkdtemp.return_value = self.temp_dir def move_tif(*args, **kwargs): - """copy fixture tiff to download location.""" + """Copy fixture tiff to download location.""" copy(self.red_tif_fixture, expected_downloaded_file) return expected_downloaded_file diff --git a/tests/unit/test_adapter.py b/tests/test_service/unit/test_adapter_unit.py similarity index 96% rename from tests/unit/test_adapter.py rename to tests/test_service/unit/test_adapter_unit.py index 2a34b84..39e358b 100644 --- a/tests/unit/test_adapter.py +++ b/tests/test_service/unit/test_adapter_unit.py @@ -6,13 +6,13 @@ from harmony.util import config from pystac import Asset, Item -from harmony_browse_image_generator.adapter import BrowseImageGeneratorAdapter -from harmony_browse_image_generator.exceptions import HyBIGInvalidMessageError +from harmony_service.adapter import BrowseImageGeneratorAdapter +from harmony_service.exceptions import HyBIGInvalidMessageError from tests.utilities import Granule, create_stac class TestAdapter(TestCase): - """A class testing the harmony_browse_image_generator.adapter module.""" + """A class testing the harmony_service.adapter module.""" @classmethod def setUpClass(cls): diff --git a/tests/unit/test_browse.py b/tests/unit/test_browse.py index 9ea1293..cbebb5a 100644 --- a/tests/unit/test_browse.py +++ b/tests/unit/test_browse.py @@ -1,13 +1,14 @@ -""" Unit tests for browse module. """ +"""Unit tests for browse module.""" import shutil import tempfile -from logging import getLogger +from logging import Logger, getLogger from pathlib import Path from unittest import TestCase from unittest.mock import MagicMock, Mock, call, patch import numpy as np +from harmony.message import SRS from harmony.message import Message as HarmonyMessage from harmony.message import Source as HarmonySource from numpy.testing import assert_array_equal @@ -20,9 +21,10 @@ from rasterio.warp import Resampling from xarray import DataArray -from harmony_browse_image_generator.browse import ( +from hybig.browse import ( convert_mulitband_to_raster, convert_singleband_to_raster, + create_browse, create_browse_imagery, get_color_map_from_image, get_tiled_filename, @@ -33,19 +35,19 @@ validate_file_crs, validate_file_type, ) -from harmony_browse_image_generator.color_utility import ( +from hybig.color_utility import ( OPAQUE, TRANSPARENT, convert_colormap_to_palette, get_color_palette, palette_from_remote_colortable, ) -from harmony_browse_image_generator.exceptions import HyBIGError +from hybig.exceptions import HyBIGError from tests.unit.utility import rasterio_test_file class TestBrowse(TestCase): - """A class testing the harmony_browse_image_generator.browse module.""" + """A class testing the hybig.browse module.""" @classmethod def setUpClass(cls): @@ -146,9 +148,9 @@ def test_create_browse_imagery_with_single_band_raster(self): message, test_tif_filename, HarmonySource({}), None, None ) - @patch('harmony_browse_image_generator.browse.reproject') + @patch('hybig.browse.reproject') @patch('rasterio.open') - @patch('harmony_browse_image_generator.browse.open_rasterio') + @patch('hybig.browse.open_rasterio') def test_create_browse_imagery_with_mocks( self, rioxarray_open_mock, rasterio_open_mock, reproject_mock ): @@ -304,7 +306,6 @@ def test_create_browse_imagery_with_mocks( def test_convert_singleband_to_raster_without_colortable(self): """Tests convert_gray_1band_to_raster.""" - return_data = np.copy(self.data).astype('float64') return_data[0][1] = np.nan ds = DataArray(return_data).expand_dims('band') @@ -548,7 +549,7 @@ def test_prepare_raster_for_writing_jpeg_4band(self): self.assertEqual(expected_color_map, actual_color_map) np.testing.assert_array_equal(expected_raster, actual_raster) - @patch('harmony_browse_image_generator.browse.palettize_raster') + @patch('hybig.browse.palettize_raster') def test_prepare_raster_for_writing_png_4band(self, palettize_mock): raster = self.random.integers(255, size=(4, 7, 8)) driver = 'PNG' @@ -557,11 +558,10 @@ def test_prepare_raster_for_writing_png_4band(self, palettize_mock): palettize_mock.assert_called_once_with(raster) - @patch('harmony_browse_image_generator.browse.Image') - @patch('harmony_browse_image_generator.browse.get_color_map_from_image') + @patch('hybig.browse.Image') + @patch('hybig.browse.get_color_map_from_image') def test_palettize_raster_no_alpha_layer(self, get_color_map_mock, image_mock): """Test that the quantize function is called by a correct image.""" - raster = self.random.integers(255, dtype='uint8', size=(3, 10, 11)) quantized_output = Image.fromarray( @@ -580,11 +580,10 @@ def test_palettize_raster_no_alpha_layer(self, get_color_map_mock, image_mock): np.testing.assert_array_equal(expected_out_raster, out_raster) - @patch('harmony_browse_image_generator.browse.Image') - @patch('harmony_browse_image_generator.browse.get_color_map_from_image') + @patch('hybig.browse.Image') + @patch('hybig.browse.get_color_map_from_image') def test_palettize_raster_with_alpha_layer(self, get_color_map_mock, image_mock): """Test that the quantize function is called by a correct image.""" - raster = self.random.integers(255, dtype='uint8', size=(4, 10, 11)) # No transparent pixels raster[3, :, :] = 255 @@ -714,7 +713,7 @@ def test_get_tiled_filename(self): self.assertEqual(expected_filename, actual_filename) def test_validate_file_crs_valid(self): - """valid file should return None.""" + """Valid file should return None.""" da = Mock(DataArray) da.rio.crs = CRS.from_epsg(4326) try: @@ -723,14 +722,14 @@ def test_validate_file_crs_valid(self): self.fail('Valid file threw unexpected exception.') def test_validate_file_crs_missing(self): - """invalid file should raise exception.""" + """Invalid file should raise exception.""" da = Mock(DataArray) da.rio.crs = None with self.assertRaisesRegex(HyBIGError, 'Input geotiff must have defined CRS.'): validate_file_crs(da) def test_validate_file_type_valid(self): - """validation should not raise exception.""" + """Validation should not raise exception.""" ds = Mock(DatasetReader) ds.driver = 'GTiff' try: @@ -747,7 +746,7 @@ def test_validate_file_type_invalid(self): ): validate_file_type(ds) - @patch('harmony_browse_image_generator.color_utility.requests.get') + @patch('hybig.color_utility.requests.get') def test_palette_from_remote_colortable(self, mock_get): with self.subTest('successful retrieval of colortable'): returned_colortable = ( @@ -784,3 +783,94 @@ def test_palette_from_remote_colortable(self, mock_get): ' http://this-domain-does-not-exist.com/bad-url' ), ) + + +class TestCreateBrowse(TestCase): + """A class testing the create_browse function call. + + Ensure library calls the `create_browse_imagery` function the same as the + service. + + """ + + @patch('hybig.browse.create_browse_imagery') + def test_calls_create_browse_with_correct_params(self, mock_create_browse_imagery): + """Ensure correct harmony message is created from inputs.""" + source_tiff = '/Path/to/source.tiff' + params = { + 'mime': 'image/png', + 'crs': {'epsg': 'EPSG:4326'}, + 'scale_extent': { + 'x': {'min': -180, 'max': 180}, + 'y': {'min': -90, 'max': 90}, + }, + 'scale_size': {'x': 10, 'y': 10}, + } + mock_logger = MagicMock(spec=Logger) + mock_palette = MagicMock(spec=ColorPalette) + + create_browse(source_tiff, params, mock_palette, mock_logger) + + mock_create_browse_imagery.assert_called_once() + call_args = mock_create_browse_imagery.call_args[0] + self.assertIsInstance(call_args[0], HarmonyMessage) + self.assertEqual(call_args[1], source_tiff) + self.assertIsInstance(call_args[2], HarmonySource) + self.assertEqual(call_args[3], mock_palette) + self.assertEqual(call_args[4], mock_logger) + + # verify message params. + harmony_message = call_args[0] + harmony_format = harmony_message.format + + # HarmonyMessage.Format does not have a json representation to compare + # to so compare the pieces individually. + self.assertEqual(harmony_format.mime, "image/png") + self.assertEqual(harmony_format['crs'], {"epsg": "EPSG:4326"}) + self.assertEqual(harmony_format['srs'], {"epsg": "EPSG:4326"}) + self.assertEqual( + harmony_format['scaleExtent'], + { + "x": {"min": -180, "max": 180}, + "y": {"min": -90, "max": 90}, + }, + ) + self.assertEqual(harmony_format['scaleSize'], {"x": 10, "y": 10}) + self.assertIsNone(harmony_message['format']['height']) + self.assertIsNone(harmony_message['format']['width']) + + @patch('hybig.browse.palette_from_remote_colortable') + @patch('hybig.browse.create_browse_imagery') + def test_calls_create_browse_with_remote_palette( + self, mock_create_browse_imagery, mock_palette_from_remote_color_table + ): + """Ensure remote palette is used.""" + mock_palette = MagicMock(sepc=ColorPalette) + mock_palette_from_remote_color_table.return_value = mock_palette + remote_color_url = 'https://path/to/colormap.txt' + source_tiff = '/Path/to/source.tiff' + mock_logger = MagicMock(spec=Logger) + + # Act + create_browse(source_tiff, {}, remote_color_url, mock_logger) + + # Assert a remote colortable was fetched. + mock_palette_from_remote_color_table.assert_called_once_with(remote_color_url) + + mock_create_browse_imagery.assert_called_once() + ( + call_harmony_message, + call_source_tiff, + call_harmony_source, + call_color_palette, + call_logger, + ) = mock_create_browse_imagery.call_args[0] + + # create_browse_imagery called with the color palette returned from + # palette_from_remote_colortable + self.assertEqual(call_color_palette, mock_palette) + + self.assertIsInstance(call_harmony_message, HarmonyMessage) + self.assertIsInstance(call_harmony_source, HarmonySource) + self.assertEqual(call_source_tiff, source_tiff) + self.assertEqual(call_logger, mock_logger) diff --git a/tests/unit/test_color_utility.py b/tests/unit/test_color_utility.py index 9de7db6..2f65870 100644 --- a/tests/unit/test_color_utility.py +++ b/tests/unit/test_color_utility.py @@ -8,13 +8,13 @@ from rasterio import DatasetReader from requests import Response -from harmony_browse_image_generator.color_utility import ( +from hybig.color_utility import ( convert_colormap_to_palette, get_color_palette, get_color_palette_from_item, get_remote_palette_from_source, ) -from harmony_browse_image_generator.exceptions import ( +from hybig.exceptions import ( HyBIGError, HyBIGNoColorInformation, ) @@ -44,9 +44,7 @@ def setUp(self): props = {} self.item = Item('id', geometry, bbox, date, props) - @patch( - 'harmony_browse_image_generator.color_utility.palette_from_remote_colortable' - ) + @patch('hybig.color_utility.palette_from_remote_colortable') def test_get_color_palette_from_item_with_no_assets( self, palette_from_remote_colortable_mock ): @@ -54,9 +52,7 @@ def test_get_color_palette_from_item_with_no_assets( self.assertIsNone(actual) palette_from_remote_colortable_mock.assert_not_called() - @patch( - 'harmony_browse_image_generator.color_utility.palette_from_remote_colortable' - ) + @patch('hybig.color_utility.palette_from_remote_colortable') def test_get_color_palette_from_item_no_palette_asset( self, palette_from_remote_colortable_mock ): @@ -67,9 +63,7 @@ def test_get_color_palette_from_item_no_palette_asset( self.assertIsNone(actual) palette_from_remote_colortable_mock.assert_not_called() - @patch( - 'harmony_browse_image_generator.color_utility.palette_from_remote_colortable' - ) + @patch('hybig.color_utility.palette_from_remote_colortable') def test_get_color_palette_from_item_palette_asset(self, palette_from_remote_mock): asset = Asset('data href', roles=['data']) palette_asset = Asset('palette href', roles=['palette']) @@ -85,7 +79,7 @@ def test_get_color_palette_from_item_palette_asset(self, palette_from_remote_moc palette_from_remote_mock.assert_called_once_with('palette href') self.assertEqual(expected_palette, actual) - @patch('harmony_browse_image_generator.color_utility.requests.get') + @patch('hybig.color_utility.requests.get') def test_get_color_palette_from_item_palette_asset_fails(self, get_mock): """Raise exception if there is a colortable, but it cannot be retrieved.""" asset = Asset('data href', roles=['data']) @@ -104,9 +98,7 @@ def test_get_color_palette_from_item_palette_asset_fails(self, get_mock): ): get_color_palette_from_item(self.item) - @patch( - 'harmony_browse_image_generator.color_utility.palette_from_remote_colortable' - ) + @patch('hybig.color_utility.palette_from_remote_colortable') def test_get_remote_palette_from_source(self, palette_from_remote_mock): with self.subTest('No variables in source'): test_source = HarmonySource({}) @@ -212,9 +204,7 @@ def test_get_remote_palette_from_source(self, palette_from_remote_mock): get_remote_palette_from_source(test_source) palette_from_remote_mock.reset_mock() - @patch( - 'harmony_browse_image_generator.color_utility.get_remote_palette_from_source' - ) + @patch('hybig.color_utility.get_remote_palette_from_source') def test_get_color_palette_with_item_palette( self, get_remote_palette_from_source_mock ): @@ -228,7 +218,7 @@ def test_get_color_palette_with_item_palette( get_remote_palette_from_source_mock.assert_not_called() ds.colormap.assert_not_called() - @patch('harmony_browse_image_generator.color_utility.requests.get') + @patch('hybig.color_utility.requests.get') def test_get_color_palette_request_fails(self, get_mock): failed_response = Mock(Response) failed_response.ok = False @@ -258,9 +248,7 @@ def test_get_color_palette_request_fails(self, get_mock): get_color_palette(ds, source, None) ds.colormap.assert_not_called() - @patch( - 'harmony_browse_image_generator.color_utility.palette_from_remote_colortable' - ) + @patch('hybig.color_utility.palette_from_remote_colortable') def test_get_color_palette_finds_no_url(self, palette_from_remote_mock): palette_from_remote_mock.side_effect = HyBIGError('mocked exception') ds = Mock(DatasetReader) @@ -287,9 +275,7 @@ def test_get_color_palette_finds_no_url(self, palette_from_remote_mock): get_color_palette(ds, source, None) palette_from_remote_mock.assert_called_once_with('url:of:colortable') - @patch( - 'harmony_browse_image_generator.color_utility.palette_from_remote_colortable' - ) + @patch('hybig.color_utility.palette_from_remote_colortable') def test_get_color_palette_source_remote_exists(self, palette_from_remote_mock): ds = Mock(DatasetReader) ds.colormap.return_value = self.colormap diff --git a/tests/unit/test_crs.py b/tests/unit/test_crs.py index 3fa14e2..baf4dc8 100644 --- a/tests/unit/test_crs.py +++ b/tests/unit/test_crs.py @@ -12,15 +12,15 @@ from rasterio.crs import CRS from rioxarray import open_rasterio -from harmony_browse_image_generator.crs import ( +from hybig.crs import ( PREFERRED_CRS, choose_best_crs_from_metadata, choose_target_crs, ) -from harmony_browse_image_generator.exceptions import HyBIGInvalidMessageError +from hybig.exceptions import HyBIGValueError from tests.unit.utility import rasterio_test_file -## Test constants +# Test constants WKT_EPSG_3031 = ( 'PROJCS["WGS 84 / Antarctic Polar Stereographic",' 'GEOGCS["WGS 84",DATUM["WGS_1984",' @@ -125,10 +125,10 @@ def test_choose_target_crs_with_proj4_from_harmony_message_and_empty_epsg(self): def test_choose_target_crs_with_invalid_SRS_from_harmony_message(self): """Test SRS does not have epsg, wkt or proj4 string.""" test_srs_is_json = {'how': 'did this happen?'} - with self.assertRaisesRegex(HyBIGInvalidMessageError, 'Bad input SRS'): + with self.assertRaisesRegex(HyBIGValueError, 'Bad input SRS'): choose_target_crs(test_srs_is_json, None) - @patch('harmony_browse_image_generator.crs.choose_crs_from_metadata') + @patch('hybig.crs.choose_crs_from_metadata') def test_choose_target_harmony_message_has_crs_but_no_srs(self, mock_choose_fxn): """Explicitly show we do not support format.crs only. diff --git a/tests/unit/test_sizes.py b/tests/unit/test_sizes.py index 4899850..dcfca1b 100644 --- a/tests/unit/test_sizes.py +++ b/tests/unit/test_sizes.py @@ -10,9 +10,8 @@ from rasterio.crs import CRS from rioxarray import open_rasterio -from harmony_browse_image_generator.crs import PREFERRED_CRS -from harmony_browse_image_generator.exceptions import HyBIGValueError -from harmony_browse_image_generator.sizes import ( +from hybig.crs import PREFERRED_CRS +from hybig.sizes import ( METERS_PER_DEGREE, ScaleExtent, best_guess_target_dimensions, @@ -120,7 +119,7 @@ def test_grid_parameters_from_harmony_message_has_complete_information(self): self.assertDictEqual(expected_parameters, actual_parameters) def test_grid_parameters_from_harmony_no_message_information(self): - """input granule is in preferred_crs on a 25km grid""" + """Input granule is in preferred_crs on a 25km grid""" crs = CRS.from_epsg(sp_seaice_grid['epsg']) height = sp_seaice_grid['height'] width = sp_seaice_grid['width'] @@ -255,14 +254,14 @@ def test_needs_tiling(self): self.assertFalse(needs_tiling(grid_parameters)) def test_get_cells_per_tile(self): - """test how tiles sizes are generated.""" + """Test how tiles sizes are generated.""" expected_cells_per_tile = self.CELLS_PER_TILE actual_cells_per_tile = get_cells_per_tile() self.assertEqual(expected_cells_per_tile, actual_cells_per_tile) self.assertIsInstance(actual_cells_per_tile, int) def test_compute_tile_boundaries_exact(self): - """tests subdivision of output image.""" + """Tests subdivision of output image.""" cells_per_tile = 10 full_width = 10 * 4 expected_origins = [0.0, 10.0, 20.0, 30.0, 40.0] @@ -272,7 +271,7 @@ def test_compute_tile_boundaries_exact(self): self.assertEqual(expected_origins, actual_origins) def test_compute_tile_boundaries_with_leftovers(self): - """tests subdivision of output image.""" + """Tests subdivision of output image.""" cells_per_tile = 10 full_width = 10 * 4 + 3 expected_origins = [0.0, 10.0, 20.0, 30.0, 40.0, 43.0] @@ -282,7 +281,7 @@ def test_compute_tile_boundaries_with_leftovers(self): self.assertEqual(expected_origins, actual_origins) def test_compute_tile_dimensions_uniform(self): - """test tile dimensions.""" + """Test tile dimensions.""" tile_origins = [0.0, 10.0, 20.0, 30.0, 40.0, 43.0] expected_dimensions = [10.0, 10.0, 10.0, 10.0, 3.0, 0.0] @@ -291,7 +290,7 @@ def test_compute_tile_dimensions_uniform(self): self.assertEqual(expected_dimensions, actual_dimensions) def test_compute_tile_dimensions_nonuniform(self): - """test tile dimensions.""" + """Test tile dimensions.""" tile_origins = [0.0, 20.0, 35.0, 40.0, 43.0] expected_dimensions = [20.0, 15.0, 5.0, 3.0, 0.0] @@ -299,8 +298,8 @@ def test_compute_tile_dimensions_nonuniform(self): self.assertEqual(expected_dimensions, actual_dimensions) - @patch('harmony_browse_image_generator.sizes.get_cells_per_tile') - @patch('harmony_browse_image_generator.sizes.needs_tiling') + @patch('hybig.sizes.get_cells_per_tile') + @patch('hybig.sizes.needs_tiling') def test_create_tile_output_parameters( self, needs_tiling_mock, cells_per_tile_mock ): @@ -425,7 +424,6 @@ def test_scale_extent_in_harmony_message(self): def test_scale_extent_from_input_image_and_no_crs_transformation(self): """Ensure no change of output extent when src_crs == target_crs""" - with open_rasterio( self.fixtures / 'RGB.byte.small.tif', mode='r', mask_and_scale=True ) as in_array: @@ -475,7 +473,7 @@ def test_message_has_scale_sizes(self): actual_dimensions = choose_target_dimensions(message, None, scale_extent, None) self.assertDictEqual(expected_dimensions, actual_dimensions) - @patch('harmony_browse_image_generator.sizes.best_guess_target_dimensions') + @patch('hybig.sizes.best_guess_target_dimensions') def test_message_has_no_information(self, mock_best_guess_target_dimensions): """Test message with no information gets sent to best guess.""" message = Message({}) @@ -489,7 +487,7 @@ def test_message_has_no_information(self, mock_best_guess_target_dimensions): dataset, scale_extent, target_crs ) - @patch('harmony_browse_image_generator.sizes.best_guess_target_dimensions') + @patch('hybig.sizes.best_guess_target_dimensions') def test_message_has_just_one_dimension(self, mock_best_guess_target_dimensions): """Message with only one dimension. diff --git a/tests/unit/test_utilities.py b/tests/unit/test_utilities.py index 1a0800f..8ea96c1 100644 --- a/tests/unit/test_utilities.py +++ b/tests/unit/test_utilities.py @@ -1,7 +1,7 @@ from pathlib import Path from unittest import TestCase -from harmony_browse_image_generator.utilities import ( +from harmony_service.utilities import ( get_asset_name, get_file_mime_type, get_tiled_file_extension, @@ -9,7 +9,7 @@ class TestUtilities(TestCase): - """A class testing the harmony_browse_image_generator.utilities module.""" + """A class testing the hybig.utilities module.""" def test_get_file_mime_type(self): """Ensure a MIME type can be retrieved from an input file path.""" @@ -26,8 +26,7 @@ def test_get_file_mime_type(self): self.assertIsNone(get_file_mime_type('file.xyzzyx')) def test_get_tiled_file_extension(self): - """ensure correct extensions are extracted""" - + """Ensure correct extensions are extracted""" test_params = [ (Path('/tmp/tmp4w/14316c44a.r00c02.png.aux.xml'), '.r00c02.png.aux.xml'), (Path('/tmp/tmp4w/14316c44a.png.aux.xml'), '.png.aux.xml'), @@ -45,8 +44,7 @@ def test_get_tiled_file_extension(self): self.assertEqual(expected_extension, actual_extension) def test_get_asset_name(self): - """ensure correct asset names are generated""" - + """Ensure correct asset names are generated""" test_params = [ ( ('name', 'https://tmp_bucket/tmp4w/14316c44a.r00c02.png.aux.xml'),