diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..16520fc --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +ignore = E501 \ No newline at end of file diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..0ae32d6 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,140 @@ +name: main + +on: + push: + branches: + - "**" + pull_request: + branches: + - "**" + +jobs: + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install uv + run: pip install uv + - name: Set up uv environment + run: uv venv + - name: Install dependencies + run: uv pip install -r pyproject.toml .[dev] + - name: Activate virtual environment & run linters + run: | + source .venv/bin/activate + isort . --check-only + black . --check + + bandit: + name: bandit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install uv + run: pip install uv + - name: Set up uv environment + run: uv venv + - name: Install dependencies + run: uv pip install -r pyproject.toml .[dev] + - name: Activate virtual environment & run bandit + run: | + source .venv/bin/activate + bandit -r -c pyproject.toml . --skip B105,B106,B101 + + cognitive: + name: cognitive + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install uv + run: pip install uv + - name: Set up uv environment + run: uv venv + - name: Install dependencies + run: uv pip install -r pyproject.toml .[dev] + - name: Activate virtual environment & run ruff + run: | + source .venv/bin/activate + ruff check . --select C901 + + pydocstyle: + name: pydocstyle + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install uv + run: pip install uv + - name: Set up uv environment + run: uv venv + - name: Install dependencies + run: uv pip install -r pyproject.toml .[dev] + - name: Activate virtual environment & run pydocstyle + run: | + source .venv/bin/activate + pydocstyle . + + test: + name: test + runs-on: ubuntu-latest + needs: [bandit, cognitive, lint, pydocstyle] + steps: + - uses: actions/checkout@v3 + - name: Install uv + run: pip install uv + - name: Set up uv environment + run: uv venv + - name: Install dependencies + run: uv pip install -r pyproject.toml .[dev] + - name: Activate virtual environment & run tests + run: | + source .venv/bin/activate + pytest + + release: + name: tag, changelog, release, publish + runs-on: ubuntu-latest + needs: test + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v3 + - name: Install uv + run: pip install uv + - name: Set up uv environment + run: uv venv + - name: Install dependencies + run: uv pip install -r pyproject.toml + - name: Version + uses: paulhatch/semantic-version@v5.0.0 + id: version + with: + major_pattern: "(feat!)" + minor_pattern: "(feat)" + - name: Create changelog text + id: changelog + uses: loopwerk/tag-changelog@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + exclude_types: other,doc,chore + - name: Create release + uses: actions/create-release@latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.version.outputs.version_tag }} + release_name: Release ${{ steps.version.outputs.version_tag }} + body: ${{ steps.changelog.outputs.changes }} + - name: Create changelog pull request + uses: peter-evans/create-pull-request@v2 + with: + commit-message: "Release ${{ steps.version.outputs.version_tag }} [skip ci]" + labels: release, bot + title: "Release ${{ steps.version.outputs.version_tag }}" + body: | + # Release ${{ steps.version.outputs.version_tag }} + + Merge this PR to update your version and changelog! + + ## Included Pull Requests + + ${{ steps.changelog.outputs.changes }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..411066e --- /dev/null +++ b/.gitignore @@ -0,0 +1,141 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Ignore config.yaml +config/config.yaml + +# Ignore .vscode +.vscode/ + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a0cf709 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +# CHANGELOG diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..6ac8589 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,12 @@ +Copyright (c) 2022, OPEN COSMOS LTD +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b343c0 --- /dev/null +++ b/README.md @@ -0,0 +1,239 @@ +# DataCosmos SDK + +## Overview + +The **DataCosmos SDK** allows Open Cosmos' customers to interact with the **DataCosmos APIs** for seamless data management and retrieval. It provides authentication handling, HTTP request utilities, and a client for interacting with the **STAC API** (SpatioTemporal Asset Catalog). + +## Installation + +### Install via PyPI + +The easiest way to install the SDK is via **pip**: + +```sh +pip install datacosmos +``` + +## Getting Started + +### Initializing the Client + +The recommended way to initialize the SDK is by passing a `Config` object with authentication credentials: + +```python +from datacosmos.datacosmos_client import DatacosmosClient +from datacosmos.config import Config + +config = Config( + authentication={ + "client_id": "your_client_id", + "client_secret": "your_client_secret", + "token_url": "https://login.open-cosmos.com/oauth/token", + "audience": "https://beeapp.open-cosmos.com" + } +) +client = DatacosmosClient(config=config) +``` + +Alternatively, the SDK can load configuration automatically from: + +- A YAML file (`config/config.yaml`) +- Environment variables + +### STAC Client + +The STACClient enables interaction with the STAC API, allowing for searching, retrieving, creating, updating, and deleting STAC items and collections. + +#### Initialize STACClient + +```python +from datacosmos.stac.stac_client import STACClient + +stac_client = STACClient(client) +``` + +### STACClient Methods + +#### 1. **Fetch a Collection** + +```python +from datacosmos.stac.stac_client import STACClient +from datacosmos.datacosmos_client import DatacosmosClient + +datacosmos_client = DatacosmosClient() +stac_client = STACClient(datacosmos_client) + +collection = stac_client.fetch_collection("test-collection") +``` + +#### 2. **Fetch All Collections** + +```python +collections = list(stac_client.fetch_all_collections()) +``` + +#### 3. **Create a Collection** + +```python +from pystac import Collection + +new_collection = Collection( + id="test-collection", + title="Test Collection", + description="This is a test collection", + license="proprietary", + extent={ + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": {"interval": [["2023-01-01T00:00:00Z", None]]}, + }, +) + +stac_client.create_collection(new_collection) +``` + +#### 4. **Update a Collection** + +```python +from datacosmos.stac.collection.models.collection_update import CollectionUpdate + +update_data = CollectionUpdate( + title="Updated Collection Title version 2", + description="Updated description version 2", +) + +stac_client.update_collection("test-collection", update_data) +``` + +#### 5. **Delete a Collection** + +```python +collection_id = "test-collection" +stac_client.delete_collection(collection_id) +``` + +#### 1. **Search Items** + +```python +from datacosmos.stac.models.search_parameters import SearchParameters + +parameters = SearchParameters(collections=["example-collection"], limit=1) +items = list(stac_client.search_items(parameters=parameters)) +``` + +#### 2. **Fetch a Single Item** + +```python +item = stac_client.fetch_item(item_id="example-item", collection_id="example-collection") +``` + +#### 3. **Fetch All Items in a Collection** + +```python +items = stac_client.fetch_collection_items(collection_id="example-collection") +``` + +#### 4. **Create a New STAC Item** + +```python +from pystac import Item, Asset +from datetime import datetime + +stac_item = Item( + id="new-item", + geometry={"type": "Point", "coordinates": [102.0, 0.5]}, + bbox=[101.0, 0.0, 103.0, 1.0], + datetime=datetime.utcnow(), + properties={}, + collection="example-collection" +) + +stac_item.add_asset( + "image", + Asset( + href="https://example.com/sample-image.tiff", + media_type="image/tiff", + roles=["data"], + title="Sample Image" + ) +) + +stac_client.create_item(collection_id="example-collection", item=stac_item) +``` + +#### 5. **Update an Existing STAC Item** + +```python +from datacosmos.stac.models.item_update import ItemUpdate +from pystac import Asset, Link + +update_payload = ItemUpdate( + properties={ + "new_property": "updated_value", + "datetime": "2024-11-10T14:58:00Z" + }, + assets={ + "image": Asset( + href="https://example.com/updated-image.tiff", + media_type="image/tiff" + ) + }, + links=[ + Link(rel="self", target="https://example.com/updated-image.tiff") + ], + geometry={ + "type": "Point", + "coordinates": [10, 20] + }, + bbox=[10.0, 20.0, 30.0, 40.0] +) + +stac_client.update_item(item_id="new-item", collection_id="example-collection", update_data=update_payload) +``` + +#### 6. **Delete an Item** + +```python +stac_client.delete_item(item_id="new-item", collection_id="example-collection") +``` + +## Configuration Options + +- **Recommended:** Instantiate `DatacosmosClient` with a `Config` object. +- Alternatively, use **YAML files** (`config/config.yaml`). +- Or, use **environment variables**. + +## Contributing + +If you would like to contribute: + +1. Fork the repository. +2. Create a feature branch. +3. Submit a pull request. + +### Development Setup + +If you are developing the SDK, you can use `uv` for dependency management: + +```sh +pip install uv +uv venv +uv pip install -r pyproject.toml +uv pip install -r pyproject.toml .[dev] +source .venv/bin/activate +``` + +Before making changes, ensure that: + +- The code is formatted using **Black** and **isort**. +- Static analysis and linting are performed using **ruff** and **pydocstyle**. +- Security checks are performed using **bandit**. +- Tests are executed with **pytest**. + +```sh +black . +isort . +ruff check . --select C901 +pydocstyle . +bandit -r -c pyproject.toml . --skip B105,B106,B101 +pytest +``` diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..ac196a0 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,10 @@ +"""Configuration package for the Datacosmos SDK. + +This package includes modules for loading and managing authentication +configurations. +""" + +# Expose Config class for easier imports +from .config import Config + +__all__ = ["Config"] diff --git a/config/config.py b/config/config.py new file mode 100644 index 0000000..35f7c81 --- /dev/null +++ b/config/config.py @@ -0,0 +1,166 @@ +"""Configuration module for the Datacosmos SDK. + +Handles configuration management using Pydantic and Pydantic Settings. +It loads default values, allows overrides via YAML configuration files, +and supports environment variable-based overrides. +""" + +import os +from typing import ClassVar, Optional + +import yaml +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from config.models.url import URL + + +class Config(BaseSettings): + """Centralized configuration for the Datacosmos SDK.""" + + model_config = SettingsConfigDict( + env_nested_delimiter="__", + nested_model_default_partial_update=True, + ) + + authentication: Optional[M2MAuthenticationConfig] = None + stac: Optional[URL] = None + + DEFAULT_AUTH_TYPE: ClassVar[str] = "m2m" + DEFAULT_AUTH_TOKEN_URL: ClassVar[str] = "https://login.open-cosmos.com/oauth/token" + DEFAULT_AUTH_AUDIENCE: ClassVar[str] = "https://beeapp.open-cosmos.com" + + @classmethod + def from_yaml(cls, file_path: str = "config/config.yaml") -> "Config": + """Load configuration from a YAML file and override defaults. + + Args: + file_path (str): The path to the YAML configuration file. + + Returns: + Config: An instance of the Config class with loaded settings. + """ + config_data: dict = {} + if os.path.exists(file_path): + with open(file_path, "r") as f: + yaml_data = yaml.safe_load(f) or {} + # Remove empty values from YAML to avoid overwriting with `None` + config_data = { + key: value + for key, value in yaml_data.items() + if value not in [None, ""] + } + + return cls(**config_data) + + @classmethod + def from_env(cls) -> "Config": + """Load configuration from environment variables. + + Returns: + Config: An instance of the Config class with settings loaded from environment variables. + """ + authentication_config = M2MAuthenticationConfig( + type=os.getenv("OC_AUTH_TYPE", cls.DEFAULT_AUTH_TYPE), + client_id=os.getenv("OC_AUTH_CLIENT_ID"), + client_secret=os.getenv("OC_AUTH_CLIENT_SECRET"), + token_url=os.getenv("OC_AUTH_TOKEN_URL", cls.DEFAULT_AUTH_TOKEN_URL), + audience=os.getenv("OC_AUTH_AUDIENCE", cls.DEFAULT_AUTH_AUDIENCE), + ) + + stac_config = URL( + protocol=os.getenv("OC_STAC_PROTOCOL", "https"), + host=os.getenv("OC_STAC_HOST", "app.open-cosmos.com"), + port=int(os.getenv("OC_STAC_PORT", "443")), + path=os.getenv("OC_STAC_PATH", "/api/data/v0/stac"), + ) + + return cls(authentication=authentication_config, stac=stac_config) + + @field_validator("authentication", mode="before") + @classmethod + def validate_authentication( + cls, auth_data: Optional[dict] + ) -> M2MAuthenticationConfig: + """Ensure authentication is provided and apply defaults. + + Args: + auth_data (Optional[dict]): The authentication config as a dictionary. + + Returns: + M2MAuthenticationConfig: The validated authentication configuration. + + Raises: + ValueError: If authentication is missing or required fields are not set. + """ + if not auth_data: + cls.raise_missing_auth_error() + + auth = cls.parse_auth_config(auth_data) + auth = cls.apply_auth_defaults(auth) + + cls.check_required_auth_fields(auth) + return auth + + @staticmethod + def raise_missing_auth_error(): + """Raise an error when authentication is missing.""" + raise ValueError( + "M2M authentication is required. Provide it via:\n" + "1. Explicit instantiation (Config(authentication=...))\n" + "2. A YAML config file (config.yaml)\n" + "3. Environment variables (OC_AUTH_CLIENT_ID, OC_AUTH_CLIENT_SECRET, etc.)" + ) + + @staticmethod + def parse_auth_config(auth_data: dict) -> M2MAuthenticationConfig: + """Convert dictionary input to M2MAuthenticationConfig object.""" + return ( + M2MAuthenticationConfig(**auth_data) + if isinstance(auth_data, dict) + else auth_data + ) + + @classmethod + def apply_auth_defaults( + cls, auth: M2MAuthenticationConfig + ) -> M2MAuthenticationConfig: + """Apply default authentication values if they are missing.""" + auth.type = auth.type or cls.DEFAULT_AUTH_TYPE + auth.token_url = auth.token_url or cls.DEFAULT_AUTH_TOKEN_URL + auth.audience = auth.audience or cls.DEFAULT_AUTH_AUDIENCE + return auth + + @staticmethod + def check_required_auth_fields(auth: M2MAuthenticationConfig): + """Ensure required fields (client_id, client_secret) are provided.""" + missing_fields = [ + field + for field in ("client_id", "client_secret") + if not getattr(auth, field) + ] + if missing_fields: + raise ValueError( + f"Missing required authentication fields: {', '.join(missing_fields)}" + ) + + @field_validator("stac", mode="before") + @classmethod + def validate_stac(cls, stac_config: Optional[URL]) -> URL: + """Ensure STAC configuration has a default if not explicitly set. + + Args: + stac_config (Optional[URL]): The STAC config to validate. + + Returns: + URL: The validated STAC configuration. + """ + if stac_config is None: + return URL( + protocol="https", + host="app.open-cosmos.com", + port=443, + path="/api/data/v0/stac", + ) + return stac_config diff --git a/config/models/m2m_authentication_config.py b/config/models/m2m_authentication_config.py new file mode 100644 index 0000000..45d92ca --- /dev/null +++ b/config/models/m2m_authentication_config.py @@ -0,0 +1,23 @@ +"""Module for configuring machine-to-machine (M2M) authentication. + +Used when running scripts in the cluster that require automated authentication +without user interaction. +""" + +from typing import Literal + +from pydantic import BaseModel + + +class M2MAuthenticationConfig(BaseModel): + """Configuration for machine-to-machine authentication. + + This is used when running scripts in the cluster that require authentication + with client credentials. + """ + + type: Literal["m2m"] + client_id: str + token_url: str + audience: str + client_secret: str diff --git a/config/models/url.py b/config/models/url.py new file mode 100644 index 0000000..3544876 --- /dev/null +++ b/config/models/url.py @@ -0,0 +1,35 @@ +"""Module defining a structured URL configuration model. + +Ensures that URLs contain required components such as protocol, host, +port, and path. +""" + +from pydantic import BaseModel + +from datacosmos.utils.url import URL as DomainURL + + +class URL(BaseModel): + """Generic configuration model for a URL. + + This class provides attributes to store URL components and a method + to convert them into a `DomainURL` instance. + """ + + protocol: str + host: str + port: int + path: str + + def as_domain_url(self) -> DomainURL: + """Convert the URL instance to a `DomainURL` object. + + Returns: + DomainURL: A domain-specific URL object. + """ + return DomainURL( + protocol=self.protocol, + host=self.host, + port=self.port, + base=self.path, + ) diff --git a/datacosmos/__init__.py b/datacosmos/__init__.py new file mode 100644 index 0000000..8e3838b --- /dev/null +++ b/datacosmos/__init__.py @@ -0,0 +1 @@ +"""A library for interacting with DataCosmos from Python code.""" diff --git a/datacosmos/datacosmos_client.py b/datacosmos/datacosmos_client.py new file mode 100644 index 0000000..d4082a8 --- /dev/null +++ b/datacosmos/datacosmos_client.py @@ -0,0 +1,120 @@ +"""DatacosmosClient handles authenticated interactions with the Datacosmos API. + +Automatically manages token refreshing and provides HTTP convenience +methods. +""" + +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +import requests +from oauthlib.oauth2 import BackendApplicationClient +from requests.exceptions import ConnectionError, HTTPError, RequestException, Timeout +from requests_oauthlib import OAuth2Session + +from config.config import Config +from datacosmos.exceptions.datacosmos_exception import DatacosmosException + + +class DatacosmosClient: + """Client to interact with the Datacosmos API with authentication and request handling.""" + + def __init__(self, config: Optional[Config] = None): + """Initialize the DatacosmosClient. + + Args: + config (Optional[Config]): Configuration object. + """ + if config: + self.config = config + else: + try: + self.config = Config.from_yaml() + except ValueError: + self.config = Config.from_env() + + self.token = None + self.token_expiry = None + self._http_client = self._authenticate_and_initialize_client() + + def _authenticate_and_initialize_client(self) -> requests.Session: + """Authenticate and initialize the HTTP client with a valid token.""" + try: + client = BackendApplicationClient( + client_id=self.config.authentication.client_id + ) + oauth_session = OAuth2Session(client=client) + + token_response = oauth_session.fetch_token( + token_url=self.config.authentication.token_url, + client_id=self.config.authentication.client_id, + client_secret=self.config.authentication.client_secret, + audience=self.config.authentication.audience, + ) + + self.token = token_response["access_token"] + self.token_expiry = datetime.now(timezone.utc) + timedelta( + seconds=token_response.get("expires_in", 3600) + ) + + http_client = requests.Session() + http_client.headers.update({"Authorization": f"Bearer {self.token}"}) + return http_client + except (HTTPError, ConnectionError, Timeout) as e: + raise DatacosmosException(f"Authentication failed: {str(e)}") from e + except RequestException as e: + raise DatacosmosException( + f"Unexpected request failure during authentication: {str(e)}" + ) from e + + def _refresh_token_if_needed(self): + """Refresh the token if it has expired.""" + if not self.token or self.token_expiry <= datetime.now(timezone.utc): + self._http_client = self._authenticate_and_initialize_client() + + def request( + self, method: str, url: str, *args: Any, **kwargs: Any + ) -> requests.Response: + """Send an HTTP request using the authenticated session.""" + self._refresh_token_if_needed() + try: + response = self._http_client.request(method, url, *args, **kwargs) + response.raise_for_status() + return response + except HTTPError as e: + raise DatacosmosException( + f"HTTP error during {method.upper()} request to {url}", + response=e.response, + ) from e + except ConnectionError as e: + raise DatacosmosException( + f"Connection error during {method.upper()} request to {url}: {str(e)}" + ) from e + except Timeout as e: + raise DatacosmosException( + f"Request timeout during {method.upper()} request to {url}: {str(e)}" + ) from e + except RequestException as e: + raise DatacosmosException( + f"Unexpected request failure during {method.upper()} request to {url}: {str(e)}" + ) from e + + def get(self, url: str, *args: Any, **kwargs: Any) -> requests.Response: + """Send a GET request using the authenticated session.""" + return self.request("GET", url, *args, **kwargs) + + def post(self, url: str, *args: Any, **kwargs: Any) -> requests.Response: + """Send a POST request using the authenticated session.""" + return self.request("POST", url, *args, **kwargs) + + def put(self, url: str, *args: Any, **kwargs: Any) -> requests.Response: + """Send a PUT request using the authenticated session.""" + return self.request("PUT", url, *args, **kwargs) + + def patch(self, url: str, *args: Any, **kwargs: Any) -> requests.Response: + """Send a PATCH request using the authenticated session.""" + return self.request("PATCH", url, *args, **kwargs) + + def delete(self, url: str, *args: Any, **kwargs: Any) -> requests.Response: + """Send a DELETE request using the authenticated session.""" + return self.request("DELETE", url, *args, **kwargs) diff --git a/datacosmos/exceptions/__init__.py b/datacosmos/exceptions/__init__.py new file mode 100644 index 0000000..17476aa --- /dev/null +++ b/datacosmos/exceptions/__init__.py @@ -0,0 +1 @@ +"""Exceptions for the datacosmos package.""" diff --git a/datacosmos/exceptions/datacosmos_exception.py b/datacosmos/exceptions/datacosmos_exception.py new file mode 100644 index 0000000..5639aca --- /dev/null +++ b/datacosmos/exceptions/datacosmos_exception.py @@ -0,0 +1,27 @@ +"""Base exception class for all Datacosmos SDK exceptions.""" + +from typing import Optional + +from requests import Response +from requests.exceptions import RequestException + + +class DatacosmosException(RequestException): + """Base exception class for all Datacosmos SDK exceptions.""" + + def __init__(self, message: str, response: Optional[Response] = None): + """Initialize DatacosmosException. + + Args: + message (str): The error message. + response (Optional[Response]): The HTTP response object, if available. + """ + self.response = response + self.status_code = response.status_code if response else None + self.details = response.text if response else None + full_message = ( + f"{message} (Status: {self.status_code}, Details: {self.details})" + if response + else message + ) + super().__init__(full_message) diff --git a/datacosmos/stac/__init__.py b/datacosmos/stac/__init__.py new file mode 100644 index 0000000..e87992f --- /dev/null +++ b/datacosmos/stac/__init__.py @@ -0,0 +1,5 @@ +"""STAC package for interacting with the STAC API, providing query and fetch functionalities. + +It enables interaction with STAC (SpatioTemporal Asset Catalog) services +using an authenticated Datacosmos client. +""" diff --git a/datacosmos/stac/collection/__init__.py b/datacosmos/stac/collection/__init__.py new file mode 100644 index 0000000..f6642d5 --- /dev/null +++ b/datacosmos/stac/collection/__init__.py @@ -0,0 +1,4 @@ +"""STAC package for interacting with collections from the STAC API, providing query and fetch functionalities. + +It enables interaction with collections from the STAC using an authenticated Datacosmos client. +""" diff --git a/datacosmos/stac/collection/collection_client.py b/datacosmos/stac/collection/collection_client.py new file mode 100644 index 0000000..013ec27 --- /dev/null +++ b/datacosmos/stac/collection/collection_client.py @@ -0,0 +1,149 @@ +"""Handles operations related to STAC collections.""" + +from typing import Generator, Optional + +from pystac import Collection, Extent, SpatialExtent, TemporalExtent +from pystac.utils import str_to_datetime + +from datacosmos.datacosmos_client import DatacosmosClient +from datacosmos.stac.collection.models.collection_update import CollectionUpdate +from datacosmos.utils.http_response import check_api_response + + +class CollectionClient: + """Handles operations related to STAC collections.""" + + def __init__(self, client: DatacosmosClient): + """Initialize the CollectionClient with a DatacosmosClient.""" + self.client = client + self.base_url = client.config.stac.as_domain_url() + + def fetch_collection(self, collection_id: str) -> Collection: + """Fetch details of an existing STAC collection.""" + url = self.base_url.with_suffix(f"/collections/{collection_id}") + response = self.client.get(url) + check_api_response(response) + return Collection.from_dict(response.json()) + + def create_collection(self, collection: Collection) -> None: + """Create a new STAC collection. + + Args: + collection (Collection): The STAC collection to create. + + Raises: + InvalidRequest: If the collection data is malformed. + """ + if isinstance(collection.extent, dict): + spatial_data = collection.extent.get("spatial", {}).get("bbox", [[]]) + temporal_data = collection.extent.get("temporal", {}).get("interval", [[]]) + + # Convert string timestamps to datetime objects + parsed_temporal = [] + for interval in temporal_data: + start = str_to_datetime(interval[0]) if interval[0] else None + end = ( + str_to_datetime(interval[1]) + if len(interval) > 1 and interval[1] + else None + ) + parsed_temporal.append([start, end]) + + collection.extent = Extent( + spatial=SpatialExtent(spatial_data), + temporal=TemporalExtent(parsed_temporal), + ) + + url = self.base_url.with_suffix("/collections") + response = self.client.post(url, json=collection.to_dict()) + check_api_response(response) + + def update_collection( + self, collection_id: str, update_data: CollectionUpdate + ) -> None: + """Update an existing STAC collection.""" + url = self.base_url.with_suffix(f"/collections/{collection_id}") + response = self.client.patch( + url, json=update_data.model_dump(by_alias=True, exclude_none=True) + ) + check_api_response(response) + + def delete_collection(self, collection_id: str) -> None: + """Delete a STAC collection by its ID.""" + url = self.base_url.with_suffix(f"/collections/{collection_id}") + response = self.client.delete(url) + check_api_response(response) + + def fetch_all_collections(self) -> Generator[Collection, None, None]: + """Fetch all STAC collections with pagination support.""" + url = self.base_url.with_suffix("/collections") + params = {"limit": 10} + + while True: + data = self._fetch_collections_page(url, params) + yield from self._parse_collections(data) + + next_cursor = self._get_next_pagination_cursor(data) + if not next_cursor: + break + + params["cursor"] = next_cursor + + def _fetch_collections_page(self, url: str, params: dict) -> dict: + """Fetch a single page of collections from the API.""" + response = self.client.get(url, params=params) + check_api_response(response) + + data = response.json() + + if isinstance(data, list): + return {"collections": data} + + return data + + def _parse_collections(self, data: dict) -> Generator[Collection, None, None]: + """Convert API response data to STAC Collection objects, ensuring required fields exist.""" + return ( + Collection.from_dict( + { + **collection, + "type": collection.get("type", "Collection"), + "id": collection.get("id", ""), + "stac_version": collection.get("stac_version", "1.0.0"), + "extent": collection.get( + "extent", + {"spatial": {"bbox": []}, "temporal": {"interval": []}}, + ), + "links": collection.get("links", []) or [], + "properties": collection.get("properties", {}), + } + ) + for collection in data.get("collections", []) + if collection.get("type") == "Collection" + ) + + def _get_next_pagination_cursor(self, data: dict) -> Optional[str]: + """Extract the next pagination token from the response.""" + next_href = self._get_next_link(data) + return self._extract_pagination_token(next_href) if next_href else None + + def _get_next_link(self, data: dict) -> Optional[str]: + """Extract the next page link from the response.""" + next_link = next( + (link for link in data.get("links", []) if link.get("rel") == "next"), None + ) + return next_link.get("href", "") if next_link else None + + def _extract_pagination_token(self, next_href: str) -> Optional[str]: + """Extract the pagination token from the next link URL. + + Args: + next_href (str): The next page URL. + + Returns: + Optional[str]: The extracted token, or None if parsing fails. + """ + try: + return next_href.split("?")[1].split("=")[-1] + except (IndexError, AttributeError): + raise InvalidRequest(f"Failed to parse pagination token from {next_href}") diff --git a/datacosmos/stac/collection/models/__init__.py b/datacosmos/stac/collection/models/__init__.py new file mode 100644 index 0000000..f4c32e1 --- /dev/null +++ b/datacosmos/stac/collection/models/__init__.py @@ -0,0 +1 @@ +"""Models for the Collection Client.""" diff --git a/datacosmos/stac/collection/models/collection_update.py b/datacosmos/stac/collection/models/collection_update.py new file mode 100644 index 0000000..7d44619 --- /dev/null +++ b/datacosmos/stac/collection/models/collection_update.py @@ -0,0 +1,46 @@ +"""Represents a structured update model for STAC collections. + +Allows partial updates where only the provided fields are modified. +""" +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field +from pystac import Extent, Link, Provider, Summaries + + +class CollectionUpdate(BaseModel): + """Represents a structured update model for STAC collections. + + Allows partial updates where only the provided fields are modified. + """ + + model_config = {"arbitrary_types_allowed": True} + + title: Optional[str] = Field(None, description="Title of the STAC collection.") + description: Optional[str] = Field( + None, description="Description of the collection." + ) + keywords: Optional[List[str]] = Field( + None, description="List of keywords associated with the collection." + ) + license: Optional[str] = Field(None, description="Collection license information.") + providers: Optional[List[Provider]] = Field( + None, description="List of data providers." + ) + extent: Optional[Extent] = Field( + None, description="Spatial and temporal extent of the collection." + ) + summaries: Optional[Summaries] = Field( + None, description="Summaries for the collection." + ) + links: Optional[List[Link]] = Field( + None, description="List of links associated with the collection." + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert the model into a dictionary, excluding `None` values. + + Returns: + Dict[str, Any]: Dictionary representation of the update payload. + """ + return self.model_dump(by_alias=True, exclude_none=True) diff --git a/datacosmos/stac/item/__init__.py b/datacosmos/stac/item/__init__.py new file mode 100644 index 0000000..05bd8ad --- /dev/null +++ b/datacosmos/stac/item/__init__.py @@ -0,0 +1,4 @@ +"""STAC package for interacting with items from the STAC API, providing query and fetch functionalities. + +It enables interaction with items from the STAC using an authenticated Datacosmos client. +""" diff --git a/datacosmos/stac/item/item_client.py b/datacosmos/stac/item/item_client.py new file mode 100644 index 0000000..fc991e4 --- /dev/null +++ b/datacosmos/stac/item/item_client.py @@ -0,0 +1,185 @@ +"""STAC Client module for interacting with a STAC (SpatioTemporal Asset Catalog) API. + +Provides methods for querying, fetching, creating, updating, and deleting STAC items. +""" + +from typing import Generator, Optional + +from pystac import Item + +from datacosmos.datacosmos_client import DatacosmosClient +from datacosmos.exceptions.datacosmos_exception import DatacosmosException +from datacosmos.stac.item.models.item_update import ItemUpdate +from datacosmos.stac.item.models.search_parameters import SearchParameters +from datacosmos.utils.http_response import check_api_response + + +class ItemClient: + """Client for interacting with the STAC API.""" + + def __init__(self, client: DatacosmosClient): + """Initialize the STACClient with a DatacosmosClient. + + Args: + client (DatacosmosClient): The authenticated Datacosmos client instance. + """ + self.client = client + self.base_url = client.config.stac.as_domain_url() + + def fetch_item(self, item_id: str, collection_id: str) -> Item: + """Fetch a single STAC item by ID. + + Args: + item_id (str): The ID of the item to fetch. + collection_id (str): The ID of the collection containing the item. + + Returns: + Item: The fetched STAC item. + """ + url = self.base_url.with_suffix(f"/collections/{collection_id}/items/{item_id}") + response = self.client.get(url) + check_api_response(response) + return Item.from_dict(response.json()) + + def fetch_collection_items( + self, collection_id: str, parameters: Optional[SearchParameters] = None + ) -> Generator[Item, None, None]: + """Fetch all items in a collection with optional filtering. + + Args: + collection_id (str): The ID of the collection. + parameters (Optional[SearchParameters]): Filtering parameters (spatial, temporal, etc.). + + Yields: + Item: Parsed STAC item. + """ + if parameters is None: + parameters = SearchParameters(collections=[collection_id]) + + return self.search_items(parameters) + + def search_items(self, parameters: SearchParameters) -> Generator[Item, None, None]: + """Query the STAC catalog using the POST endpoint with filtering and pagination. + + Args: + parameters (SearchParameters): The search parameters. + + Yields: + Item: Parsed STAC item. + """ + url = self.base_url.with_suffix("/search") + body = parameters.model_dump(by_alias=True, exclude_none=True) + return self._paginate_items(url, body) + + def create_item(self, collection_id: str, item: Item) -> None: + """Create a new STAC item in a specified collection. + + Args: + collection_id (str): The ID of the collection where the item will be created. + item (Item): The STAC Item to be created. + + Raises: + RequestError: If the API returns an error response. + """ + url = self.base_url.with_suffix(f"/collections/{collection_id}/items") + item_json: dict = item.to_dict() + + response = self.client.post(url, json=item_json) + check_api_response(response) + + def update_item( + self, item_id: str, collection_id: str, update_data: ItemUpdate + ) -> None: + """Partially update an existing STAC item. + + Args: + item_id (str): The ID of the item to update. + collection_id (str): The ID of the collection containing the item. + update_data (ItemUpdate): The structured update payload. + """ + url = self.base_url.with_suffix(f"/collections/{collection_id}/items/{item_id}") + + update_payload = update_data.model_dump(by_alias=True, exclude_none=True) + + if "assets" in update_payload: + update_payload["assets"] = { + key: asset.to_dict() for key, asset in update_payload["assets"].items() + } + if "links" in update_payload: + update_payload["links"] = [ + link.to_dict() for link in update_payload["links"] + ] + + response = self.client.patch(url, json=update_payload) + check_api_response(response) + + def delete_item(self, item_id: str, collection_id: str) -> None: + """Delete a STAC item by its ID. + + Args: + item_id (str): The ID of the item to delete. + collection_id (str): The ID of the collection containing the item. + + Raises: + OCError: If the item is not found or deletion is forbidden. + """ + url = self.base_url.with_suffix(f"/collections/{collection_id}/items/{item_id}") + response = self.client.delete(url) + check_api_response(response) + + def _paginate_items(self, url: str, body: dict) -> Generator[Item, None, None]: + """Handle pagination for the STAC search POST endpoint. + + Fetches items one page at a time using the 'next' link. + + Args: + url (str): The base URL for the search endpoint. + body (dict): The request body containing search parameters. + + Yields: + Item: Parsed STAC item. + """ + params = {"limit": body.get("limit", 10)} + + while True: + response = self.client.post(url, json=body, params=params) + check_api_response(response) + data = response.json() + + yield from (Item.from_dict(feature) for feature in data.get("features", [])) + + next_href = self._get_next_link(data) + if not next_href: + break + + token = self._extract_pagination_token(next_href) + if not token: + break + params["cursor"] = token + + def _get_next_link(self, data: dict) -> Optional[str]: + """Extract the next page link from the response.""" + next_link = next( + (link for link in data.get("links", []) if link.get("rel") == "next"), None + ) + return next_link.get("href", "") if next_link else None + + def _extract_pagination_token(self, next_href: str) -> Optional[str]: + """Extract the pagination token from the next link URL. + + Args: + next_href (str): The next page URL. + + Returns: + Optional[str]: The extracted token, or None if parsing fails. + + Raises: + DatacosmosException: If pagination token extraction fails. + """ + try: + return next_href.split("?")[1].split("=")[-1] + except (IndexError, AttributeError) as e: + raise DatacosmosException( + f"Failed to parse pagination token from {next_href}", + response=e.response, + ) from e diff --git a/datacosmos/stac/item/models/__init__.py b/datacosmos/stac/item/models/__init__.py new file mode 100644 index 0000000..1a5b17b --- /dev/null +++ b/datacosmos/stac/item/models/__init__.py @@ -0,0 +1 @@ +"""Models for the Item Client.""" diff --git a/datacosmos/stac/item/models/item_update.py b/datacosmos/stac/item/models/item_update.py new file mode 100644 index 0000000..3fbf8cf --- /dev/null +++ b/datacosmos/stac/item/models/item_update.py @@ -0,0 +1,57 @@ +"""Model representing a partial update for a STAC item.""" + +from typing import Any, Optional + +from pydantic import BaseModel, Field, model_validator +from pystac import Asset, Link + + +class ItemUpdate(BaseModel): + """Model representing a partial update for a STAC item.""" + + model_config = {"arbitrary_types_allowed": True} + + stac_extensions: Optional[list[str]] = None + geometry: Optional[dict[str, Any]] = None + bbox: Optional[list[float]] = Field( + None, min_items=4, max_items=4 + ) # Must be [minX, minY, maxX, maxY] + properties: Optional[dict[str, Any]] = None + assets: Optional[dict[str, Asset]] = None + links: Optional[list[Link]] = None + + def set_geometry(self, geom_type: str, coordinates: list[Any]) -> None: + """Set the geometry manually without using shapely. + + Args: + geom_type (str): The type of geometry (e.g., 'Point', 'Polygon'). + coordinates (list[Any]): The coordinates defining the geometry. + """ + self.geometry = {"type": geom_type, "coordinates": coordinates} + + @staticmethod + def has_valid_datetime(properties: dict[str, Any]) -> bool: + """Check if 'datetime' is present and not None.""" + return properties.get("datetime") is not None + + @staticmethod + def has_valid_datetime_range(properties: dict[str, Any]) -> bool: + """Check if both 'start_datetime' and 'end_datetime' are present and not None.""" + return all( + properties.get(key) is not None + for key in ["start_datetime", "end_datetime"] + ) + + @model_validator(mode="before") + def validate_datetime_fields(cls, values): + """Ensure at least one of 'datetime' or 'start_datetime'/'end_datetime' exists.""" + properties = values.get("properties", {}) + + if not cls.has_valid_datetime(properties) and not cls.has_valid_datetime_range( + properties + ): + raise ValueError( + "Either 'datetime' or both 'start_datetime' and 'end_datetime' must be provided." + ) + + return values diff --git a/datacosmos/stac/item/models/search_parameters.py b/datacosmos/stac/item/models/search_parameters.py new file mode 100644 index 0000000..8dd74d3 --- /dev/null +++ b/datacosmos/stac/item/models/search_parameters.py @@ -0,0 +1,58 @@ +"""Module defining the SearchParameters model for STAC API queries, encapsulating filtering criteria. + +It includes spatial, temporal, and property-based filters for querying STAC items efficiently. +""" + +from typing import Optional, Union + +from pydantic import BaseModel, Field, model_validator + + +class SearchParameters(BaseModel): + """Encapsulates the parameters for the STAC search API with validation.""" + + bbox: Optional[list[float]] = Field( + None, + description="Bounding box filter [minX, minY, maxX, maxY]. Optional six values for 3D bounding box.", + example=[-180.0, -90.0, 180.0, 90.0], + ) + datetime_range: Optional[str] = Field( + None, + alias="datetime", + description=( + "Temporal filter, either a single RFC 3339 datetime or an interval. " + 'Example: "2025-01-01T00:00:00Z/.."' + ), + ) + intersects: Optional[dict] = Field( + None, description="GeoJSON geometry filter, e.g., a Polygon or Point." + ) + ids: Optional[list[str]] = Field( + None, + description="Array of item IDs to filter by.", + example=["item1", "item2"], + ) + collections: Optional[list[str]] = Field( + None, + description="Array of collection IDs to filter by.", + example=["collection1", "collection2"], + ) + limit: Optional[int] = Field( + None, + ge=1, + le=10000, + description="Maximum number of items per page. Default: 10, Max: 10000.", + example=10, + ) + query: Optional[dict[str, dict[str, Union[str, int, float]]]] = Field( + None, + description="Additional property filters, e.g., { 'cloud_coverage': { 'lt': 10 } }.", + ) + + @model_validator(mode="before") + def validate_bbox(cls, values): + """Validate that the `bbox` field contains either 4 or 6 values.""" + bbox = values.get("bbox") + if bbox and len(bbox) not in {4, 6}: + raise ValueError("bbox must contain 4 or 6 values.") + return values diff --git a/datacosmos/stac/stac_client.py b/datacosmos/stac/stac_client.py new file mode 100644 index 0000000..fad514f --- /dev/null +++ b/datacosmos/stac/stac_client.py @@ -0,0 +1,12 @@ +"""Unified interface for STAC API, combining Item & Collection operations.""" + +from datacosmos.stac.collection.collection_client import CollectionClient +from datacosmos.stac.item.item_client import ItemClient + + +class STACClient(ItemClient, CollectionClient): + """Unified interface for STAC API, combining Item & Collection operations.""" + + def __init__(self, client): + """Initialize the STACClient with a DatacosmosClient.""" + super().__init__(client) diff --git a/datacosmos/utils/__init__.py b/datacosmos/utils/__init__.py new file mode 100644 index 0000000..fdfe1ee --- /dev/null +++ b/datacosmos/utils/__init__.py @@ -0,0 +1 @@ +"""Http response and url utils for datacosmos.""" diff --git a/datacosmos/utils/http_response/__init__.py b/datacosmos/utils/http_response/__init__.py new file mode 100644 index 0000000..2a6b2d2 --- /dev/null +++ b/datacosmos/utils/http_response/__init__.py @@ -0,0 +1 @@ +"""Validates an API response.""" diff --git a/datacosmos/utils/http_response/check_api_response.py b/datacosmos/utils/http_response/check_api_response.py new file mode 100644 index 0000000..6ada038 --- /dev/null +++ b/datacosmos/utils/http_response/check_api_response.py @@ -0,0 +1,34 @@ +"""Validates an API response and raises a DatacosmosException if an error occurs.""" + +from pydantic import ValidationError +from requests import Response + +from datacosmos.exceptions.datacosmos_exception import DatacosmosException +from datacosmos.utils.http_response.models.datacosmos_response import DatacosmosResponse + + +def check_api_response(response: Response) -> None: + """Validates an API response and raises a DatacosmosException if an error occurs. + + Args: + resp (requests.Response): The response object. + + Raises: + DatacosmosException: If the response status code indicates an error. + """ + if 200 <= response.status_code < 400: + return + + try: + response = DatacosmosResponse.model_validate_json(response.text) + msg = response.errors[0].human_readable() + if len(response.errors) > 1: + msg = "\n * " + "\n * ".join( + error.human_readable() for error in response.errors + ) + raise DatacosmosException(msg, response=response) + + except ValidationError: + raise DatacosmosException( + f"HTTP {response.status_code}: {response.text}", response=response + ) diff --git a/datacosmos/utils/http_response/models/__init__.py b/datacosmos/utils/http_response/models/__init__.py new file mode 100644 index 0000000..6aac997 --- /dev/null +++ b/datacosmos/utils/http_response/models/__init__.py @@ -0,0 +1 @@ +"""Models for validation of API response.""" diff --git a/datacosmos/utils/http_response/models/datacosmos_error.py b/datacosmos/utils/http_response/models/datacosmos_error.py new file mode 100644 index 0000000..62ed3a5 --- /dev/null +++ b/datacosmos/utils/http_response/models/datacosmos_error.py @@ -0,0 +1,26 @@ +"""Structured API error message for Datacosmos.""" + +from pydantic import BaseModel + + +class DatacosmosError(BaseModel): + """Structured API error message for Datacosmos.""" + + message: str + field: str | None = None + type: str | None = None + source: str | None = None + trace_id: str | None = None + + def human_readable(self) -> str: + """Formats the error message into a readable format.""" + msg = self.message + if self.type: + msg += f" (type: {self.type})" + if self.field: + msg += f" (field: {self.field})" + if self.source: + msg += f" (source: {self.source})" + if self.trace_id: + msg += f" (trace_id: {self.trace_id})" + return msg diff --git a/datacosmos/utils/http_response/models/datacosmos_response.py b/datacosmos/utils/http_response/models/datacosmos_response.py new file mode 100644 index 0000000..a668e88 --- /dev/null +++ b/datacosmos/utils/http_response/models/datacosmos_response.py @@ -0,0 +1,11 @@ +"""Structured response for Datacosmos handling multiple API errors.""" + +from pydantic import BaseModel + +from datacosmos.utils.http_response.models.datacosmos_error import DatacosmosError + + +class DatacosmosResponse(BaseModel): + """Structured response for Datacosmos handling multiple API errors.""" + + errors: list[DatacosmosError] diff --git a/datacosmos/utils/url.py b/datacosmos/utils/url.py new file mode 100644 index 0000000..4242f87 --- /dev/null +++ b/datacosmos/utils/url.py @@ -0,0 +1,37 @@ +"""URL utility class for building and handling URLs in the SDK.""" + + +class URL: + """Class to represent and build URLs in a convenient way.""" + + def __init__(self, protocol: str, host: str, port: int, base: str): + """Creates a new basis to build URLs. + + Args: + protocol (str): Protocol to use in the URL (http/https). + host (str): Hostname (e.g., example.com). + port (int): Port number. + base (str): Base path (e.g., /api/v1). + """ + self.protocol = protocol + self.host = host + self.port = port + self.base = base + + def string(self) -> str: + """Returns the full URL as a string.""" + port = "" if self.port in [80, 443] else f":{self.port}" + base = f"/{self.base.lstrip('/')}" if self.base else "" + return f"{self.protocol}://{self.host}{port}{base}" + + def with_suffix(self, suffix: str) -> str: + """Appends a suffix to the URL, ensuring proper formatting. + + Args: + suffix (str): The path to append. + + Returns: + str: Full URL with the suffix. + """ + base = self.string() + return f"{base.rstrip('/')}/{suffix.lstrip('/')}" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ab723d3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,51 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "datacosmos" +version = "0.0.1" +authors = [ + { name="Open Cosmos", email="support@open-cosmos.com" }, +] +description = "A library for interacting with DataCosmos from Python code" +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", +] +dependencies = [ + "pydantic_settings>=2.7.0", + "requests==2.31.0", + "oauthlib==3.2.0", + "requests-oauthlib==1.3.1", + "pydantic==2.10.6", + "pystac==1.12.1" +] + +[project.optional-dependencies] +dev = [ + "black==22.3.0", + "ruff==0.9.5", + "pytest==7.2.0", + "bandit[toml]==1.7.4", + "isort==5.11.4", + "pydocstyle==6.1.1" +] + +[tool.setuptools] +packages = ["datacosmos"] + +[tool.bandit] +exclude_dirs = [".venv"] + +[tool.pydocstyle] +convention = "google" + +[tool.isort] +profile = "black" +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +line_length = 88 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..56d7a9b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests of the DataCosmos SDK.""" diff --git a/tests/test_pass.py b/tests/test_pass.py new file mode 100644 index 0000000..228fe40 --- /dev/null +++ b/tests/test_pass.py @@ -0,0 +1,9 @@ +"""Test suite for basic functionality and CI setup.""" + + +class TestPass: + """A simple test class to validate the CI pipeline setup.""" + + def test_pass(self): + """A passing test to ensure the CI pipeline is functional.""" + assert True diff --git a/tests/unit/datacosmos/datacosmos_client/test_client_authentication.py b/tests/unit/datacosmos/datacosmos_client/test_client_authentication.py new file mode 100644 index 0000000..69ea619 --- /dev/null +++ b/tests/unit/datacosmos/datacosmos_client/test_client_authentication.py @@ -0,0 +1,104 @@ +import os +from unittest.mock import patch + +import pytest +import yaml + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.datacosmos_client import DatacosmosClient + + +@pytest.mark.usefixtures("mock_fetch_token", "mock_auth_client") +class TestClientAuthentication: + """Test suite for DatacosmosClient authentication.""" + + @pytest.fixture + def mock_fetch_token(self): + """Fixture to mock OAuth2 token fetch.""" + with patch("datacosmos.datacosmos_client.OAuth2Session.fetch_token") as mock: + mock.return_value = { + "access_token": "mock-access-token", + "expires_in": 3600, + } + yield mock + + @pytest.fixture + def mock_auth_client(self, mock_fetch_token): + """Fixture to mock the authentication client initialization.""" + with patch( + "datacosmos.datacosmos_client.DatacosmosClient._authenticate_and_initialize_client", + autospec=True, + ) as mock: + + def mock_authenticate(self): + """Simulate authentication by setting token values.""" + token_response = mock_fetch_token.return_value + self.token = token_response["access_token"] + self.token_expiry = "mock-expiry" + + mock.side_effect = mock_authenticate + yield mock + + def test_authentication_with_explicit_config(self): + """Test authentication when explicitly providing Config.""" + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + + assert client.token == "mock-access-token" + assert client.token_expiry == "mock-expiry" + + @patch("config.config.Config.from_yaml") + def test_authentication_from_yaml(self, mock_from_yaml, tmp_path): + """Test authentication when loading Config from YAML file.""" + config_path = tmp_path / "config.yaml" + yaml_data = { + "authentication": { + "type": "m2m", + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "token_url": "https://mock.token.url/oauth/token", + "audience": "https://mock.audience", + } + } + + with open(config_path, "w") as f: + yaml.dump(yaml_data, f) + + mock_from_yaml.return_value = Config.from_yaml(str(config_path)) + + # Clear any previous calls before instantiating the client + mock_from_yaml.reset_mock() + + client = DatacosmosClient() + + assert client.token == "mock-access-token" + assert client.token_expiry == "mock-expiry" + + # Ensure it was called exactly once after reset + mock_from_yaml.assert_called_once() + + @patch.dict( + os.environ, + { + "OC_AUTH_CLIENT_ID": "test-client-id", + "OC_AUTH_TOKEN_URL": "https://mock.token.url/oauth/token", + "OC_AUTH_AUDIENCE": "https://mock.audience", + "OC_AUTH_CLIENT_SECRET": "test-client-secret", + }, + ) + def test_authentication_from_env(self): + """Test authentication when loading Config from environment variables.""" + client = DatacosmosClient() + + assert client.token == "mock-access-token" + assert client.token_expiry == "mock-expiry" diff --git a/tests/unit/datacosmos/datacosmos_client/test_client_delete_request.py b/tests/unit/datacosmos/datacosmos_client/test_client_delete_request.py new file mode 100644 index 0000000..8d61786 --- /dev/null +++ b/tests/unit/datacosmos/datacosmos_client/test_client_delete_request.py @@ -0,0 +1,38 @@ +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.datacosmos_client import DatacosmosClient + + +@patch( + "datacosmos.datacosmos_client.DatacosmosClient._authenticate_and_initialize_client" +) +def test_delete_request(mock_auth_client): + """Test that the client performs a DELETE request correctly.""" + # Mock the HTTP client + mock_http_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 204 + mock_http_client.request.return_value = mock_response + mock_auth_client.return_value = mock_http_client + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + response = client.delete("https://mock.api/some-endpoint") + + # Assertions + assert response.status_code == 204 + mock_http_client.request.assert_called_once_with( + "DELETE", "https://mock.api/some-endpoint" + ) + mock_auth_client.call_count == 2 diff --git a/tests/unit/datacosmos/datacosmos_client/test_client_get_request.py b/tests/unit/datacosmos/datacosmos_client/test_client_get_request.py new file mode 100644 index 0000000..50e22f9 --- /dev/null +++ b/tests/unit/datacosmos/datacosmos_client/test_client_get_request.py @@ -0,0 +1,40 @@ +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.datacosmos_client import DatacosmosClient + + +@patch( + "datacosmos.datacosmos_client.DatacosmosClient._authenticate_and_initialize_client" +) +def test_client_get_request(mock_auth_client): + """Test that the client performs a GET request correctly.""" + # Mock the HTTP client + mock_http_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"message": "success"} + mock_http_client.request.return_value = mock_response + mock_auth_client.return_value = mock_http_client + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + response = client.get("https://mock.api/some-endpoint") + + # Assertions + assert response.status_code == 200 + assert response.json() == {"message": "success"} + mock_http_client.request.assert_called_once_with( + "GET", "https://mock.api/some-endpoint" + ) + mock_auth_client.call_count == 2 diff --git a/tests/unit/datacosmos/datacosmos_client/test_client_patch_request.py b/tests/unit/datacosmos/datacosmos_client/test_client_patch_request.py new file mode 100644 index 0000000..3a0d528 --- /dev/null +++ b/tests/unit/datacosmos/datacosmos_client/test_client_patch_request.py @@ -0,0 +1,42 @@ +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.datacosmos_client import DatacosmosClient + + +@patch( + "datacosmos.datacosmos_client.DatacosmosClient._authenticate_and_initialize_client" +) +def test_patch_request(mock_auth_client): + """Test that the client performs a PATCH request correctly.""" + # Mock the HTTP client + mock_http_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"message": "updated"} + mock_http_client.request.return_value = mock_response + mock_auth_client.return_value = mock_http_client + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + response = client.patch( + "https://mock.api/some-endpoint", json={"key": "updated-value"} + ) + + # Assertions + assert response.status_code == 200 + assert response.json() == {"message": "updated"} + mock_http_client.request.assert_called_once_with( + "PATCH", "https://mock.api/some-endpoint", json={"key": "updated-value"} + ) + mock_auth_client.call_count == 2 diff --git a/tests/unit/datacosmos/datacosmos_client/test_client_post_request.py b/tests/unit/datacosmos/datacosmos_client/test_client_post_request.py new file mode 100644 index 0000000..3235eb1 --- /dev/null +++ b/tests/unit/datacosmos/datacosmos_client/test_client_post_request.py @@ -0,0 +1,40 @@ +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.datacosmos_client import DatacosmosClient + + +@patch( + "datacosmos.datacosmos_client.DatacosmosClient._authenticate_and_initialize_client" +) +def test_post_request(mock_auth_client): + """Test that the client performs a POST request correctly.""" + # Mock the HTTP client + mock_http_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = {"message": "created"} + mock_http_client.request.return_value = mock_response + mock_auth_client.return_value = mock_http_client + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + response = client.post("https://mock.api/some-endpoint", json={"key": "value"}) + + # Assertions + assert response.status_code == 201 + assert response.json() == {"message": "created"} + mock_http_client.request.assert_called_once_with( + "POST", "https://mock.api/some-endpoint", json={"key": "value"} + ) + mock_auth_client.call_count == 2 diff --git a/tests/unit/datacosmos/datacosmos_client/test_client_put_request.py b/tests/unit/datacosmos/datacosmos_client/test_client_put_request.py new file mode 100644 index 0000000..09e0335 --- /dev/null +++ b/tests/unit/datacosmos/datacosmos_client/test_client_put_request.py @@ -0,0 +1,42 @@ +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.datacosmos_client import DatacosmosClient + + +@patch( + "datacosmos.datacosmos_client.DatacosmosClient._authenticate_and_initialize_client" +) +def test_put_request(mock_auth_client): + """Test that the client performs a PUT request correctly.""" + # Mock the HTTP client + mock_http_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"message": "updated"} + mock_http_client.request.return_value = mock_response + mock_auth_client.return_value = mock_http_client + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + response = client.put( + "https://mock.api/some-endpoint", json={"key": "updated-value"} + ) + + # Assertions + assert response.status_code == 200 + assert response.json() == {"message": "updated"} + mock_http_client.request.assert_called_once_with( + "PUT", "https://mock.api/some-endpoint", json={"key": "updated-value"} + ) + mock_auth_client.call_count == 2 diff --git a/tests/unit/datacosmos/datacosmos_client/test_client_token_refreshing.py b/tests/unit/datacosmos/datacosmos_client/test_client_token_refreshing.py new file mode 100644 index 0000000..0585ff7 --- /dev/null +++ b/tests/unit/datacosmos/datacosmos_client/test_client_token_refreshing.py @@ -0,0 +1,53 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.datacosmos_client import DatacosmosClient + + +@patch( + "datacosmos.datacosmos_client.DatacosmosClient._authenticate_and_initialize_client" +) +def test_client_token_refreshing(mock_auth_client): + """Test that the client refreshes the token when it expires.""" + # Mock the HTTP client returned by _authenticate_and_initialize_client + mock_http_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"message": "success"} + mock_http_client.request.return_value = mock_response + mock_auth_client.return_value = mock_http_client + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + # Initialize the client (first call to _authenticate_and_initialize_client) + client = DatacosmosClient(config=config) + + # Simulate expired token + client.token_expiry = datetime.now(timezone.utc) - timedelta(seconds=1) + + # Make a GET request (should trigger token refresh) + response = client.get("https://mock.api/some-endpoint") + + # Assertions + assert response.status_code == 200 + assert response.json() == {"message": "success"} + + # Verify _authenticate_and_initialize_client was called twice: + # 1. During initialization + # 2. During token refresh + assert mock_auth_client.call_count == 2 + + # Verify the request was made correctly + mock_http_client.request.assert_called_once_with( + "GET", "https://mock.api/some-endpoint" + ) diff --git a/tests/unit/datacosmos/stac/collection/collection_client/test_create_collection.py b/tests/unit/datacosmos/stac/collection/collection_client/test_create_collection.py new file mode 100644 index 0000000..fb7caae --- /dev/null +++ b/tests/unit/datacosmos/stac/collection/collection_client/test_create_collection.py @@ -0,0 +1,63 @@ +from unittest.mock import MagicMock, patch + +from pystac import Collection, Extent, SpatialExtent, TemporalExtent +from pystac.utils import str_to_datetime + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.datacosmos_client import DatacosmosClient +from datacosmos.stac.collection.collection_client import CollectionClient + + +@patch("requests_oauthlib.OAuth2Session.fetch_token") +@patch.object(DatacosmosClient, "post") +@patch("datacosmos.stac.collection.collection_client.check_api_response") +def test_create_collection(mock_check_api_response, mock_post, mock_fetch_token): + """Test creating a STAC collection.""" + + mock_fetch_token.return_value = {"access_token": "mock-token", "expires_in": 3600} + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_post.return_value = mock_response + mock_check_api_response.return_value = None + + collection = Collection( + id="test-collection", + description="A test STAC collection", + extent=Extent( + SpatialExtent([[-180.0, -90.0, 180.0, 90.0]]), + TemporalExtent( + [ + [ + str_to_datetime("2020-01-01T00:00:00Z"), + str_to_datetime("2023-12-31T23:59:59Z"), + ] + ] + ), + ), + license="proprietary", + stac_extensions=[], + ) + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + collection_client = CollectionClient(client) + + collection_client.create_collection(collection) + + mock_post.assert_called_once_with( + client.config.stac.as_domain_url().with_suffix("/collections"), + json=collection.to_dict(), + ) + + mock_check_api_response.assert_called_once_with(mock_response) diff --git a/tests/unit/datacosmos/stac/collection/collection_client/test_delete_collection.py b/tests/unit/datacosmos/stac/collection/collection_client/test_delete_collection.py new file mode 100644 index 0000000..9f7c93e --- /dev/null +++ b/tests/unit/datacosmos/stac/collection/collection_client/test_delete_collection.py @@ -0,0 +1,44 @@ +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.datacosmos_client import DatacosmosClient +from datacosmos.stac.collection.collection_client import CollectionClient + + +@patch("requests_oauthlib.OAuth2Session.fetch_token") +@patch.object(DatacosmosClient, "delete") +@patch("datacosmos.stac.collection.collection_client.check_api_response") +def test_delete_collection(mock_check_api_response, mock_delete, mock_fetch_token): + """Test deleting a STAC collection.""" + + mock_fetch_token.return_value = {"access_token": "mock-token", "expires_in": 3600} + + mock_response = MagicMock() + mock_response.status_code = 204 + mock_delete.return_value = mock_response + mock_check_api_response.return_value = None + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + collection_client = CollectionClient(client) + + collection_client.delete_collection("test-collection") + + mock_delete.assert_called_once_with( + client.config.stac.as_domain_url().with_suffix("/collections/test-collection") + ) + + mock_check_api_response.assert_called_once_with(mock_response) + mock_delete.assert_called_with( + collection_client.base_url.with_suffix("/collections/test-collection") + ) diff --git a/tests/unit/datacosmos/stac/collection/collection_client/test_fetch_collection.py b/tests/unit/datacosmos/stac/collection/collection_client/test_fetch_collection.py new file mode 100644 index 0000000..3daa667 --- /dev/null +++ b/tests/unit/datacosmos/stac/collection/collection_client/test_fetch_collection.py @@ -0,0 +1,73 @@ +from unittest.mock import MagicMock, patch + +from pystac import Collection +from pystac.utils import datetime_to_str + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.datacosmos_client import DatacosmosClient +from datacosmos.stac.collection.collection_client import CollectionClient + + +@patch("requests_oauthlib.OAuth2Session.fetch_token") +@patch.object(DatacosmosClient, "get") +@patch("datacosmos.stac.collection.collection_client.check_api_response") +def test_fetch_collection(mock_check_api_response, mock_get, mock_fetch_token): + """Test fetching a single STAC collection by ID.""" + + mock_fetch_token.return_value = {"access_token": "mock-token", "expires_in": 3600} + + mock_response = MagicMock() + mock_response.json.return_value = { + "type": "Collection", + "id": "test-collection", + "stac_version": "1.1.0", + "description": "A test STAC collection", + "license": "proprietary", + "extent": { + "spatial": {"bbox": [[-180.0, -90.0, 180.0, 90.0]]}, + "temporal": { + "interval": [["2020-01-01T00:00:00Z", "2023-12-31T23:59:59Z"]] + }, + }, + "links": [], + "stac_extensions": [], + } + mock_response.status_code = 200 + mock_get.return_value = mock_response + mock_check_api_response.return_value = None + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + collection_client = CollectionClient(client) + + collection = collection_client.fetch_collection("test-collection") + + assert isinstance(collection, Collection) + assert collection.id == "test-collection" + assert collection.description == "A test STAC collection" + assert collection.license == "proprietary" + assert collection.extent.spatial.bboxes == [[-180.0, -90.0, 180.0, 90.0]] + + actual_temporal_intervals = [ + [datetime_to_str(interval[0]), datetime_to_str(interval[1])] + for interval in collection.extent.temporal.intervals + ] + expected_temporal_intervals = [["2020-01-01T00:00:00Z", "2023-12-31T23:59:59Z"]] + + assert actual_temporal_intervals == expected_temporal_intervals + + mock_get.assert_called_once() + mock_check_api_response.assert_called_once_with(mock_response) + mock_get.assert_called_with( + collection_client.base_url.with_suffix("/collections/test-collection") + ) diff --git a/tests/unit/datacosmos/stac/collection/collection_client/test_update_collection.py b/tests/unit/datacosmos/stac/collection/collection_client/test_update_collection.py new file mode 100644 index 0000000..3994620 --- /dev/null +++ b/tests/unit/datacosmos/stac/collection/collection_client/test_update_collection.py @@ -0,0 +1,54 @@ +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.datacosmos_client import DatacosmosClient +from datacosmos.stac.collection.collection_client import CollectionClient +from datacosmos.stac.collection.models.collection_update import CollectionUpdate + + +@patch("requests_oauthlib.OAuth2Session.fetch_token") +@patch.object(DatacosmosClient, "patch") +@patch("datacosmos.stac.collection.collection_client.check_api_response") +def test_update_collection(mock_check_api_response, mock_patch, mock_fetch_token): + """Test updating a STAC collection.""" + + mock_fetch_token.return_value = {"access_token": "mock-token", "expires_in": 3600} + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_patch.return_value = mock_response + mock_check_api_response.return_value = None + + update_data = CollectionUpdate( + title="Updated Collection Title", + description="Updated description", + keywords=["updated", "collection"], + license="proprietary", + ) + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + collection_client = CollectionClient(client) + + collection_client.update_collection("test-collection", update_data) + + mock_patch.assert_called_once_with( + client.config.stac.as_domain_url().with_suffix("/collections/test-collection"), + json=update_data.model_dump(by_alias=True, exclude_none=True), + ) + + mock_check_api_response.assert_called_once_with(mock_response) + mock_patch.assert_called_with( + collection_client.base_url.with_suffix("/collections/test-collection"), + json=update_data.model_dump(by_alias=True, exclude_none=True), + ) diff --git a/tests/unit/datacosmos/stac/item/item_client/test_create_item.py b/tests/unit/datacosmos/stac/item/item_client/test_create_item.py new file mode 100644 index 0000000..c90992e --- /dev/null +++ b/tests/unit/datacosmos/stac/item/item_client/test_create_item.py @@ -0,0 +1,54 @@ +from unittest.mock import MagicMock, patch + +from pystac import Item + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.datacosmos_client import DatacosmosClient +from datacosmos.stac.item.item_client import ItemClient + + +@patch("requests_oauthlib.OAuth2Session.fetch_token") +@patch.object(DatacosmosClient, "post") +@patch("datacosmos.stac.item.item_client.check_api_response") +def test_create_item(mock_check_api_response, mock_post, mock_fetch_token): + """Test creating a new STAC item.""" + mock_fetch_token.return_value = {"access_token": "mock-token", "expires_in": 3600} + + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "item-1", + "collection": "test-collection", + "type": "Feature", + "stac_version": "1.0.0", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": {"datetime": "2023-12-01T12:00:00Z"}, + "assets": {}, + "links": [], + } + mock_post.return_value = mock_response + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + client = DatacosmosClient(config=config) + stac_client = ItemClient(client) + + item = Item.from_dict(mock_response.json()) + + stac_client.create_item("test-collection", item) + + mock_post.assert_called_once() + + mock_check_api_response.assert_called_once() + + mock_post.assert_called_with( + stac_client.base_url.with_suffix("/collections/test-collection/items"), + json=item.to_dict(), + ) diff --git a/tests/unit/datacosmos/stac/item/item_client/test_delete_item.py b/tests/unit/datacosmos/stac/item/item_client/test_delete_item.py new file mode 100644 index 0000000..d75240a --- /dev/null +++ b/tests/unit/datacosmos/stac/item/item_client/test_delete_item.py @@ -0,0 +1,36 @@ +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.datacosmos_client import DatacosmosClient +from datacosmos.stac.item.item_client import ItemClient + + +@patch("requests_oauthlib.OAuth2Session.fetch_token") +@patch.object(DatacosmosClient, "delete") +@patch("datacosmos.stac.item.item_client.check_api_response") +def test_delete_item(mock_check_api_response, mock_delete, mock_fetch_token): + """Test deleting a STAC item.""" + mock_fetch_token.return_value = {"access_token": "mock-token", "expires_in": 3600} + + mock_response = MagicMock() + mock_response.status_code = 204 # Successful deletion + mock_delete.return_value = mock_response + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + stac_client = ItemClient(client) + + stac_client.delete_item("item-1", "test-collection") + + mock_delete.assert_called_once() + mock_check_api_response.assert_called_once() diff --git a/tests/unit/datacosmos/stac/item/item_client/test_fetch_collection_items.py b/tests/unit/datacosmos/stac/item/item_client/test_fetch_collection_items.py new file mode 100644 index 0000000..b81f43b --- /dev/null +++ b/tests/unit/datacosmos/stac/item/item_client/test_fetch_collection_items.py @@ -0,0 +1,45 @@ +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.datacosmos_client import DatacosmosClient +from datacosmos.stac.item.item_client import ItemClient + + +@patch("requests_oauthlib.OAuth2Session.fetch_token") +@patch("datacosmos.stac.item.item_client.check_api_response") +@patch.object(ItemClient, "search_items") +def test_fetch_collection_items( + mock_search_items, mock_check_api_response, mock_fetch_token +): + """Test fetching all items in a collection.""" + mock_fetch_token.return_value = {"access_token": "mock-token", "expires_in": 3600} + + mock_response_1 = MagicMock(id="item-1") + mock_response_2 = MagicMock(id="item-2") + + mock_search_items.return_value = iter([mock_response_1, mock_response_2]) + + mock_check_api_response.return_value = None + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + stac_client = ItemClient(client) + + results = list(stac_client.fetch_collection_items("test-collection")) + + assert len(results) == 2 + assert results[0].id == "item-1" + assert results[1].id == "item-2" + + mock_search_items.assert_called_once() + mock_check_api_response.assert_not_called() diff --git a/tests/unit/datacosmos/stac/item/item_client/test_fetch_item.py b/tests/unit/datacosmos/stac/item/item_client/test_fetch_item.py new file mode 100644 index 0000000..fa9edeb --- /dev/null +++ b/tests/unit/datacosmos/stac/item/item_client/test_fetch_item.py @@ -0,0 +1,50 @@ +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.datacosmos_client import DatacosmosClient +from datacosmos.stac.item.item_client import ItemClient + + +@patch("requests_oauthlib.OAuth2Session.fetch_token") +@patch("datacosmos.stac.item.item_client.check_api_response") +@patch.object(DatacosmosClient, "get") +def test_fetch_item(mock_get, mock_check_api_response, mock_fetch_token): + """Test fetching a single STAC item by ID.""" + mock_fetch_token.return_value = {"access_token": "mock-token", "expires_in": 3600} + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "item-1", + "collection": "test-collection", + "type": "Feature", + "stac_version": "1.0.0", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": {"datetime": "2023-12-01T12:00:00Z"}, + "assets": {}, + "links": [], + } + mock_get.return_value = mock_response + + mock_check_api_response.return_value = None + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + stac_client = ItemClient(client) + + item = stac_client.fetch_item("item-1", "test-collection") + + assert item.id == "item-1" + assert item.properties["datetime"] == "2023-12-01T12:00:00Z" + mock_get.assert_called_once() + mock_check_api_response.assert_called_once_with(mock_response) diff --git a/tests/unit/datacosmos/stac/item/item_client/test_search_items.py b/tests/unit/datacosmos/stac/item/item_client/test_search_items.py new file mode 100644 index 0000000..15b16c9 --- /dev/null +++ b/tests/unit/datacosmos/stac/item/item_client/test_search_items.py @@ -0,0 +1,59 @@ +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.datacosmos_client import DatacosmosClient +from datacosmos.stac.item.item_client import ItemClient +from datacosmos.stac.item.models.search_parameters import SearchParameters + + +@patch("requests_oauthlib.OAuth2Session.fetch_token") +@patch("datacosmos.stac.item.item_client.check_api_response") +@patch.object(DatacosmosClient, "post") +def test_search_items(mock_post, mock_check_api_response, mock_fetch_token): + """Test searching STAC items with filters and pagination.""" + mock_fetch_token.return_value = {"access_token": "mock-token", "expires_in": 3600} + + mock_response = MagicMock() + mock_response.status_code = 200 # Ensure the mock behaves like a real response + mock_response.json.return_value = { + "features": [ + { + "id": "item-1", + "collection": "test-collection", + "type": "Feature", + "stac_version": "1.0.0", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": {"datetime": "2023-12-01T12:00:00Z"}, + "assets": {}, + "links": [], + } + ], + "links": [], + } + mock_post.return_value = mock_response + + mock_check_api_response.return_value = None + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + + client = DatacosmosClient(config=config) + stac_client = ItemClient(client) + parameters = SearchParameters(collections=["test-collection"]) + + results = list(stac_client.search_items(parameters)) + + assert len(results) == 1 + assert results[0].id == "item-1" + mock_post.assert_called_once() + mock_check_api_response.assert_called_once_with( + mock_response + ) # Ensure the API check was called diff --git a/tests/unit/datacosmos/stac/item/item_client/test_update_item.py b/tests/unit/datacosmos/stac/item/item_client/test_update_item.py new file mode 100644 index 0000000..e845183 --- /dev/null +++ b/tests/unit/datacosmos/stac/item/item_client/test_update_item.py @@ -0,0 +1,55 @@ +from unittest.mock import MagicMock, patch + +from config.config import Config +from config.models.m2m_authentication_config import M2MAuthenticationConfig +from datacosmos.datacosmos_client import DatacosmosClient +from datacosmos.stac.item.item_client import ItemClient +from datacosmos.stac.item.models.item_update import ItemUpdate + + +@patch("requests_oauthlib.OAuth2Session.fetch_token") +@patch.object(DatacosmosClient, "patch") +@patch("datacosmos.stac.item.item_client.check_api_response") +def test_update_item(mock_check_api_response, mock_patch, mock_fetch_token): + """Test updating an existing STAC item.""" + mock_fetch_token.return_value = {"access_token": "mock-token", "expires_in": 3600} + + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "item-1", + "collection": "test-collection", + "type": "Feature", + "stac_version": "1.0.0", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "properties": {"datetime": "2023-12-01T12:00:00Z", "new_property": "value"}, + "assets": {}, + "links": [], + } + mock_patch.return_value = mock_response + + config = Config( + authentication=M2MAuthenticationConfig( + type="m2m", + client_id="test-client-id", + client_secret="test-client-secret", + token_url="https://mock.token.url/oauth/token", + audience="https://mock.audience", + ) + ) + client = DatacosmosClient(config=config) + stac_client = ItemClient(client) + + update_data = ItemUpdate( + properties={"new_property": "value", "datetime": "2023-12-01T12:00:00Z"} + ) + + stac_client.update_item("item-1", "test-collection", update_data) + + mock_patch.assert_called_once() + + mock_check_api_response.assert_called_once() + + mock_patch.assert_called_with( + stac_client.base_url.with_suffix("/collections/test-collection/items/item-1"), + json=update_data.model_dump(by_alias=True, exclude_none=True), + ) diff --git a/tests/unit/datacosmos/stac/item/models/test_item_update.py b/tests/unit/datacosmos/stac/item/models/test_item_update.py new file mode 100644 index 0000000..6338ea7 --- /dev/null +++ b/tests/unit/datacosmos/stac/item/models/test_item_update.py @@ -0,0 +1,42 @@ +import pytest + +from datacosmos.stac.item.models.item_update import ItemUpdate + + +class TestItemUpdate: + """Unit tests for the ItemUpdate model validation.""" + + def test_valid_item_update_with_datetime(self): + """Test that ItemUpdate passes validation with a single datetime field.""" + update_data = ItemUpdate( + properties={"new_property": "value", "datetime": "2023-12-01T12:00:00Z"} + ) + assert update_data.properties["datetime"] == "2023-12-01T12:00:00Z" + + def test_valid_item_update_with_start_and_end_datetime(self): + """Test that ItemUpdate passes validation with start_datetime and end_datetime.""" + update_data = ItemUpdate( + properties={ + "new_property": "value", + "start_datetime": "2023-12-01T12:00:00Z", + "end_datetime": "2023-12-01T12:30:00Z", + } + ) + assert update_data.properties["start_datetime"] == "2023-12-01T12:00:00Z" + assert update_data.properties["end_datetime"] == "2023-12-01T12:30:00Z" + + def test_invalid_item_update_missing_datetime(self): + """Test that ItemUpdate fails validation when datetime is missing.""" + with pytest.raises( + ValueError, + match="Either 'datetime' or both 'start_datetime' and 'end_datetime' must be provided.", + ): + ItemUpdate(properties={"new_property": "value"}) + + def test_invalid_item_update_empty_properties(self): + """Test that ItemUpdate fails validation when properties are empty.""" + with pytest.raises( + ValueError, + match="Either 'datetime' or both 'start_datetime' and 'end_datetime' must be provided.", + ): + ItemUpdate(properties={}) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..34c641c --- /dev/null +++ b/uv.lock @@ -0,0 +1,42 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile -o uv.lock pyproject.toml +annotated-types==0.7.0 + # via pydantic +certifi==2025.1.31 + # via requests +charset-normalizer==3.4.1 + # via requests +idna==3.10 + # via requests +oauthlib==3.2.0 + # via + # datacosmos (pyproject.toml) + # requests-oauthlib +pydantic==2.10.6 + # via + # datacosmos (pyproject.toml) + # pydantic-settings +pydantic-core==2.27.2 + # via pydantic +pydantic-settings==2.7.1 + # via datacosmos (pyproject.toml) +pystac==1.12.1 + # via datacosmos (pyproject.toml) +python-dateutil==2.9.0.post0 + # via pystac +python-dotenv==1.0.1 + # via pydantic-settings +requests==2.31.0 + # via + # datacosmos (pyproject.toml) + # requests-oauthlib +requests-oauthlib==1.3.1 + # via datacosmos (pyproject.toml) +six==1.17.0 + # via python-dateutil +typing-extensions==4.12.2 + # via + # pydantic + # pydantic-core +urllib3==2.3.0 + # via requests