diff --git a/.gitignore b/.gitignore index 7dc1985e4fe41ddf8b2098bd75c80181685c186b..ff863056e2369e053c70fad93ae84af3b6ca5619 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dinamis_sdk/test build dist +*venv/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 300962ee0fd7b5b2cf85a46e61dc4df8b36ff269..6f9da0865b79a10714fcdd8da266877a6d36a833 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,19 @@ default: - image: python:3.8-slim + image: python:3.12-slim + +variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + +cache: + paths: + - .cache/pip + - .venv + +before_script: + - pip install virtualenv + - virtualenv .venv + - source .venv/bin/activate + - pip install -e . workflow: rules: @@ -7,12 +21,22 @@ workflow: - if: $CI_COMMIT_BRANCH == 'main' stages: - - Static Analysis - Install + - Static Analysis - Test - Ship - Documentation +# ------------------------------- Install ------------------------------------- + +pip_install: + stage: Install + before_script: + - python --version ; pip --version + script: + - pip install . + - pip list -v + # ------------------------------ Static analysis ------------------------------ .static_analysis_base: @@ -20,35 +44,45 @@ stages: allow_failure: true flake8: + needs: + - pip_install extends: .static_analysis_base script: - pip install flake8 - - flake8 $PWD/dinamis_sdk + - flake8 --ignore E501 ./dinamis_sdk pylint: + needs: + - pip_install extends: .static_analysis_base script: - - pip install pylint appdirs requests pystac pystac-client pydantic urllib3 qrcode - - pylint $PWD/dinamis_sdk --disable=W0718,W0603,R0914,C0415 --ignore=examples + - pip install pylint + - pylint ./dinamis_sdk codespell: + needs: + - pip_install extends: .static_analysis_base script: - pip install codespell - - codespell dinamis_sdk docs README.md + - codespell ./dinamis_sdk docs README.md pydocstyle: + needs: + - pip_install extends: .static_analysis_base script: - pip install pydocstyle - - pydocstyle $PWD/dinamis_sdk - -# ------------------------------- Install ------------------------------------- + - pydocstyle ./dinamis_sdk -pip_install: - stage: Install +mypy: + needs: + - pip_install + extends: .static_analysis_base script: - - pip install . + - pip install mypy + - pip list -v + - mypy --install-types --non-interactive . # --------------------------------- Doc --------------------------------------- @@ -78,13 +112,11 @@ pages: # --------------------------------- Test -------------------------------------- .tests_base: + needs: + - pip_install stage: Test except: - main - before_script: - - pip install . - - pip install pystac-client - OAuth2 Tests: extends: .tests_base @@ -116,10 +148,9 @@ API key Tests: pypi: stage: Ship only: - - main - before_script: - - python3 -m pip install --upgrade build twine + - main script: - - python3 -m build + - pip install --upgrade build twine + - python3 -m build after_script: - - python3 -m twine upload --repository-url https://upload.pypi.org/legacy/ --non-interactive --verbose -u __token__ -p $pypi_token dist/* + - python3 -m twine upload --repository-url https://upload.pypi.org/legacy/ --non-interactive --verbose -u __token__ -p $pypi_token dist/* diff --git a/dinamis_sdk/__init__.py b/dinamis_sdk/__init__.py index d062092cd2d27ae2fa6bcf97a1cc879ea5f2501b..bf723aa3836a291e33b9f1dbece62f92a6cf7ae3 100644 --- a/dinamis_sdk/__init__.py +++ b/dinamis_sdk/__init__.py @@ -1,6 +1,8 @@ """Dinamis SDK module.""" + # flake8: noqa import pkg_resources + __version__ = pkg_resources.require("dinamis-sdk")[0].version from dinamis_sdk.s3 import ( sign, @@ -9,7 +11,7 @@ from dinamis_sdk.s3 import ( sign_item, sign_asset, sign_item_collection, - sign_url_put + sign_url_put, ) # noqa from dinamis_sdk import auth # noqa from dinamis_sdk.upload import push diff --git a/dinamis_sdk/auth.py b/dinamis_sdk/auth.py index 1bcdb40ddef82e804433c7f3c20201c76eb61903..9e938f1e87a64f6ddbe977408f9fde29c9c95d57 100644 --- a/dinamis_sdk/auth.py +++ b/dinamis_sdk/auth.py @@ -1,4 +1,5 @@ """Module dedicated to OAuth2 device flow.""" + import datetime import io import json @@ -8,9 +9,14 @@ from abc import abstractmethod from typing import Dict import requests from pydantic import BaseModel # pylint: disable = no-name-in-module -import qrcode +import qrcode # type: ignore from .utils import ( - log, JWT_FILE, TOKEN_ENDPOINT, AUTH_BASE_URL, settings, create_session + log, + JWT_FILE, + TOKEN_ENDPOINT, + AUTH_BASE_URL, + settings, + create_session, ) @@ -36,9 +42,7 @@ class DeviceGrantResponse(BaseModel): # pylint: disable = R0903 class GrantMethodBase: """Base class for grant methods.""" - headers: Dict[str, str] = { - "Content-Type": "application/x-www-form-urlencoded" - } + headers: Dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"} token_endpoint = TOKEN_ENDPOINT base_url = AUTH_BASE_URL keycloak_realm = "dinamis" @@ -60,7 +64,7 @@ class GrantMethodBase: """Base payload.""" return { "client_id": self.client_id, - "scope": "openid offline_access" + "scope": "openid offline_access", } def refresh_token(self, old_jwt: JWT) -> JWT: @@ -77,15 +81,17 @@ class GrantMethodBase: log.debug("Refreshing token") assert old_jwt, "JWT is empty" data = self.data_base.copy() - data.update({ - "refresh_token": old_jwt.refresh_token, - "grant_type": "refresh_token" - }) + data.update( + { + "refresh_token": old_jwt.refresh_token, + "grant_type": "refresh_token", + } + ) ret = requests.post( self.token_endpoint, headers=self.headers, data=data, - timeout=10 + timeout=10, ) if ret.status_code == 200: log.debug(ret.text) @@ -113,7 +119,7 @@ class DeviceGrant(GrantMethodBase): device_endpoint, headers=self.headers, data=self.data_base, - timeout=10 + timeout=10, ) if ret.status_code == 200: response = DeviceGrantResponse(**ret.json()) @@ -132,19 +138,26 @@ class DeviceGrant(GrantMethodBase): log.info("Waiting for authentication...") start = time.time() data = self.data_base.copy() - data.update({ - "device_code": response.device_code, - "grant_type": "urn:ietf:params:oauth:grant-type:device_code" - }) + grant_type = "urn:ietf:params:oauth:grant-type:device_code" + data.update( + { + "device_code": response.device_code, + "grant_type": grant_type, + } + ) while True: ret = requests.post( self.token_endpoint, headers=self.headers, data=data, - timeout=10 + timeout=10, ) elapsed = time.time() - start - log.debug("Elapsed: %s, status: %s", elapsed, ret.status_code) + log.debug( + "Elapsed: %s, status: %s", + elapsed, + ret.status_code, + ) if elapsed > response.expires_in: raise ConnectionError("Authentication link has expired.") if ret.status_code != 200: @@ -157,7 +170,7 @@ class DeviceGrant(GrantMethodBase): class OAuth2Session: """Class to start an OAuth2 session.""" - def __init__(self, grant_type: GrantMethodBase = DeviceGrant): + def __init__(self, grant_type: type[GrantMethodBase] = DeviceGrant): """ Initialize. @@ -168,7 +181,7 @@ class OAuth2Session: self.grant = grant_type() self.jwt_ttl_margin_seconds = 60 self.jwt_issuance = datetime.datetime(year=1, month=1, day=1) - self.jwt = None + self.jwt: JWT | None = None def save_token(self, now: datetime.datetime): """ @@ -181,8 +194,9 @@ class OAuth2Session: self.jwt_issuance = now if JWT_FILE: try: - with open(JWT_FILE, 'w', encoding='UTF-8') as file: - json.dump(self.jwt.dict(), file) + if self.jwt: + with open(JWT_FILE, "w", encoding="UTF-8") as file: + json.dump(self.jwt.dict(), file) log.debug("Token saved in %s", JWT_FILE) except IOError as io_err: log.warning("Unable to save token (%s)", io_err) @@ -197,9 +211,7 @@ class OAuth2Session: log.debug("access_token_ttl is %s", access_token_ttl_seconds) if access_token_ttl_seconds >= ttl_margin_seconds: # Token is still valid - log.debug( - "Credentials from %s still valid", JWT_FILE - ) + log.debug("Credentials from %s still valid", JWT_FILE) return if access_token_ttl_seconds < ttl_margin_seconds: # Access token in not valid, but refresh might be @@ -209,7 +221,7 @@ class OAuth2Session: log.warning( "Unable to refresh token (reason: %s). " "Renewing initial authentication.", - con_err + con_err, ) self.jwt = self.grant.get_first_token() else: @@ -223,13 +235,13 @@ class OAuth2Session: if JWT_FILE and os.path.isfile(JWT_FILE): log.debug("Trying to grab credentials from %s", JWT_FILE) try: - with open(JWT_FILE, encoding='UTF-8') as json_file: + with open(JWT_FILE, encoding="UTF-8") as json_file: self.jwt = JWT(**json.load(json_file)) except FileNotFoundError as error: log.warning( "Warning: can't use token from file %s (%s)", JWT_FILE, - error + error, ) if not self.jwt: # if JWT is still None, we use the grant method @@ -249,7 +261,7 @@ class TokenServer: self.endpoint = endpoint self.session = create_session( retry_total=retry_total, - retry_backoff_factor=retry_backoff_factor + retry_backoff_factor=retry_backoff_factor, ) log.info("Using Token Server: %s", self.endpoint) @@ -258,8 +270,11 @@ class TokenServer: return self.session.get(self.endpoint, timeout=10).json() -session = TokenServer(settings.dinamis_sdk_token_server) \ - if settings.dinamis_sdk_token_server else OAuth2Session() +session = ( + TokenServer(settings.dinamis_sdk_token_server) + if settings.dinamis_sdk_token_server + else OAuth2Session() +) def _get_access_token(): @@ -278,5 +293,5 @@ def set_access_token_fn(func): func: access token accessor """ - global get_access_token + global get_access_token # pylint: disable=W0603 get_access_token = func diff --git a/dinamis_sdk/cli.py b/dinamis_sdk/cli.py index dd2582af525f697d8118040103847b16d56a1af0..f47981776dcac43c89052a59afe7d5501aae07ef 100644 --- a/dinamis_sdk/cli.py +++ b/dinamis_sdk/cli.py @@ -1,16 +1,23 @@ """Dinamis Command Line Interface.""" + +import json +import os +from typing import Dict, List + import click -from .utils import APIKEY_FILE, create_session, S3_SIGNING_ENDPOINT, log + from .auth import get_access_token -import os -import json -from typing import List, Dict +from .utils import ( + APIKEY_FILE, + S3_SIGNING_ENDPOINT, + create_session, + log, +) @click.group(help="Dinamis CLI") def app() -> None: """Click group for dinamis sdk subcommands.""" - pass def http(route: str): @@ -19,7 +26,7 @@ def http(route: str): ret = session.get( f"{S3_SIGNING_ENDPOINT}{route}", timeout=5, - headers={"authorization": f"bearer {get_access_token()}"} + headers={"authorization": f"bearer {get_access_token()}"}, ) ret.raise_for_status() return ret @@ -48,7 +55,7 @@ def create(): @app.command(help="List all API keys") -def list(): +def list(): # [redefined-builtin] """List all API keys.""" log.info(f"All generated API keys: {list_keys()}") @@ -73,7 +80,7 @@ def revoke(access_key: str): @app.command(help="Get and store an API key") def register(): """Get and store an API key.""" - with open(APIKEY_FILE, 'w') as f: + with open(APIKEY_FILE, "w", encoding="utf-8") as f: json.dump(create_key(), f) log.info(f"API key successfully created and stored in {APIKEY_FILE}") @@ -84,7 +91,7 @@ def delete(dont_revoke): """Delete the stored API key.""" if os.path.isfile(APIKEY_FILE): if not dont_revoke: - with open(APIKEY_FILE, encoding='UTF-8') as json_file: + with open(APIKEY_FILE, encoding="UTF-8") as json_file: api_key = json.load(json_file) if "access-key" in api_key: revoke_key(api_key["access-key"]) diff --git a/dinamis_sdk/examples/pyotb_ndvi_loss.py b/dinamis_sdk/examples/pyotb_ndvi_loss.py index 770830bbb5b3626e088a5492d3fc57dc7afb31c2..935c5cb05f5ca257a8ea21e1c642f39a4a057a13 100644 --- a/dinamis_sdk/examples/pyotb_ndvi_loss.py +++ b/dinamis_sdk/examples/pyotb_ndvi_loss.py @@ -1,11 +1,12 @@ """NDVI loss example with pyotb.""" + from pystac_client import Client -import pyotb +import pyotb # type: ignore from dinamis_sdk import sign_inplace api = Client.open( - 'https://stacapi-cdos.apps.okd.crocc.meso.umontpellier.fr', - modifier=sign_inplace + "https://stacapi-cdos.apps.okd.crocc.meso.umontpellier.fr", + modifier=sign_inplace, ) @@ -14,7 +15,7 @@ def mosa(year): res = api.search( bbox=[4, 42.99, 5, 44.05], datetime=[f"{year}-01-01", f"{year}-12-25"], - collections=["spot-6-7-drs"] + collections=["spot-6-7-drs"], ) urls = [f"/vsicurl/{r.assets['src_xs'].href}" for r in res.items()] return pyotb.Mosaic({"il": urls}) diff --git a/dinamis_sdk/examples/pyotb_toa_mosaic.py b/dinamis_sdk/examples/pyotb_toa_mosaic.py index 9eccf0fd4ab77d4a3893b503a9c291d292a77e5c..596a1e05e7f2bc5d8c7ff3789130fcc4c5e6e878 100644 --- a/dinamis_sdk/examples/pyotb_toa_mosaic.py +++ b/dinamis_sdk/examples/pyotb_toa_mosaic.py @@ -1,17 +1,20 @@ """TOA images mosaicing with pyotb.""" + +# pylint: disable=duplicate-code + from pystac_client import Client -import pyotb +import pyotb # type: ignore from dinamis_sdk import sign_inplace api = Client.open( - 'https://stacapi-cdos.apps.okd.crocc.meso.umontpellier.fr', - modifier=sign_inplace + "https://stacapi-cdos.apps.okd.crocc.meso.umontpellier.fr", + modifier=sign_inplace, ) res = api.search( bbox=[4, 42.99, 5, 44.05], datetime=["2022-01-01", "2022-12-25"], - collections=["spot-6-7-drs"] + collections=["spot-6-7-drs"], ) urls = [f"/vsicurl/{r.assets['src_xs'].href}" for r in res.items()] diff --git a/dinamis_sdk/examples/rio_metadata.py b/dinamis_sdk/examples/rio_metadata.py index 3f75e7d044913ec42cfc37b7a2741bca640b1e8f..4d5a30d692ca72ef91bb87af8f5a25ea22798f00 100644 --- a/dinamis_sdk/examples/rio_metadata.py +++ b/dinamis_sdk/examples/rio_metadata.py @@ -1,34 +1,31 @@ """Metadata extraction example with rasterio.""" + from pystac_client import Client -import rasterio.features -import rasterio.warp +import rasterio.features # type: ignore +import rasterio.warp # type: ignore from dinamis_sdk import sign_inplace api = Client.open( - 'https://stacapi-cdos.apps.okd.crocc.meso.umontpellier.fr', - modifier=sign_inplace + "https://stacapi-cdos.apps.okd.crocc.meso.umontpellier.fr", + modifier=sign_inplace, ) -year = 2022 +YEAR = 2022 bbox = [4, 42.99, 5, 44.05] -res = api.search(bbox=bbox, datetime=[f'{year}-01-01', f'{year}-12-25']) +res = api.search(bbox=bbox, datetime=[f"{YEAR}-01-01", f"{YEAR}-12-25"]) for item in res.items(): url = item.assets["src_xs"].href with rasterio.open(url) as dataset: - # Read the dataset's valid data mask as a ndarray. mask = dataset.dataset_mask() # Extract feature shapes and values from the array. - for geom, val in rasterio.features.shapes( - mask, transform=dataset.transform - ): - + for geom, val in rasterio.features.shapes(mask, transform=dataset.transform): # Transform shapes from the dataset's own coordinate # reference system to CRS84 (EPSG:4326). geom = rasterio.warp.transform_geom( - dataset.crs, 'EPSG:4326', geom, precision=6 + dataset.crs, "EPSG:4326", geom, precision=6 ) # Print GeoJSON shapes to stdout. diff --git a/dinamis_sdk/py.typed b/dinamis_sdk/py.typed new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/dinamis_sdk/s3.py b/dinamis_sdk/s3.py index cc0cad7b456ef9090400af85361b72779143c8b0..69c933b4687eaa7cb4d35d778f05e26adb2de06d 100644 --- a/dinamis_sdk/s3.py +++ b/dinamis_sdk/s3.py @@ -5,32 +5,42 @@ Revamp of Microsoft Planetary Computer SAS, using S3 and custom URL signing endpoint instead. """ + import collections.abc +import math import re import time +from ast import literal_eval from copy import deepcopy from datetime import datetime, timezone from functools import singledispatch -from typing import Any, Dict, Mapping, TypeVar, cast, List -from urllib.parse import urlparse, parse_qs -import math +from typing import Any, Dict, List, Mapping, TypeVar, cast +from urllib.parse import parse_qs, urlparse + +import packaging.version +import pydantic +import pystac_client from pydantic import BaseModel # pylint: disable = no-name-in-module -from pystac import Asset, Item, ItemCollection, STACObjectType, Collection +from pystac import ( + Asset, + Collection, + Item, + ItemCollection, + STACObjectType, +) from pystac.serialization.identify import identify_stac_object_type from pystac.utils import datetime_to_str -import pystac_client from pystac_client import ItemSearch -import packaging.version -import pydantic +from .auth import get_access_token from .utils import ( - log, - settings, + APIKEY, MAX_URLS, S3_SIGNING_ENDPOINT, S3_STORAGE_DOMAIN, create_session, - APIKEY + log, + settings, ) _PYDANTIC_2_0 = packaging.version.parse( @@ -41,7 +51,7 @@ AssetLike = TypeVar("AssetLike", Asset, Dict[str, Any]) asset_xpr = re.compile( r"https:\/\/(?P<account>[A-z0-9-.]+?)" - r"\.meso\.umontpellier\.fr\/(?P<blob>[^<]+)" + r"\.meso\.umontpellier\.fr\/(?P<blob>[^<]+)" # ignore ) @@ -115,8 +125,9 @@ def sign_inplace(obj: Any) -> Any: def is_vrt_string(string: str) -> bool: """Check whether a string looks like a VRT.""" - return string.strip().startswith("<VRTDataset") and \ - string.strip().endswith("</VRTDataset>") + return string.strip().startswith("<VRTDataset") and string.strip().endswith( + "</VRTDataset>" + ) @sign.register(str) @@ -147,10 +158,7 @@ def sign_string(url: str, copy: bool = True) -> str: return sign_urls(urls=[url])[url] -def _generic_sign_urls( - urls: List[str], - route: str -) -> Dict[str, str]: +def _generic_sign_urls(urls: List[str], route: str) -> Dict[str, str]: """Sign URLs with a S3 Token. Signing URL allows read access to files in storage. @@ -182,19 +190,20 @@ def _generic_sign_urls( if set(parsed_qs) & { "X-Amz-Security-Token", "X-Amz-Signature", - "X-Amz-Credential" + "X-Amz-Credential", }: # looks like we've already signed it signed_urls[url] = url not_signed_urls = [url for url in urls if url not in signed_urls] - signed_urls.update({ - url: signed_url.href - for url, signed_url in _generic_get_signed_urls( - urls=not_signed_urls, - route=route - ).items() - }) + signed_urls.update( + { + url: signed_url.href + for url, signed_url in _generic_get_signed_urls( + urls=not_signed_urls, route=route + ).items() + } + ) return signed_urls @@ -214,8 +223,7 @@ def sign_url_put(url: str) -> str: return urls[url] -def sign_vrt_string(vrt: str, - copy: bool = True) -> str: # pylint: disable = W0613 # noqa: E501 +def sign_vrt_string(vrt: str, copy: bool = True) -> str: # pylint: disable = W0613 # noqa: E501 """Sign a VRT-like string containing URLs from the storage. Signing URLs allows read access to files in storage. @@ -235,7 +243,7 @@ def sign_vrt_string(vrt: str, """ urls = [] - def _repl_vrt(m: re.Match) -> str: + def _repl_vrt(m: re.Match): urls.append(m.string[slice(*m.span())]) asset_xpr.sub(_repl_vrt, vrt) @@ -296,7 +304,7 @@ def sign_asset(asset: Asset, copy: bool = True) -> Asset: @sign.register(ItemCollection) def sign_item_collection( - item_collection: ItemCollection, copy: bool = True + item_collection: ItemCollection, copy: bool = True ) -> ItemCollection: """Sign a PySTAC item collection. @@ -315,11 +323,7 @@ def sign_item_collection( """ if copy: item_collection = item_collection.clone() - urls = [ - asset.href - for item in item_collection - for asset in item.assets.values() - ] + urls = [asset.href for item in item_collection for asset in item.assets.values()] signed_urls = sign_urls(urls=urls) for item in item_collection: for key, asset in item.assets.items(): @@ -371,10 +375,7 @@ def sign_collection(collection: Collection, copy: bool = True) -> Collection: if assets and not collection.assets: collection.assets = deepcopy(assets) - urls = [ - collection.assets[key].href - for key in collection.assets - ] + urls = [collection.assets[key].href for key in collection.assets] signed_urls = sign_urls(urls=urls) for key, asset in collection.assets.items(): collection.assets[key].href = signed_urls[asset.href] @@ -420,8 +421,7 @@ def sign_mapping(mapping: Mapping, copy: bool = True) -> Mapping: url = val["href"] val["href"] = signed_urls[url] - elif mapping.get("type") == "FeatureCollection" and \ - mapping.get("features"): + elif mapping.get("type") == "FeatureCollection" and mapping.get("features"): urls = [ val["href"] for feat in mapping["features"] @@ -440,10 +440,10 @@ sign_reference_file = sign_mapping def _generic_get_signed_urls( - urls: List[str], - route: str, - retry_total: int = 10, - retry_backoff_factor: float = .8 + urls: List[str], + route: str, + retry_total: int = 10, + retry_backoff_factor: float = 0.8, ) -> Dict[str, SignedURL]: """ Get multiple signed URLs. @@ -468,11 +468,14 @@ def _generic_get_signed_urls( Returns: SignedURL: the signed URL """ + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches log.debug("Get signed URLs for %s", urls) start_time = time.time() headers = { "Content-Type": "application/json", - "Accept": "application/json" + "Accept": "application/json", } if APIKEY: headers.update(APIKEY) @@ -480,11 +483,12 @@ def _generic_get_signed_urls( elif settings.dinamis_sdk_bypass_api: log.debug("Using bypass API %s", settings.dinamis_sdk_bypass_api) else: - from .auth import get_access_token access_token = get_access_token() headers.update({"Authorization": f"Bearer {access_token}"}) log.debug( - "Access token: %s...%s", access_token[:8], access_token[-8:] + "Access token: %s...%s", + access_token[:8], + access_token[-8:], ) signed_urls = {} for url in urls: @@ -497,7 +501,7 @@ def _generic_get_signed_urls( log.debug( "Using cache (%s > %s)", ttl, - settings.dinamis_sdk_ttl_margin + settings.dinamis_sdk_ttl_margin, ) signed_urls[url] = signed_url_in_cache not_signed_urls = [url for url in urls if url not in signed_urls] @@ -510,7 +514,7 @@ def _generic_get_signed_urls( # give a small amount of time to do stuff with the url session = create_session( retry_backoff_factor=retry_backoff_factor, - retry_total=retry_total + retry_total=retry_total, ) n_urls = len(not_signed_urls) log.debug("Number of URLs to sign: %s", n_urls) @@ -521,40 +525,33 @@ def _generic_get_signed_urls( chunk_start = i_chunk * MAX_URLS chunk_end = min(chunk_start + MAX_URLS, n_urls) not_signed_urls_chunk = not_signed_urls[chunk_start:chunk_end] - params = {"urls": not_signed_urls_chunk} + params: Dict[str, Any] = {"urls": not_signed_urls_chunk} if settings.dinamis_sdk_url_duration: params["duration_seconds"] = settings.dinamis_sdk_url_duration post_url = f"{S3_SIGNING_ENDPOINT}{route}" log.debug("POST %s", post_url) response = session.post( - post_url, - params=params, - headers=headers, - timeout=10 + post_url, params=params, headers=headers, timeout=10 ) try: response.raise_for_status() except Exception as e: - log.error(eval(response.content)) - raise(e) + log.error(literal_eval(response.content)) + raise e signed_url_batch = SignedURLBatch(**response.json()) if not signed_url_batch: raise ValueError( f"No signed url batch found in response: {response.json()}" ) - if not all(key in signed_url_batch.hrefs - for key in not_signed_urls_chunk): + if not all(key in signed_url_batch.hrefs for key in not_signed_urls_chunk): raise ValueError( f"URLs to sign are {not_signed_urls_chunk} but returned " f"signed URLs" f"are for {signed_url_batch.hrefs.keys()}" ) for url, href in signed_url_batch.hrefs.items(): - signed_url = SignedURL( - expiry=signed_url_batch.expiry, - href=href - ) + signed_url = SignedURL(expiry=signed_url_batch.expiry, href=href) if route == "sign_urls": # Only put GET urls in cache CACHE[url] = signed_url @@ -562,7 +559,7 @@ def _generic_get_signed_urls( log.debug( "Got signed urls %s in %s seconds", signed_urls, - f"{time.time() - start_time:.2f}" + f"{time.time() - start_time:.2f}", ) return signed_urls diff --git a/dinamis_sdk/settings.py b/dinamis_sdk/settings.py index 4940795ed0c6853f1e8c14fe38b2b958e4f15f37..b237b196d21e0ab4a737d2060030426bc492979e 100644 --- a/dinamis_sdk/settings.py +++ b/dinamis_sdk/settings.py @@ -1,4 +1,5 @@ """Settings from environment variables.""" + from pydantic_settings import BaseSettings diff --git a/dinamis_sdk/upload.py b/dinamis_sdk/upload.py index ec5bc4f9f124639294e807a1233dd17aa5744e04..f971e1a762607d833fd7496509133a0cd29077a0 100644 --- a/dinamis_sdk/upload.py +++ b/dinamis_sdk/upload.py @@ -1,23 +1,24 @@ """This module is used to upload files using HTTP requests.""" + from .s3 import sign_url_put from .utils import create_session def push( - local_filename: str, - target_url: str, - retry_total: int = 5, - retry_backoff_factor: float = .8 + local_filename: str, + target_url: str, + retry_total: int = 5, + retry_backoff_factor: float = 0.8, ): """Publish a local file to the cloud.""" remote_presigned_url = sign_url_put(target_url) session = create_session( retry_total=retry_total, - retry_backoff_factor=retry_backoff_factor + retry_backoff_factor=retry_backoff_factor, ) - with open(local_filename, 'rb') as f: + with open(local_filename, "rb") as f: ret = session.put(remote_presigned_url, data=f) if ret.status_code == 200: diff --git a/dinamis_sdk/utils.py b/dinamis_sdk/utils.py index 439eba0635eedd960809ab12af7c45b1369f371a..9bb6fd154c4dace1aa8cf44b1b11a5dc91ff86ae 100644 --- a/dinamis_sdk/utils.py +++ b/dinamis_sdk/utils.py @@ -1,12 +1,16 @@ """Some helpers.""" + import json import logging import os -import appdirs +import sys + +import appdirs # type: ignore + import requests import urllib3.util.retry + from .settings import Settings -import sys # Settings settings = Settings() @@ -14,17 +18,17 @@ settings = Settings() # Logger logging.basicConfig(stream=sys.stdout) log = logging.getLogger("dinamis_sdk") -log.setLevel(level=os.environ.get('LOGLEVEL', 'INFO').upper()) +log.setLevel(level=os.environ.get("LOGLEVEL", "INFO").upper()) # Constants MAX_URLS = 64 S3_STORAGE_DOMAIN = "meso.umontpellier.fr" -S3_SIGNING_ENDPOINT = \ - "https://s3-signing-cdos.apps.okd.crocc.meso.umontpellier.fr/" +S3_SIGNING_ENDPOINT = "https://s3-signing-cdos.apps.okd.crocc.meso.umontpellier.fr/" # Config path -CFG_PTH = settings.dinamis_sdk_settings_dir or \ - appdirs.user_config_dir(appname='dinamis_sdk_auth') +CFG_PTH = settings.dinamis_sdk_settings_dir or appdirs.user_config_dir( + appname="dinamis_sdk_auth" +) if not os.path.exists(CFG_PTH): try: os.makedirs(CFG_PTH) @@ -47,7 +51,7 @@ APIKEY = None if APIKEY_FILE and os.path.isfile(APIKEY_FILE): try: log.debug("Found a stored API key") - with open(APIKEY_FILE, encoding='UTF-8') as json_file: + with open(APIKEY_FILE, encoding="UTF-8") as json_file: APIKEY = json.load(json_file) log.debug("API key successfully loaded") except json.decoder.JSONDecodeError: @@ -56,20 +60,17 @@ if APIKEY_FILE and os.path.isfile(APIKEY_FILE): if settings.dinamis_sdk_access_key and settings.dinamis_sdk_secret_key: APIKEY = { "access-key": settings.dinamis_sdk_access_key, - "secret-key": settings.dinamis_sdk_secret_key + "secret-key": settings.dinamis_sdk_secret_key, } -def create_session( - retry_total: int = 5, - retry_backoff_factor: float = .8 -): + +def create_session(retry_total: int = 5, retry_backoff_factor: float = 0.8): """Create a session for requests.""" session = requests.Session() retry = urllib3.util.retry.Retry( total=retry_total, backoff_factor=retry_backoff_factor, status_forcelist=[404, 429, 500, 502, 503, 504], - allowed_methods=False, ) adapter = requests.adapters.HTTPAdapter(max_retries=retry) session.mount("http://", adapter) @@ -78,7 +79,9 @@ def create_session( return session -def retrieve_token_endpoint(s3_signing_endpoint: str = S3_SIGNING_ENDPOINT): +def retrieve_token_endpoint( + s3_signing_endpoint: str = S3_SIGNING_ENDPOINT, +): """Retrieve the token endpoint from the s3 signing endpoint.""" openapi_url = s3_signing_endpoint + "openapi.json" log.debug("Fetching OAuth2 endpoint from openapi url %s", openapi_url) @@ -98,9 +101,9 @@ if settings.dinamis_sdk_bypass_api: # Token endpoint is typically something like: https://keycloak-dinamis.apps.okd # .crocc.meso.umontpellier.fr/auth/realms/dinamis/protocol/openid-connect/token -TOKEN_ENDPOINT = None if settings.dinamis_sdk_bypass_api \ - else retrieve_token_endpoint() +TOKEN_ENDPOINT = " " if settings.dinamis_sdk_bypass_api else retrieve_token_endpoint() # Auth base URL is typically something like: https://keycloak-dinamis.apps.okd. # crocc.meso.umontpellier.fr/auth/realms/dinamis/protocol/openid-connect -AUTH_BASE_URL = None if settings.dinamis_sdk_bypass_api \ - else TOKEN_ENDPOINT.rsplit('/', 1)[0] +AUTH_BASE_URL = ( + "" if settings.dinamis_sdk_bypass_api else TOKEN_ENDPOINT.rsplit("/", 1)[0] +) diff --git a/doc/gen_ref_pages.py b/doc/gen_ref_pages.py index a35c15e96461b7bf6f741eb4e3ee35c35e4badce..f427d1633f9251c76df8f0a5cd6cd870d2585724 100755 --- a/doc/gen_ref_pages.py +++ b/doc/gen_ref_pages.py @@ -22,4 +22,3 @@ for path in sorted(Path("dinamis_sdk").rglob("*.py")): # print("::: " + identifier, file=fd) # mkdocs_gen_files.set_edit_path(full_doc_path, path) - diff --git a/pyproject.toml b/pyproject.toml index 502f757d2376653008fd8bc7e90f0c4fba1621f3..8df4e7852fd69b1bd8e64c4063e253e9f5ab66e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,10 +5,11 @@ build-backend = "setuptools.build_meta" [project] name = "dinamis-sdk" authors = [{name = "inrae", email = "remi.cresson@inrae.fr"}] -version = "0.3.3" +version = "0.3.4" description = "DINAMIS SDK for Python" requires-python = ">=3.7" dependencies = [ + "setuptools>=61.2", "click>=7.1", "pydantic>=1.7.3", "pystac>=1.0.0", @@ -25,5 +26,13 @@ license = {file = "LICENSE"} [project.scripts] dinamis_cli = "dinamis_sdk.cli:app" +[tool.mypy] +show_error_codes = true +pretty = true +exclude = ["doc", "venv", ".venv"] + +[tool.pylint] +disable = "W1203,R0903,E0401,W0622" + [tool.setuptools] -include-package-data = false +packages = ["dinamis_sdk"] diff --git a/tests/test_push.py b/tests/test_push.py old mode 100644 new mode 100755 index d7623ea8116fab84071eb093d837f29ab824f77d..252e2728540abd4f50ded8b5518ecbd07a775ca3 --- a/tests/test_push.py +++ b/tests/test_push.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + import dinamis_sdk import time @@ -8,6 +10,6 @@ with open(local_filename, "w") as f: pushed = dinamis_sdk.push( local_filename=local_filename, - target_url="https://s3-data.meso.umontpellier.fr/sm1-gdc-tests/titi.txt" + target_url="https://s3-data.meso.umontpellier.fr/sm1-gdc-tests/titi.txt", ) print("Done") diff --git a/tests/test_spot-6-7-drs.py b/tests/test_spot-6-7-drs.py old mode 100644 new mode 100755 index eb4f285adfb0fc9e3409817777989b4c2de6c6fb..7cf85577b8488bd016ceaeba251f8329f66b4baf --- a/tests/test_spot-6-7-drs.py +++ b/tests/test_spot-6-7-drs.py @@ -1,16 +1,17 @@ +#!/usr/bin/env python + import dinamis_sdk import pystac_client api = pystac_client.Client.open( - 'https://stacapi-cdos.apps.okd.crocc.meso.umontpellier.fr', - modifier=dinamis_sdk.sign_inplace, + "https://stacapi-cdos.apps.okd.crocc.meso.umontpellier.fr", + modifier=dinamis_sdk.sign_inplace, ) res = api.search( bbox=[-3.75, 30, 10, 60], datetime=["2017-01-01", "2022-12-31"], - collections=["spot-6-7-drs"] + collections=["spot-6-7-drs"], ) -urls = [item.assets['src_xs'].href for item in res.items()] +urls = [item.assets["src_xs"].href for item in res.items()] print(len(urls)) assert len(urls) > 1000 - diff --git a/tests/test_super-s2.py b/tests/test_super-s2.py old mode 100644 new mode 100755 index 28966b6aeee228f0009a3a7a3e57dfc5c56d14ec..eb54991b2381a3ddda4e317d3c1bde052d104f2b --- a/tests/test_super-s2.py +++ b/tests/test_super-s2.py @@ -1,19 +1,29 @@ -import dinamis_sdk -import pystac_client +#!/usr/bin/env python + import pystac +import pystac_client +import requests + +import dinamis_sdk api = pystac_client.Client.open( - 'https://stacapi-cdos.apps.okd.crocc.meso.umontpellier.fr', - modifier=dinamis_sdk.sign_inplace, + "https://stacapi-cdos.apps.okd.crocc.meso.umontpellier.fr", + modifier=dinamis_sdk.sign_inplace, ) res = api.search( bbox=[3.75, 43.58, 3.95, 43.67], datetime=["2017-01-01", "2022-12-31"], - collections=["super-sentinel-2-l2a"] + collections=["super-sentinel-2-l2a"], ) -urls = [item.assets['img'].href for item in res.items()] +urls = [item.assets["img"].href for item in res.items()] assert len(urls) == 672 # ItemCollection (bug #17) -ic = pystac.item_collection.ItemCollection(res.items()) -dinamis_sdk.sign_inplace(ic) \ No newline at end of file +ic: pystac.ItemCollection = pystac.item_collection.ItemCollection(res.items()) +dinamis_sdk.sign_inplace(ic) + +item = ic.items[0] +_, asset = next(iter(item.get_assets().items())) + +response = requests.get(asset.href, timeout=5) +response.raise_for_status()