From f3c8910ff8d61ba88e34fb6de93efde4d0331965 Mon Sep 17 00:00:00 2001 From: Theo Satabin Date: Mon, 30 Sep 2024 10:13:31 +0200 Subject: [PATCH] * Ajout de la librairie de gestion d'un style ROK4 --- .github/labeler.yml | 32 +- .github/workflows/pr-auto-labeler.yml | 15 +- README.md | 27 ++ README.pypi.md | 17 + src/rok4/storage.py | 4 + src/rok4/style.py | 523 ++++++++++++++++++++++++++ tests/test_style.py | 477 +++++++++++++++++++++++ 7 files changed, 1076 insertions(+), 19 deletions(-) create mode 100644 src/rok4/style.py create mode 100644 tests/test_style.py diff --git a/.github/labeler.yml b/.github/labeler.yml index 75c5ceb..aff23b6 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,21 +1,33 @@ ci-cd: - - .github/**/* +- changed-files: + - any-glob-to-any-file: + - .github/** dependencies: - - requirements.txt - - requirements/*.txt +- changed-files: + - any-glob-to-any-file: + - requirements.txt + - requirements/*.txt documentation: - - docs/**/* +- changed-files: + - any-glob-to-any-file: + - docs/** enhancement: - - src/**/* +- changed-files: + - any-glob-to-any-file: + - src/** quality: - - tests/**/* +- changed-files: + - any-glob-to-any-file: + - tests/** tooling: - - .gitignore - - .pre-commit-config.yaml - - setup.cfg - - pyproject.toml +- changed-files: + - any-glob-to-any-file: + - .gitignore + - .pre-commit-config.yaml + - setup.cfg + - pyproject.toml diff --git a/.github/workflows/pr-auto-labeler.yml b/.github/workflows/pr-auto-labeler.yml index cc5272e..04e764c 100644 --- a/.github/workflows/pr-auto-labeler.yml +++ b/.github/workflows/pr-auto-labeler.yml @@ -1,15 +1,12 @@ name: "🏷 PR Labeler" on: - - pull_request - -permissions: - contents: read - pull-requests: write + - pull_request_target jobs: - triage: + labeler: + permissions: + contents: read + pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/labeler@v5 - with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" + - uses: actions/labeler@v5 diff --git a/README.md b/README.md index bad3a97..b1e31f9 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,33 @@ except Exception as exc: print(exc) ``` +Les variables d'environnement suivantes peuvent être nécessaires, par module : + +* `storage` : plus de détails dans la documentation technique du module + * `ROK4_READING_LRU_CACHE_SIZE` : Nombre d'élément dans le cache de lecture (0 pour ne pas avoir de limite) + * `ROK4_READING_LRU_CACHE_TTL` : Durée de validité d'un élément du cache, en seconde (0 pour ne pas avoir de limite) + * `ROK4_CEPH_CONFFILE` : Fichier de configuration du cluster Ceph + * `ROK4_CEPH_USERNAME` : Compte d'accès au cluster Ceph + * `ROK4_CEPH_CLUSTERNAME` : Nom du cluster Ceph + * `ROK4_S3_KEY` : Clé(s) de(s) serveur(s) S3 + * `ROK4_S3_SECRETKEY` : Clé(s) secrète(s) de(s) serveur(s) S3 + * `ROK4_S3_URL` : URL de(s) serveur(s) S3 + * `ROK4_SSL_NO_VERIFY` : Désactivation de la vérification SSL pour les accès S3 (n'importe quelle valeur non vide) +* `tile_matrix_set` : + * `ROK4_TMS_DIRECTORY` : Dossier racine (fichier ou objet) des tile matrix sets +* `style` : + * `ROK4_STYLES_DIRECTORY` : Dossier racine (fichier ou objet) des styles + +Readings uses a LRU cache system with a TTL. It's possible to configure it with environment variables : +- ROK4_READING_LRU_CACHE_SIZE : Number of cached element. Default 64. Set 0 or a negative integer to configure a cache without bound. A power of two make cache more efficient. +- ROK4_READING_LRU_CACHE_TTL : Validity duration of cached element, in seconds. Default 300. 0 or negative integer to get cache without expiration date. + +To disable cache (always read data on storage), set ROK4_READING_LRU_CACHE_SIZE to 1 and ROK4_READING_LRU_CACHE_TTL to 1. + +Using CEPH storage requires environment variables : + +Using S3 storage requires environment variables : + Plus d'exemple dans la documentation développeur. diff --git a/README.pypi.md b/README.pypi.md index d5e9d1a..bb69f2b 100644 --- a/README.pypi.md +++ b/README.pypi.md @@ -24,4 +24,21 @@ except Exception as exc: print(exc) ``` +Following environment variables could be used, by module : + +* `storage` : more details in the module developer documentation + * `ROK4_READING_LRU_CACHE_SIZE` : Cache size (0 for no limit) + * `ROK4_READING_LRU_CACHE_TTL` : Cache validity time (0 for no limit) + * `ROK4_CEPH_CONFFILE` : Ceph configuration file + * `ROK4_CEPH_USERNAME` : Ceph cluster user + * `ROK4_CEPH_CLUSTERNAME` : Ceph cluster name + * `ROK4_S3_KEY` : Key(s) for S3 server(s) + * `ROK4_S3_SECRETKEY` : Secret key(s) for S3 server(s) + * `ROK4_S3_URL` : URL(s) for S3 server(s) + * `ROK4_SSL_NO_VERIFY` : Disable SSL conrols for S3 access (any non empty value) +* `tile_matrix_set` : + * `ROK4_TMS_DIRECTORY` : Root directory (file or object) for tile matrix sets +* `style` : + * `ROK4_STYLES_DIRECTORY` : Root directory (file or object) for styles + More examples in the developer documentation diff --git a/src/rok4/storage.py b/src/rok4/storage.py index 03fa170..66bd295 100644 --- a/src/rok4/storage.py +++ b/src/rok4/storage.py @@ -1,6 +1,7 @@ """Provide functions to read or write data Available storage types are : + - S3 (path are preffixed with `s3://`) - CEPH (path are prefixed with `ceph://`) - FILE (path are prefixed with `file://`, but it is the default paths' interpretation) @@ -10,17 +11,20 @@ According to functions, all storage types are not necessarily available. Readings uses a LRU cache system with a TTL. It's possible to configure it with environment variables : + - ROK4_READING_LRU_CACHE_SIZE : Number of cached element. Default 64. Set 0 or a negative integer to configure a cache without bound. A power of two make cache more efficient. - ROK4_READING_LRU_CACHE_TTL : Validity duration of cached element, in seconds. Default 300. 0 or negative integer to get cache without expiration date. To disable cache (always read data on storage), set ROK4_READING_LRU_CACHE_SIZE to 1 and ROK4_READING_LRU_CACHE_TTL to 1. Using CEPH storage requires environment variables : + - ROK4_CEPH_CONFFILE - ROK4_CEPH_USERNAME - ROK4_CEPH_CLUSTERNAME Using S3 storage requires environment variables : + - ROK4_S3_KEY - ROK4_S3_SECRETKEY - ROK4_S3_URL diff --git a/src/rok4/style.py b/src/rok4/style.py new file mode 100644 index 0000000..1c1725f --- /dev/null +++ b/src/rok4/style.py @@ -0,0 +1,523 @@ +"""Provide classes to use a ROK4 style. + +The module contains the following classe: + +- `Style` - Style descriptor, to convert raster data + +Loading a style requires environment variables : + +- ROK4_STYLES_DIRECTORY +""" + +# -- IMPORTS -- + +# standard library +import json +import os +import re +from json.decoder import JSONDecodeError +from typing import Dict, List, Tuple + +# package +from rok4.exceptions import FormatError, MissingAttributeError, MissingEnvironmentError +from rok4.storage import get_data_str, exists +from rok4.enums import ColorFormat + +DEG_TO_RAD = 0.0174532925199432958 + +class Colour: + """A palette's RGBA colour. + + Attributes: + value (float): Value to convert to RGBA + red (int): Red value (from 0 to 255) + green (int): Green value (from 0 to 255) + blue (int): Blue value (from 0 to 255) + alpha (int): Alpha value (from 0 to 255) + """ + + def __init__(self, palette: Dict, style: "Style") -> None: + """Constructor method + + Args: + colour: Colour attributes, according to JSON structure + style: Style object containing the palette's colour to create + + Examples: + + JSON colour section + + { + "value": 600, + "red": 220, + "green": 179, + "blue": 99, + "alpha": 255 + } + + Raises: + MissingAttributeError: Attribute is missing in the content + Exception: Invalid colour's band + """ + + try: + self.value = palette["value"] + + self.red = palette["red"] + if self.red < 0 or self.red > 255: + raise Exception(f"In style '{style.path}', a palette colour band has an invalid value (integer between 0 and 255 expected)") + self.green = palette["green"] + if self.green < 0 or self.green > 255: + raise Exception(f"In style '{style.path}', a palette colour band has an invalid value (integer between 0 and 255 expected)") + self.blue = palette["blue"] + if self.blue < 0 or self.blue > 255: + raise Exception(f"In style '{style.path}', a palette colour band has an invalid value (integer between 0 and 255 expected)") + self.alpha = palette["alpha"] + if self.alpha < 0 or self.alpha > 255: + raise Exception(f"In style '{style.path}', a palette colour band has an invalid value (integer between 0 and 255 expected)") + + except KeyError as e: + raise MissingAttributeError(style.path, f"palette.colours[].{e}") + + except TypeError as e: + raise Exception(f"In style '{style.path}', a palette colour band has an invalid value (integer between 0 and 255 expected)") + + @property + def rgba(self) -> Tuple[int]: + return (self.red, self.green, self.blue, self.alpha) + + @property + def rgb(self) -> Tuple[int]: + return (self.red, self.green, self.blue) + + +class Palette: + """A style's RGBA palette. + + Attributes: + no_alpha (bool): Colour without alpha band + rgb_continuous (bool): Continuous RGB values ? + alpha_continuous (bool): Continuous alpha values ? + colours (List[Colour]): Palette's colours, input values ascending + """ + + def __init__(self, palette: Dict, style: "Style") -> None: + """Constructor method + + Args: + palette: Palette attributes, according to JSON structure + style: Style object containing the palette to create + + Examples: + + JSON palette section + + { + "no_alpha": false, + "rgb_continuous": true, + "alpha_continuous": true, + "colours": [ + { "value": -99999, "red": 255, "green": 255, "blue": 255, "alpha": 0 }, + { "value": -99998.1, "red": 255, "green": 255, "blue": 255, "alpha": 0 }, + { "value": -99998.0, "red": 255, "green": 0, "blue": 255, "alpha": 255 }, + { "value": -501, "red": 255, "green": 0, "blue": 255, "alpha": 255 }, + { "value": -500, "red": 1, "green": 29, "blue": 148, "alpha": 255 }, + { "value": -15, "red": 19, "green": 42, "blue": 255, "alpha": 255 }, + { "value": 0, "red": 67, "green": 105, "blue": 227, "alpha": 255 }, + { "value": 0.01, "red": 57, "green": 151, "blue": 105, "alpha": 255 }, + { "value": 300, "red": 230, "green": 230, "blue": 128, "alpha": 255 }, + { "value": 600, "red": 220, "green": 179, "blue": 99, "alpha": 255 }, + { "value": 2000, "red": 162, "green": 100, "blue": 51, "alpha": 255 }, + { "value": 2500, "red": 122, "green": 81, "blue": 40, "alpha": 255 }, + { "value": 3000, "red": 255, "green": 255, "blue": 255, "alpha": 255 }, + { "value": 9000, "red": 255, "green": 255, "blue": 255, "alpha": 255 }, + { "value": 9001, "red": 255, "green": 255, "blue": 255, "alpha": 255 } + ] + } + + Raises: + MissingAttributeError: Attribute is missing in the content + Exception: No colour in the palette or invalid colour + """ + + try: + self.no_alpha = palette["no_alpha"] + self.rgb_continuous = palette["rgb_continuous"] + self.alpha_continuous = palette["alpha_continuous"] + + self.colours = [] + for colour in palette["colours"]: + self.colours.append(Colour(colour, style)) + if len(self.colours) >= 2 and self.colours[-1].value <= self.colours[-2].value: + raise Exception(f"Style '{style.path}' palette colours hav eto be ordered input value ascending") + + if len(self.colours) == 0: + raise Exception(f"Style '{style.path}' palette has no colour") + + except KeyError as e: + raise MissingAttributeError(style.path, f"palette.{e}") + + def convert(self, value: float) -> Tuple[int]: + + # Les couleurs dans la palette sont rangées par valeur croissante + # On commence par gérer les cas où la valeur est en dehors de la palette + + if value <= self.colours[0].value: + if self.no_alpha: + return self.colours[0].rgb + else: + return self.colours[0].rgba + + if value >= self.colours[-1].value: + if self.no_alpha: + return self.colours[-1].rgb + else: + return self.colours[-1].rgba + + # On va maintenant chercher les deux couleurs entre lesquelles la valeur est + for i in range(1, len(self.colours)): + if self.colours[i].value < value: + continue + + # on est sur la première couleur de valeur supérieure + colour_inf = self.colours[i-1] + colour_sup = self.colours[i] + break + + ratio = (value - colour_inf.value) / (colour_sup.value - colour_inf.value) + if self.rgb_continuous: + pixel = ( + colour_inf.red + ratio * (colour_sup.red - colour_inf.red), + colour_inf.green + ratio * (colour_sup.green - colour_inf.green), + colour_inf.blue + ratio * (colour_sup.blue - colour_inf.blue) + ) + else: + pixel = (colour_inf.red, colour_inf.green, colour_inf.blue) + + if self.no_alpha: + return pixel + else: + if self.alpha_continuous: + return pixel + (colour_inf.alpha + ratio * (colour_sup.alpha - colour_inf.alpha),) + else: + return pixel + (colour_inf.alpha,) + +class Slope: + """A style's slope parameters. + + Attributes: + algo (str): Slope calculation algorithm chosen by the user ("H" for Horn) + unit (str): Slope unit + image_nodata (float): Nodata input value + slope_nodata (float): Nodata slope value + slope_max (float): Maximum value for the slope + """ + + def __init__(self, slope: Dict, style: "Style") -> None: + """Constructor method + + Args: + slope: Slope attributes, according to JSON structure + style: Style object containing the slope to create + + Examples: + + JSON pente section + + { + "algo": "H", + "unit": "degree", + "image_nodata": -99999, + "slope_nodata": 91, + "slope_max": 90 + } + + Raises: + MissingAttributeError: Attribute is missing in the content + """ + + try: + self.algo = slope.get("algo", "H") + self.unit = slope.get("unit", "degree") + self.image_nodata = slope.get("image_nodata", -99999) + self.slope_nodata = slope.get("slope_nodata", 0) + self.slope_max = slope.get("slope_max", 90) + except KeyError as e: + raise MissingAttributeError(style.path, f"pente.{e}") + +class Exposition: + """A style's exposition parameters. + + Attributes: + algo (str): Slope calculation algorithm chosen by the user ("H" for Horn) + min_slope (int): Slope from which exposition is computed + image_nodata (float): Nodata input value + exposition_nodata (float): Nodata exposition value + """ + + def __init__(self, exposition: Dict, style: "Style") -> None: + """Constructor method + + Args: + exposition: Exposition attributes, according to JSON structure + style: Style object containing the exposition to create + + Examples: + + JSON exposition section + + { + "algo": "H", + "min_slope": 1 + } + + Raises: + MissingAttributeError: Attribute is missing in the content + """ + + try: + self.algo = exposition.get("algo", "H") + self.min_slope = exposition.get("min_slope", 1.0) * DEG_TO_RAD + self.image_nodata = exposition.get("min_slope", -99999) + self.exposition_nodata = exposition.get("aspect_nodata", -1) + except KeyError as e: + raise MissingAttributeError(style.path, f"exposition.{e}") + + +class Estompage: + """A style's estompage parameters. + + Attributes: + zenith (float): Sun's zenith in degree + azimuth (float): Sun's azimuth in degree + z_factor (int): Slope exaggeration factor + image_nodata (float): Nodata input value + estompage_nodata (float): Nodata estompage value + """ + + def __init__(self, estompage: Dict, style: "Style") -> None: + """Constructor method + + Args: + estompage: Estompage attributes, according to JSON structure + style: Style object containing the estompage to create + + Examples: + + JSON estompage section + + { + "zenith": 45, + "azimuth": 315, + "z_factor": 1 + } + + Raises: + MissingAttributeError: Attribute is missing in the content + """ + + try: + # azimuth et azimuth sont converti en leur complémentaire en radian + self.zenith = (90. - estompage.get("zenith", 45)) * DEG_TO_RAD + self.azimuth = (360. - estompage.get("azimuth", 315)) * DEG_TO_RAD + self.z_factor = estompage.get("z_factor", 1) + self.image_nodata = estompage.get("image_nodata", -99999.0) + self.estompage_nodata = estompage.get("estompage_nodata", 0.0) + except KeyError as e: + raise MissingAttributeError(style.path, f"estompage.{e}") + +class Legend: + """A style's legend. + + Attributes: + format (str): Legend image's mime type + url (str): Legend image's url + height (int): Legend image's pixel height + width (int): Legend image's pixel width + min_scale_denominator (int): Minimum scale at which the legend is applicable + max_scale_denominator (int): Maximum scale at which the legend is applicable + """ + + def __init__(self, legend: Dict, style: "Style") -> None: + """Constructor method + + Args: + legend: Legend attributes, according to JSON structure + style: Style object containing the legend to create + + Examples: + + JSON legend section + + { + "format": "image/png", + "url": "http://ign.fr", + "height": 100, + "width": 100, + "min_scale_denominator": 0, + "max_scale_denominator": 30 + } + + Raises: + MissingAttributeError: Attribute is missing in the content + """ + + try: + self.format = legend["format"] + self.url = legend["url"] + self.height = legend["height"] + self.width = legend["width"] + self.min_scale_denominator = legend["min_scale_denominator"] + self.max_scale_denominator = legend["max_scale_denominator"] + except KeyError as e: + raise MissingAttributeError(style.path, f"legend.{e}") + +class Style: + """A raster data style + + Attributes: + path (str): TMS origin path (JSON) + id (str): Style's technical identifier + identifier (str): Style's public identifier + title (str): Style's title + abstract (str): Style's abstract + keywords (List[str]): Style's keywords + legend (Legend): Style's legend + + palette (Palette): Style's palette, optionnal + estompage (Estompage): Style's estompage parameters, optionnal + slope (Slope): Style's slope parameters, optionnal + exposition (Exposition): Style's exposition parameters, optionnal + + """ + + def __init__(self, id: str) -> None: + """Constructor method + + Style's directory is defined with environment variable ROK4_STYLES_DIRECTORY. Provided id is used as file/object name, with pr without JSON extension + + Args: + path: Style's id + + Raises: + MissingEnvironmentError: Missing object storage informations + StorageError: Storage read issue + FileNotFoundError: Style file or object does not exist, with or without extension + FormatError: Provided path is not a well formed JSON + MissingAttributeError: Attribute is missing in the content + Exception: No colour in the palette or invalid colour + """ + + self.id = id + + try: + self.path = os.path.join(os.environ["ROK4_STYLES_DIRECTORY"], f"{self.id}") + if not exists(self.path): + self.path = os.path.join(os.environ["ROK4_STYLES_DIRECTORY"], f"{self.id}.json") + if not exists(self.path): + raise FileNotFoundError(f"{self.path}, even without extension") + except KeyError as e: + raise MissingEnvironmentError(e) + + + try: + data = json.loads(get_data_str(self.path)) + + self.identifier = data["identifier"] + self.title = data["title"] + self.abstract = data["abstract"] + self.keywords = data["keywords"] + + self.legend = Legend(data["legend"], self) + + if "palette" in data: + self.palette = Palette(data["palette"], self) + else: + self.palette = None + + if "estompage" in data: + self.estompage = Estompage(data["estompage"], self) + else: + self.estompage = None + + if "pente" in data: + self.slope = Slope(data["pente"], self) + else: + self.slope = None + + if "exposition" in data: + self.exposition = Exposition(data["exposition"], self) + else: + self.exposition = None + + + except JSONDecodeError as e: + raise FormatError("JSON", self.path, e) + + except KeyError as e: + raise MissingAttributeError(self.path, e) + + @property + def bands(self) -> int: + """Bands count after style application + + Returns: + int: Bands count after style application, None if style is identity + """ + if self.palette is not None: + if self.palette.no_alpha: + return 3 + else: + return 4 + + elif self.estompage is not None or self.exposition is not None or self.slope is not None: + return 1 + + else: + return None + + @property + def format(self) -> ColorFormat: + """Bands format after style application + + Returns: + ColorFormat: Bands format after style application, None if style is identity + """ + if self.palette is not None: + return ColorFormat.UINT8 + + elif self.estompage is not None or self.exposition is not None or self.slope is not None: + return ColorFormat.FLOAT32 + + else: + return None + + @property + def input_nodata(self) -> float: + """Input nodata value + + Returns: + float: Input nodata value, None if style is identity + """ + + if self.estompage is not None: + return self.estompage.image_nodata + elif self.exposition is not None: + return self.exposition.image_nodata + elif self.slope is not None: + return self.slope.image_nodata + elif self.palette is not None: + return self.palette.colours[0].value + else: + return None + + @property + def is_identity(self) -> bool: + """Is style identity + + Returns: + bool: Is style identity + """ + + return self.estompage is None and self.exposition is None and self.slope is None and self.palette is None + diff --git a/tests/test_style.py b/tests/test_style.py new file mode 100644 index 0000000..82da869 --- /dev/null +++ b/tests/test_style.py @@ -0,0 +1,477 @@ +import os +from unittest import mock +from unittest.mock import * + +import pytest + +from rok4.exceptions import ( + FormatError, + MissingAttributeError, + MissingEnvironmentError, + StorageError, +) +from rok4.style import Style +from rok4.enums import ColorFormat + + +@mock.patch.dict(os.environ, {}, clear=True) +def test_missing_env(): + with pytest.raises(MissingEnvironmentError): + Style("normal") + + +@mock.patch.dict(os.environ, {"ROK4_STYLES_DIRECTORY": "file:///path/to"}, clear=True) +@mock.patch( + "rok4.style.exists", + return_value=False, +) +def test_wrong_file(mocked_exists): + with pytest.raises(FileNotFoundError): + Style("toto") + + mocked_exists.assert_has_calls([call("file:///path/to/toto"), call("file:///path/to/toto.json")]) + + +@mock.patch.dict(os.environ, {"ROK4_STYLES_DIRECTORY": "file:///path/to"}, clear=True) +@mock.patch( + "rok4.style.exists", + return_value=True, +) +@mock.patch( + "rok4.style.get_data_str", + return_value='"palette":"",}', +) +def test_bad_json(mocked_get_data_str, mocked_exists): + with pytest.raises(FormatError): + Style("normal") + mocked_get_data_str.assert_called_once_with("file:///path/to/normal") + + +@mock.patch.dict(os.environ, {"ROK4_STYLES_DIRECTORY": "file:///path/to"}, clear=True) +@mock.patch( + "rok4.style.exists", + return_value=True, +) +@mock.patch( + "rok4.style.get_data_str", + return_value=""" + { + "title": "Données Brutes", + "abstract": "Données brutes sans changement de palette", + "keywords": ["Défaut"], + "legend": { + "format": "image/png", + "url": "http://serveur.fr/image.png", + "height": 100, + "width": 100, + "min_scale_denominator": 0, + "max_scale_denominator": 30 + } + }""" +) +def test_missing_identifier(mocked_get_data_str, mocked_exists): + with pytest.raises(MissingAttributeError) as exc: + Style("normal") + assert str(exc.value) == "Missing attribute 'identifier' in 'file:///path/to/normal'" + mocked_get_data_str.assert_called_once_with("file:///path/to/normal") + + +@mock.patch.dict(os.environ, {"ROK4_STYLES_DIRECTORY": "file:///path/to"}, clear=True) +@mock.patch( + "rok4.style.exists", + return_value=True, +) +@mock.patch( + "rok4.style.get_data_str", + return_value=""" + { + "identifier": "normal", + "title": "Données Brutes", + "abstract": "Données brutes sans changement de palette", + "keywords": ["Défaut"], + "legend": { + "format": "image/png", + "url": "http://serveur.fr/image.png", + "height": 100, + "width": 100, + "min_scale_denominator": 0, + "max_scale_denominator": 30 + }, + "palette": { + "no_alpha": false, + "rgb_continuous": true, + "alpha_continuous": true, + "colours": [] + } + }""" +) +def test_palette_without_colour(mocked_get_data_str, mocked_exists): + with pytest.raises(Exception) as exc: + Style("normal") + assert str(exc.value) == "Style 'file:///path/to/normal' palette has no colour" + mocked_get_data_str.assert_called_once_with("file:///path/to/normal") + + + +@mock.patch.dict(os.environ, {"ROK4_STYLES_DIRECTORY": "file:///path/to"}, clear=True) +@mock.patch( + "rok4.style.exists", + return_value=True, +) +@mock.patch( + "rok4.style.get_data_str", + return_value=""" + { + "identifier": "normal", + "title": "Données Brutes", + "abstract": "Données brutes sans changement de palette", + "keywords": ["Défaut"], + "legend": { + "format": "image/png", + "url": "http://serveur.fr/image.png", + "height": 100, + "width": 100, + "min_scale_denominator": 0, + "max_scale_denominator": 30 + }, + "palette": { + "no_alpha": false, + "rgb_continuous": true, + "alpha_continuous": true, + "colours": [ + { "value": -99999, "red": 255, "green": 300, "blue": 255, "alpha": 0 } + ] + } + }""" +) +def test_palette_wrong_colour(mocked_get_data_str, mocked_exists): + with pytest.raises(Exception) as exc: + Style("normal") + assert str(exc.value) == "In style 'file:///path/to/normal', a palette colour band has an invalid value (integer between 0 and 255 expected)" + mocked_get_data_str.assert_called_once_with("file:///path/to/normal") + + +@mock.patch.dict(os.environ, {"ROK4_STYLES_DIRECTORY": "file:///path/to"}, clear=True) +@mock.patch( + "rok4.style.exists", + return_value=True, +) +@mock.patch( + "rok4.style.get_data_str", + return_value=""" + { + "identifier": "normal", + "title": "Données Brutes", + "abstract": "Données brutes sans changement de palette", + "keywords": ["Défaut"], + "legend": { + "format": "image/png", + "url": "http://serveur.fr/image.png", + "height": 100, + "width": 100, + "min_scale_denominator": 0, + "max_scale_denominator": 30 + }, + "palette": { + "no_alpha": false, + "rgb_continuous": true, + "alpha_continuous": true, + "colours": [ + { "value": -80000, "red": 255, "green": 255, "blue": 255, "alpha": 0 }, + { "value": -99999, "red": 255, "green": 255, "blue": 255, "alpha": 0 } + ] + } + }""" +) +def test_palette_wrong_colour_order(mocked_get_data_str, mocked_exists): + with pytest.raises(Exception) as exc: + Style("normal") + assert str(exc.value) == "Style 'file:///path/to/normal' palette colours hav eto be ordered input value ascending" + mocked_get_data_str.assert_called_once_with("file:///path/to/normal") + +@mock.patch.dict(os.environ, {"ROK4_STYLES_DIRECTORY": "file:///path/to"}, clear=True) +@mock.patch( + "rok4.style.exists", + return_value=True, +) +@mock.patch( + "rok4.style.get_data_str", + return_value=""" + { + "identifier": "normal", + "title": "Données Brutes", + "abstract": "Données brutes sans changement de palette", + "keywords": ["Défaut"], + "legend": { + "format": "image/png", + "url": "http://serveur.fr/image.png", + "height": 100, + "width": 100, + "min_scale_denominator": 0, + "max_scale_denominator": 30 + }, + "palette": { + "no_alpha": false, + "rgb_continuous": true, + "alpha_continuous": true, + "colours": [ + { "value": 42, "red": 255, "green": 255, "blue": 255, "alpha": 0 } + ] + } + }""" +) +def test_ok_only_palette(mocked_get_data_str, mocked_exists): + + try: + style = Style("normal") + mocked_get_data_str.assert_called_once_with("file:///path/to/normal") + + assert not style.is_identity + assert style.bands == 4 + assert style.format == ColorFormat.UINT8 + assert style.input_nodata == 42. + + except Exception as exc: + assert False, f"Style read raises an exception: {exc}" + + +@mock.patch.dict(os.environ, {"ROK4_STYLES_DIRECTORY": "file:///path/to"}, clear=True) +@mock.patch( + "rok4.style.exists", + return_value=True, +) +@mock.patch( + "rok4.style.get_data_str", + return_value=""" + { + "identifier": "normal", + "title": "Données Brutes", + "abstract": "Données brutes sans changement de palette", + "keywords": ["Défaut"], + "legend": { + "format": "image/png", + "url": "http://serveur.fr/image.png", + "height": 100, + "width": 100, + "min_scale_denominator": 0, + "max_scale_denominator": 30 + }, + "pente": { + "algo": "H", + "unit": "degree", + "interpolation": "linear", + "image_nodata": -50000, + "slope_nodata": 91, + "slope_max": 90 + } + }""" +) +def test_ok_only_pente(mocked_get_data_str, mocked_exists): + + try: + style = Style("normal") + mocked_get_data_str.assert_called_once_with("file:///path/to/normal") + + assert not style.is_identity + assert style.bands == 1 + assert style.format == ColorFormat.FLOAT32 + assert style.input_nodata == -50000 + + except Exception as exc: + assert False, f"Style read raises an exception: {exc}" + + +@mock.patch.dict(os.environ, {"ROK4_STYLES_DIRECTORY": "file:///path/to"}, clear=True) +@mock.patch( + "rok4.style.exists", + return_value=True, +) +@mock.patch( + "rok4.style.get_data_str", + return_value=""" + { + "identifier": "normal", + "title": "Données Brutes", + "abstract": "Données brutes sans changement de palette", + "keywords": ["Défaut"], + "legend": { + "format": "image/png", + "url": "http://serveur.fr/image.png", + "height": 100, + "width": 100, + "min_scale_denominator": 0, + "max_scale_denominator": 30 + }, + "pente": { + "algo": "H", + "unit": "degree", + "interpolation": "linear", + "image_nodata": -50000, + "slope_nodata": 91, + "slope_max": 90 + }, + "estompage": { + "zenith": 45, + "azimuth": 315, + "image_nodata": -50000, + "z_factor": 1, + "interpolation": "linear" + }, + "exposition": { + "algo": "H", + "image_nodata": -50000, + "min_slope": 1 + }, + "palette": { + "no_alpha": true, + "rgb_continuous": true, + "alpha_continuous": true, + "colours": [ + { "value": 42, "red": 255, "green": 255, "blue": 255, "alpha": 0 } + ] + } + }""" +) +def test_ok_all(mocked_get_data_str, mocked_exists): + + try: + style = Style("normal") + mocked_get_data_str.assert_called_once_with("file:///path/to/normal") + + assert not style.is_identity + assert style.bands == 3 + assert style.format == ColorFormat.UINT8 + assert style.input_nodata == -50000 + + except Exception as exc: + assert False, f"Style read raises an exception: {exc}" + + +@mock.patch.dict(os.environ, {"ROK4_STYLES_DIRECTORY": "file:///path/to"}, clear=True) +@mock.patch( + "rok4.style.exists", + return_value=True, +) +@mock.patch( + "rok4.style.get_data_str", + return_value=""" + { + "identifier": "normal", + "title": "Données Brutes", + "abstract": "Données brutes sans changement de palette", + "keywords": ["Défaut"], + "legend": { + "format": "image/png", + "url": "http://serveur.fr/image.png", + "height": 100, + "width": 100, + "min_scale_denominator": 0, + "max_scale_denominator": 30 + } + }""" +) +def test_ok_identity(mocked_get_data_str, mocked_exists): + + try: + style = Style("normal") + mocked_get_data_str.assert_called_once_with("file:///path/to/normal") + + assert style.is_identity + assert style.bands is None + assert style.format is None + assert style.input_nodata is None + + except Exception as exc: + assert False, f"Style read raises an exception: {exc}" + + +@mock.patch.dict(os.environ, {"ROK4_STYLES_DIRECTORY": "file:///path/to"}, clear=True) +@mock.patch( + "rok4.style.exists", + return_value=True, +) +@mock.patch( + "rok4.style.get_data_str", + return_value=""" + { + "identifier": "normal", + "title": "Données Brutes", + "abstract": "Données brutes sans changement de palette", + "keywords": ["Défaut"], + "legend": { + "format": "image/png", + "url": "http://serveur.fr/image.png", + "height": 100, + "width": 100, + "min_scale_denominator": 0, + "max_scale_denominator": 30 + }, + "palette": { + "no_alpha": false, + "rgb_continuous": true, + "alpha_continuous": false, + "colours": [ + { "value": 0, "red": 10, "green": 20, "blue": 30, "alpha": 40 }, + { "value": 100, "red": 50, "green": 40, "blue": 10, "alpha": 100 } + ] + } + }""" +) +def test_ok_palette_convert_rgba_continuous(mocked_get_data_str, mocked_exists): + + try: + style = Style("normal") + mocked_get_data_str.assert_called_once_with("file:///path/to/normal") + + assert style.palette.convert(-10) == (10, 20, 30, 40) + assert style.palette.convert(150) == (50, 40, 10, 100) + assert style.palette.convert(20) == (18, 24, 26, 40) + + except Exception as exc: + assert False, f"Style read raises an exception: {exc}" + + +@mock.patch.dict(os.environ, {"ROK4_STYLES_DIRECTORY": "file:///path/to"}, clear=True) +@mock.patch( + "rok4.style.exists", + return_value=True, +) +@mock.patch( + "rok4.style.get_data_str", + return_value=""" + { + "identifier": "normal", + "title": "Données Brutes", + "abstract": "Données brutes sans changement de palette", + "keywords": ["Défaut"], + "legend": { + "format": "image/png", + "url": "http://serveur.fr/image.png", + "height": 100, + "width": 100, + "min_scale_denominator": 0, + "max_scale_denominator": 30 + }, + "palette": { + "no_alpha": true, + "rgb_continuous": false, + "alpha_continuous": false, + "colours": [ + { "value": 0, "red": 10, "green": 20, "blue": 30, "alpha": 40 }, + { "value": 100, "red": 50, "green": 40, "blue": 10, "alpha": 100 } + ] + } + }""" +) +def test_ok_palette_convert_rgb_no_alpha(mocked_get_data_str, mocked_exists): + + try: + style = Style("normal") + mocked_get_data_str.assert_called_once_with("file:///path/to/normal") + + assert style.palette.convert(-10) == (10, 20, 30) + assert style.palette.convert(150) == (50, 40, 10) + assert style.palette.convert(20) == (10, 20, 30) + + except Exception as exc: + assert False, f"Style read raises an exception: {exc}" \ No newline at end of file