diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4a210d46633be4608625268995876ea1e50a1bed..de9b93d7a3dfb0040176f40c591df112bb2ec38f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,21 +1,2 @@ include: - - project: "cdos-pub/pycode-quality" - ref: "main" - file: - - ".gitlab/ci/static-analysis.yml" - - ".gitlab/ci/pip.yml" - -stages: - - Static Analysis - - Test - - Pip - -Tests: - image: "registry.forgemia.inra.fr/cdos-pub/pycode-quality/python-venv:3.10" - stage: Test - except: [main, tags] - script: - - pip install pip --upgrade - - pip install .[test] - - python3 tests/extensions_test.py - + - ".gitlab/ci/base.yml" diff --git a/.gitlab/ci/base.yml b/.gitlab/ci/base.yml new file mode 100644 index 0000000000000000000000000000000000000000..bff04505e8bbc1e91cf35f5b09f0c9b2ed5350b4 --- /dev/null +++ b/.gitlab/ci/base.yml @@ -0,0 +1,23 @@ +include: + - project: "cdos-pub/pycode-quality" + ref: "main" + file: + - ".gitlab/ci/static-analysis.yml" + - ".gitlab/ci/pip.yml" + +variables: + PIP_EXTRA_INDEX_URL: https://forgemia.inra.fr/api/v4/projects/10919/packages/pypi/simple + PACKAGE_INSTALL_EXTRAS: "[test]" + +stages: + - Static Analysis + - Test + - Pip + +Tests: + extends: .static_analysis_with_pip_install + stage: Test + allow_failure: false + script: + - pytest tests/ + diff --git a/LICENSE b/LICENSE index a6b5cd48345ea32d498426b024f650df9d807fff..6c258c1acae69c5bb3e39f443b166f51bdd3c05a 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2024 INRAE + Copyright 2024-2025 INRAE Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 71f0b5795d11d4e5d1f6867fc6a786a53ad95635..017b99cd60e5c0d7ad67779df93e8dbf4d6c689d 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,13 @@ This module is a helper to build STAC extensions carrying metadata defined with pydantic models. +## Installation + +``` +PIP_EXTRA_INDEX_URL=https://forgemia.inra.fr/api/v4/projects/10919/packages/pypi/simple +pip install stac-extension-genmeta +``` + ## Example Simple example in 4 steps. diff --git a/pyproject.toml b/pyproject.toml index 105cdbdc163d0d595be3cb493328eb1bb4c92bfe..fa832e397d2636b37976454d237246d357b23e90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ description = "Helper to build custom STAC extensions based on pydantic models" authors = [ { name = "Rémi Cresson", email = "remi.cresson@inrae.fr" }, ] -requires-python = ">=3.7" +requires-python = ">=3.8" dependencies = [ "pydantic >= 2.0.0", "pystac", @@ -23,14 +23,11 @@ classifiers = [ "Operating System :: OS Independent", ] -[project.scripts] -genmeta_cli = "stac_extension_genmeta.cli:app" - [tool.setuptools] packages = ["stac_extension_genmeta"] [project.optional-dependencies] -test = ["requests", "pystac[validation]"] +test = ["requests", "pystac[validation]", "pytest"] [tool.setuptools.dynamic] version = { attr = "stac_extension_genmeta.__version__" } diff --git a/stac_extension_genmeta/__init__.py b/stac_extension_genmeta/__init__.py index 55989ae2477f21ad87fd52cc857dcf7b75aa550d..9c021c3a4624d28fef4e38c76210996a27329395 100644 --- a/stac_extension_genmeta/__init__.py +++ b/stac_extension_genmeta/__init__.py @@ -2,4 +2,4 @@ from .core import create_extension_cls, BaseExtensionModel # noqa -__version__ = "0.1.3-dev1" +__version__ = "0.1.3-dev3" diff --git a/stac_extension_genmeta/core.py b/stac_extension_genmeta/core.py index 15ace43924468bdb9426a8048f33809a22810aee..2f53023a8970f6485b9453e8b0cc302d16001476 100644 --- a/stac_extension_genmeta/core.py +++ b/stac_extension_genmeta/core.py @@ -1,15 +1,13 @@ -""" -Processing extension -""" +"""Generic metadata creation.""" +from collections.abc import Iterable +import json +import re from typing import Any, Generic, TypeVar, Union, cast from pystac.extensions.base import PropertiesExtension, ExtensionManagementMixin import pystac from pydantic import BaseModel, ConfigDict -import re -from collections.abc import Iterable from .schema import generate_schema -import json class BaseExtensionModel(BaseModel): @@ -19,19 +17,7 @@ class BaseExtensionModel(BaseModel): def create_extension_cls(model_cls: BaseModel, schema_uri: str) -> PropertiesExtension: - """ - This method creates a pystac extension from a pydantic model. - - Args: - model_cls: pydantic model class - schema_uri: schema URI - - Returns: - pystac extension class - - """ - - # check URI + """This method creates a pystac extension from a pydantic model.""" if not re.findall(r"(?:(\/v\d\.(?:\d+\.)*\d+\/+))", schema_uri): raise ValueError( "The schema_uri must contain the version in the form 'vX.Y.Z'" @@ -45,7 +31,10 @@ def create_extension_cls(model_cls: BaseModel, schema_uri: str) -> PropertiesExt PropertiesExtension, ExtensionManagementMixin[Union[pystac.Item, pystac.Collection]], ): + """Custom extension class.""" + def __init__(self, obj: T): + """Initializer.""" if isinstance(obj, pystac.Item): self.properties = obj.properties elif isinstance(obj, (pystac.Asset, pystac.Collection)): @@ -65,10 +54,11 @@ def create_extension_cls(model_cls: BaseModel, schema_uri: str) -> PropertiesExt self.md = model_cls(**props) if props else None def __getattr__(self, item): - # forward getattr to self.md + """Forward getattr to self.md.""" return getattr(self.md, item) if self.md else None def apply(self, md: model_cls = None, **kwargs) -> None: + """Apply the metadata.""" if md is None and not kwargs: raise ValueError("At least `md` or kwargs is required") @@ -86,10 +76,12 @@ def create_extension_cls(model_cls: BaseModel, schema_uri: str) -> PropertiesExt @classmethod def get_schema_uri(cls) -> str: + """Get schema URI.""" return schema_uri @classmethod def get_schema(cls) -> dict: + """Get schema as dict.""" return generate_schema( model_cls=model_cls, title=f"STAC extension from {model_cls.__name__} model", @@ -99,6 +91,7 @@ def create_extension_cls(model_cls: BaseModel, schema_uri: str) -> PropertiesExt @classmethod def print_schema(cls): + """Print schema.""" print( "\033[92mPlease copy/paste the schema below in the right place " f"in the repository so it can be accessed from \033[94m" @@ -107,18 +100,20 @@ def create_extension_cls(model_cls: BaseModel, schema_uri: str) -> PropertiesExt @classmethod def export_schema(cls, json_file): - with open(json_file, "w") as f: + """Export schema.""" + with open(json_file, "w", encoding="utf-8") as f: json.dump(cls.get_schema(), f, indent=2) @classmethod def ext(cls, obj: T, add_if_missing: bool = False) -> model_cls.__name__: + """Create the extension.""" if isinstance(obj, pystac.Item): cls.ensure_has_extension(obj, add_if_missing) return cast(CustomExtension[T], ItemCustomExtension(obj)) - elif isinstance(obj, pystac.Asset): + if isinstance(obj, pystac.Asset): cls.ensure_owner_has_extension(obj, add_if_missing) return cast(CustomExtension[T], AssetCustomExtension(obj)) - elif isinstance(obj, pystac.Collection): + if isinstance(obj, pystac.Collection): cls.ensure_has_extension(obj, add_if_missing) return cast(CustomExtension[T], CollectionCustomExtension(obj)) raise pystac.ExtensionTypeError( @@ -126,23 +121,29 @@ def create_extension_cls(model_cls: BaseModel, schema_uri: str) -> PropertiesExt ) class ItemCustomExtension(CustomExtension[pystac.Item]): - pass + """Item custom extension.""" class AssetCustomExtension(CustomExtension[pystac.Asset]): + """Asset custom extension.""" + asset_href: str properties: dict[str, Any] additional_read_properties: Iterable[dict[str, Any]] | None = None def __init__(self, asset: pystac.Asset): + """Initializer.""" self.asset_href = asset.href self.properties = asset.extra_fields if asset.owner and isinstance(asset.owner, pystac.Item): self.additional_read_properties = [asset.owner.properties] class CollectionCustomExtension(CustomExtension[pystac.Collection]): + """Collection curstom extension.""" + properties: dict[str, Any] def __init__(self, collection: pystac.Collection): + """Initializer.""" self.properties = collection.extra_fields CustomExtension.__name__ = f"CustomExtensionFrom{model_cls.__name__}" diff --git a/stac_extension_genmeta/schema.py b/stac_extension_genmeta/schema.py index ffe1cd7117fe7301b34fedb2f5ccc02b09c5392f..35d33f68d96089132edf3cde2b63334ee1479101 100644 --- a/stac_extension_genmeta/schema.py +++ b/stac_extension_genmeta/schema.py @@ -1,12 +1,12 @@ +"""Generate the json schema.""" + from pydantic import BaseModel def generate_schema( - model_cls: BaseModel, - title: str, - description: str, - schema_uri: str + model_cls: BaseModel, title: str, description: str, schema_uri: str ) -> dict: + """Generate the schema.""" properties = model_cls.model_json_schema() # prune "required" properties.pop("required", None) @@ -21,116 +21,66 @@ def generate_schema( "allOf": [ { "type": "object", - "required": [ - "type", - "properties", - "assets", - "links" - ], + "required": ["type", "properties", "assets", "links"], "properties": { - "type": { - "const": "Feature" - }, - "properties": { - "$ref": "#/definitions/fields" - }, - "assets": { - "$ref": "#/definitions/assets" - }, - "links": { - "$ref": "#/definitions/links" - } - } + "type": {"const": "Feature"}, + "properties": {"$ref": "#/definitions/fields"}, + "assets": {"$ref": "#/definitions/assets"}, + "links": {"$ref": "#/definitions/links"}, + }, }, - { - "$ref": "#/definitions/stac_extensions" - } - ] + {"$ref": "#/definitions/stac_extensions"}, + ], }, { "$comment": "This is the schema for STAC Collections.", "allOf": [ { "type": "object", - "required": [ - "type" - ], + "required": ["type"], "properties": { - "type": { - "const": "Collection" - }, - "assets": { - "$ref": "#/definitions/assets" - }, - "item_assets": { - "$ref": "#/definitions/assets" - }, - "links": { - "$ref": "#/definitions/links" - } - } + "type": {"const": "Collection"}, + "assets": {"$ref": "#/definitions/assets"}, + "item_assets": {"$ref": "#/definitions/assets"}, + "links": {"$ref": "#/definitions/links"}, + }, }, - { - "$ref": "#/definitions/fields" - }, - { - "$ref": "#/definitions/stac_extensions" - } - ] + {"$ref": "#/definitions/fields"}, + {"$ref": "#/definitions/stac_extensions"}, + ], }, { "$comment": "This is the schema for STAC Catalogs.", "allOf": [ { "type": "object", - "required": [ - "type" - ], + "required": ["type"], "properties": { - "type": { - "const": "Catalog" - }, - "links": { - "$ref": "#/definitions/links" - } - } + "type": {"const": "Catalog"}, + "links": {"$ref": "#/definitions/links"}, + }, }, - { - "$ref": "#/definitions/fields" - }, - { - "$ref": "#/definitions/stac_extensions" - } - ] - } + {"$ref": "#/definitions/fields"}, + {"$ref": "#/definitions/stac_extensions"}, + ], + }, ], "definitions": { "stac_extensions": { "type": "object", - "required": [ - "stac_extensions" - ], + "required": ["stac_extensions"], "properties": { "stac_extensions": { "type": "array", - "contains": { - "const": schema_uri - } + "contains": {"const": schema_uri}, } - } - }, - "links": { - "type": "array", - "items": { - "$ref": "#/definitions/fields" - } + }, }, + "links": {"type": "array", "items": {"$ref": "#/definitions/fields"}}, "assets": { "type": "object", - "additionalProperties": { - "$ref": "#/definitions/fields" - } + "additionalProperties": {"$ref": "#/definitions/fields"}, }, - "fields": properties - } + "fields": properties, + }, } diff --git a/stac_extension_genmeta/testing.py b/stac_extension_genmeta/testing.py index f134f7c78253c3797b413f172e654cdf23061557..319ae1614f9c4bf03e50697111fe6ffff573c70b 100644 --- a/stac_extension_genmeta/testing.py +++ b/stac_extension_genmeta/testing.py @@ -1,12 +1,16 @@ -import pystac -from datetime import datetime +"""Testing module.""" + +import os import random import json -import requests import difflib +from datetime import datetime +import requests +import pystac def create_dummy_item(date=None): + """Create dummy item.""" if not date: date = datetime.now().replace(year=1999) @@ -17,21 +21,19 @@ def create_dummy_item(date=None): geom = { "type": "Polygon", "coordinates": [ - [[4.032730583418401, 43.547450099338604], - [4.036414917971517, 43.75162726634343], - [3.698685718905037, 43.75431706444037], - [3.6962018175925073, 43.55012996681564], - [4.032730583418401, 43.547450099338604]] - ] + [ + [4.032730583418401, 43.547450099338604], + [4.036414917971517, 43.75162726634343], + [3.698685718905037, 43.75431706444037], + [3.6962018175925073, 43.55012996681564], + [4.032730583418401, 43.547450099338604], + ] + ], } - asset = pystac.Asset( - href="https://example.com/SP67_FR_subset_1.tif" - ) + asset = pystac.Asset(href="https://example.com/SP67_FR_subset_1.tif") val = f"item_{random.uniform(10000, 80000)}" spat_extent = pystac.SpatialExtent([[0, 0, 2, 3]]) - temp_extent = pystac.TemporalExtent( - intervals=[(None, None)] - ) + temp_extent = pystac.TemporalExtent(intervals=[(None, None)]) item = pystac.Item( id=val, @@ -41,7 +43,7 @@ def create_dummy_item(date=None): properties={}, assets={"ndvi": asset}, href="https://example.com/collections/collection-test3/items/{val}", - collection="collection-test3" + collection="collection-test3", ) col = pystac.Collection( @@ -59,23 +61,19 @@ METHODS = ["arg", "md", "dict"] def basic_test( - ext_md, - ext_cls, - item_test: bool = True, - asset_test: bool = True, - collection_test: bool = True, - validate: bool = True + ext_md, + ext_cls, + asset_test: bool = True, + collection_test: bool = True, + validate: bool = True, ): - print( - f"Extension metadata model: \n{ext_md.__class__.model_json_schema()}" - ) + """Perform the basic testing of the extension class.""" + print(f"Extension metadata model: \n{ext_md.__class__.model_json_schema()}") ext_cls.print_schema() def apply(stac_obj, method="arg"): - """ - Apply the extension to the item - """ + """Apply the extension to the item.""" print(f"Check extension applied to {stac_obj.__class__.__name__}") ext = ext_cls.ext(stac_obj, add_if_missing=True) if method == "arg": @@ -83,23 +81,16 @@ def basic_test( elif method == "md": ext.apply(md=ext_md) elif method == "dict": - d = { - name: getattr(ext_md, name) - for name in ext_md.model_fields - } + d = {name: getattr(ext_md, name) for name in ext_md.model_fields} print(f"Passing kwargs: {d}") ext.apply(**d) def print_item(item): - """ - Print item as JSON - """ + """Print item as JSON.""" print(json.dumps(item.to_dict(), indent=2)) def comp(stac_obj): - """ - Compare the metadata carried by the stac object with the expected metadata. - """ + """Compare the metadata carried by the stac object with the expected metadata.""" read_ext = ext_cls(stac_obj) for field in ext_md.__class__.model_fields: ref = getattr(ext_md, field) @@ -107,9 +98,7 @@ def basic_test( assert got == ref, f"'{field}': values differ: {got} (expected {ref})" def test_item(method): - """ - Test extension against item - """ + """Test extension against item.""" item, _ = create_dummy_item() apply(item, method) print_item(item) @@ -119,9 +108,7 @@ def basic_test( comp(item) def test_asset(method): - """ - Test extension against asset - """ + """Test extension against asset.""" item, _ = create_dummy_item() apply(item.assets["ndvi"], method) print_item(item) @@ -131,10 +118,8 @@ def basic_test( comp(item.assets["ndvi"]) def test_collection(method): - """ - Test extension against collection - """ - item, col = create_dummy_item() + """Test extension against collection.""" + _, col = create_dummy_item() print_item(col) apply(col, method) print_item(col) @@ -144,9 +129,8 @@ def basic_test( comp(col) for method in METHODS: - if item_test: - print(f"Test item with {method} args passing strategy") - test_item(method) + print(f"Test item with {method} args passing strategy") + test_item(method) if asset_test: print(f"Test asset with {method} args passing strategy") test_asset(method) @@ -155,10 +139,19 @@ def basic_test( test_collection(method) +CI_COMMIT_REF_NAME = os.environ.get("CI_COMMIT_REF_NAME") + + def is_schema_url_synced(cls): + """Check if the schema is in sync with the repository.""" local_schema = cls.get_schema() url = cls.get_schema_uri() - remote_schema = requests.get(url).json() + url = ( + url.replace("/-/raw/main/", f"/-/raw/{CI_COMMIT_REF_NAME}/") + if CI_COMMIT_REF_NAME + else url + ) + remote_schema = requests.get(url, timeout=10).json() print( f"Local schema is :\n" f"{local_schema}\n" @@ -168,14 +161,10 @@ def is_schema_url_synced(cls): ) if local_schema != remote_schema: print("Schema differs:") + def _json2str(dic): return json.dumps(dic, indent=2).split("\n") - diff = difflib.unified_diff( - _json2str(local_schema), - _json2str(remote_schema) - ) + diff = difflib.unified_diff(_json2str(local_schema), _json2str(remote_schema)) print("\n".join(diff)) - raise ValueError( - f"Please update the schema located in {url}" - ) + raise ValueError(f"Please update the schema located in {url}") diff --git a/tests/extensions_test.py b/tests/extensions_test.py index 85ec7019c7a29c8de763b55692336a0773c24d04..bbae3cb66f82800fbc50624ab0c0d3bfaa04d82e 100644 --- a/tests/extensions_test.py +++ b/tests/extensions_test.py @@ -1,39 +1,36 @@ -from stac_extension_genmeta import create_extension_cls +"""Tests example.""" + +from typing import List, Final +from pydantic import Field +from stac_extension_genmeta import create_extension_cls, BaseExtensionModel from stac_extension_genmeta.testing import basic_test -from pydantic import BaseModel, Field, ConfigDict -from typing import List # Extension parameters -SCHEMA_URI: str = "https://example.com/image-process/v1.0.0/schema.json" -PREFIX: str = "some_prefix" - - -# Extension metadata model -class MyExtensionMetadataModel(BaseModel): - # Required so that one model can be instantiated with the attribute name - # rather than the alias - model_config = ConfigDict(populate_by_name=True) - - # Metadata fields - name: str = Field(title="Process name", alias=f"{PREFIX}:name") - authors: List[str] = Field(title="Authors", alias=f"{PREFIX}:authors") - version: str = Field(title="Process version", alias=f"{PREFIX}:version") - opt_field: str | None = Field(title="Some optional field", alias=f"{PREFIX}:opt_field", default=None) - - -# Create the extension class -MyExtension = create_extension_cls( - model_cls=MyExtensionMetadataModel, - schema_uri=SCHEMA_URI -) - -# Metadata fields -ext_md = MyExtensionMetadataModel( - name="test", - authors=["michel", "denis"], - version="alpha" -) - -basic_test(ext_md, MyExtension, validate=False) - -MyExtension.print_schema() +SCHEMA_URI: Final = "https://example.com/image-process/v1.0.0/schema.json" +PREFIX: Final = "some_prefix:" +NAME: Final = PREFIX + "name" +AUTHORS: Final = PREFIX + "authors" +VERSION: Final = PREFIX + "version" +OPT_FIELD: Final = PREFIX + "opt_field" + + +class MyExtensionMetadataModel(BaseExtensionModel): + """Extension metadata model example.""" + + name: str = Field(title="Process name", alias=NAME) + authors: List[str] = Field(title="Authors", alias=AUTHORS) + version: str = Field(title="Process version", alias=VERSION) + opt_field: str | None = Field( + title="Some optional field", alias=OPT_FIELD, default=None + ) + + +def test_example(): + """Test example function.""" + MyExtension = create_extension_cls( + model_cls=MyExtensionMetadataModel, schema_uri=SCHEMA_URI + ) + ext_md = MyExtensionMetadataModel( + name="test", authors=["michel", "denis"], version="alpha" + ) + basic_test(ext_md, MyExtension, validate=False)