diff --git a/README.md b/README.md index b21a9211..50e7246d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Spatial Temporal Asset Catalog (STAC) Validator -This utility allows users to validate catalog and/or item json files against the [STAC](https://github.com/radiantearth/stac-spec) spec. +[![CircleCI](https://circleci.com/gh/sparkgeo/stac-validator.svg?style=svg)](https://circleci.com/gh/sparkgeo/stac-validator) + +This utility allows users to validate STAC json files against the [STAC](https://github.com/radiantearth/stac-spec) spec. It can be installed as command line utility and passed either a local file path or a url along with the STAC version to validate against. @@ -10,9 +12,6 @@ It can be installed as command line utility and passed either a local file path * Requests * Docopt * pytest - * cachetools - * trio - * asks ## Example @@ -34,7 +33,7 @@ Options: --threads NTHREADS Number of threads to use. [default: 10] --verbose Verbose output. [default: False] --timer Reports time to validate the STAC (seconds) - --loglevel LOGLEVEL Standard level of logging to report. [default: CRITICAL] + --log_level LOGLEVEL Standard level of logging to report. [default: CRITICAL] stac_validator https://cbers-stac.s3.amazonaws.com/CBERS4/MUX/057/122/catalog.json -v v0.5.2 ``` diff --git a/VERSION b/VERSION index 8a9ecc2e..341cf11f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.1 \ No newline at end of file +0.2.0 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1345c979..86945f85 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ requests>=2.19.1 pytest>=3.8.0 docopt>=0.6.2 jsonschema>=2.6.0 -cachetools>=2.1.0 + diff --git a/stac_validator/stac_utilities.py b/stac_validator/stac_utilities.py index 537b6751..3a34d2e2 100644 --- a/stac_validator/stac_utilities.py +++ b/stac_validator/stac_utilities.py @@ -43,6 +43,7 @@ def _determine_verison(self): self.CATALOG_URL = os.path.join(cdn_base_url, self.filename) self.COLLECTION_URL = os.path.join(cdn_base_url, self.filename) else: + if self.version in old_versions: self.CATALOG_URL = os.path.join( git_base_url, f"static-catalog/{self.input_type}/{self.filename}" @@ -75,7 +76,7 @@ def fix_stac_item(version, filename): return filename @classmethod - def catalog_schema_url(cls, version, filename="catalog.json"): + def get_catalog_schema_url(cls, version, filename="catalog.json"): """ Return path to catalog spec :param version: version to validate @@ -85,7 +86,7 @@ def catalog_schema_url(cls, version, filename="catalog.json"): return cls(version, "json-schema", filename).CATALOG_URL @classmethod - def collection_schema_url(cls, version, filename="collection.json"): + def get_collection_schema_url(cls, version, filename="collection.json"): """ Return path to collection spec :param version: version to validate @@ -95,7 +96,7 @@ def collection_schema_url(cls, version, filename="collection.json"): return cls(version, "json-schema", filename).COLLECTION_URL @classmethod - def item_schema_url(cls, version, filename="stac-item.json"): + def get_item_schema_url(cls, version, filename="stac-item.json"): """ Return path to item spec :param version: version to validate @@ -107,7 +108,7 @@ def item_schema_url(cls, version, filename="stac-item.json"): return cls(version, "json-schema", filename).ITEM_URL @classmethod - def item_geojson_schema_url(cls, version, filename="geojson.json"): + def get_geojson_schema_url(cls, version, filename="geojson.json"): """ Return path to item geojson spec :param version: version to validate diff --git a/stac_validator/stac_validator.py b/stac_validator/stac_validator.py index c75e48a4..14a3f349 100755 --- a/stac_validator/stac_validator.py +++ b/stac_validator/stac_validator.py @@ -2,7 +2,7 @@ Description: Validate a STAC item or catalog against the STAC specification. Usage: - stac_validator [--version STAC_VERSION] [--threads NTHREADS] [--verbose] [--timer] [--loglevel LOGLEVEL] + stac_validator [--version STAC_VERSION] [--threads NTHREADS] [--verbose] [--timer] [--log_level LOGLEVEL] Arguments: stac_file Fully qualified path or url to a STAC file. @@ -13,48 +13,48 @@ --threads NTHREADS Number of threads to use. [default: 10] --verbose Verbose output. [default: False] --timer Reports time to validate the STAC (seconds) - --loglevel LOGLEVEL Standard level of logging to report. [default: CRITICAL] + --log_level LOGLEVEL Standard level of logging to report. [default: CRITICAL] """ -import json import os import shutil import tempfile -from json.decoder import JSONDecodeError +import logging + +from pathlib import Path +from concurrent import futures +from functools import lru_cache from timeit import default_timer from urllib.parse import urljoin, urlparse -from concurrent import futures -import logging +import json +from json.decoder import JSONDecodeError +from jsonschema import RefResolutionError, RefResolver, ValidationError, validate import requests -from cachetools import LRUCache from docopt import docopt -from jsonschema import RefResolutionError, RefResolver, ValidationError, validate -from pathlib import Path + from .stac_utilities import StacVersion logger = logging.getLogger(__name__) -cache = LRUCache(maxsize=10) - - class VersionException(Exception): pass class StacValidate: - def __init__(self, stac_file, version="master", loglevel="CRITICAL"): + def __init__(self, stac_file, version="master", log_level="CRITICAL"): """ - Validate a STAC file + Validate a STAC file. :param stac_file: file to validate :param version: github tag - defaults to master """ - numeric_log_level = getattr(logging, loglevel.upper(), None) + + numeric_log_level = getattr(logging, log_level.upper(), None) if not isinstance(numeric_log_level, int): - raise ValueError("Invalid log level: %s" % loglevel) + raise ValueError("Invalid log level: %s" % log_level) logging.basicConfig( format="%(asctime)s : %(levelname)s : %(thread)d : %(message)s", @@ -64,9 +64,8 @@ def __init__(self, stac_file, version="master", loglevel="CRITICAL"): logging.info("STAC Validator Started.") self.stac_version = version self.stac_file = stac_file.strip() - self.dirpath = "" + self.dirpath = tempfile.mkdtemp() - self.fetch_specs(self.stac_version) self.message = [] self.status = { "catalogs": {"valid": 0, "invalid": 0}, @@ -75,87 +74,61 @@ def __init__(self, stac_file, version="master", loglevel="CRITICAL"): "unknown": 0, } - def fetch_specs(self, version): + @lru_cache(maxsize=48) + def fetch_spec(self, spec): """ - Get the versions from github. Cache them if possible. - :return: specs + Get the spec file and cache it. + :param spec: name of spec to get + :return: STAC spec in json format """ - geojson_key = "geojson_resolver" - item_key = f"item-{self.stac_version}" - catalog_key = f"catalog-{self.stac_version}" - - if item_key in cache and catalog_key in cache: - logging.debug("Using STAC specs from local cache.") - self.geojson_resolver = RefResolver( - base_uri=f"file://{self.dirpath}/", referrer="geojson.json" - ) - return cache[item_key], cache[geojson_key], cache[catalog_key] + if spec == "geojson": + spec_name = "geojson" + elif spec == "catalog" or spec == "collection": + spec_name = "catalog" else: - try: - logging.debug("Gathering STAC specs from remote.") - stac_item_geojson = requests.get( - StacVersion.item_geojson_schema_url(version) - ).json() - stac_item = requests.get(StacVersion.item_schema_url(version)).json() - stac_catalog = requests.get( - StacVersion.catalog_schema_url(version) - ).json() - except Exception as error: - logger.exception("STAC Download Error") - raise VersionException( - f"Could not download STAC specification files for version: {version}" - ) + spec_name = "item" - self.dirpath = tempfile.mkdtemp() - - with open(os.path.join(self.dirpath, "geojson.json"), "w") as fp: - logging.debug("Copying GeoJSON spec from local file to cache") - geojson_schema = json.dumps(stac_item_geojson) - fp.write(geojson_schema) - cache[geojson_key] = self.dirpath - self.geojson_resolver = RefResolver( - base_uri="file://{self.dirpath}/", referrer="geojson.json" + try: + logging.debug("Gathering STAC specs from remote.") + url = getattr(StacVersion, f"get_{spec_name}_schema_url") + spec = requests.get(url(self.stac_version)).json() + except Exception as error: + logger.exception("STAC Download Error") + raise VersionException( + f"Could not download STAC specification files for version: {self.stac_version}" ) - stac_item_file = StacVersion.fix_stac_item(version, "stac-item.json") - - with open(os.path.join(self.dirpath, stac_item_file), "w") as fp: - logging.debug("Copying STAC item spec from local file to cache") - stac_item_schema = json.dumps(stac_item) - fp.write(stac_item_schema) - cache[item_key] = stac_item_schema - - with open(os.path.join(self.dirpath, "stac-catalog.json"), "w") as fp: - logging.debug("Copying STAC catalog spec from local file to cache") - stac_catalog_schema = json.dumps(stac_catalog) - fp.write(stac_catalog_schema) - cache[catalog_key] = stac_catalog_schema + # Write the stac file to a filepath. used as absolute links for geojson schema + if spec_name == "geojson": + file_name = os.path.join(self.dirpath, "geojson.json") + else: + file_name = os.path.join( + self.dirpath, f"{spec_name}_{self.stac_version.replace('.', '_')}.json" + ) - ITEM_SCHEMA = os.path.join(self.dirpath, "stac-item.json") - ITEM_GEOJSON_SCHEMA = os.path.join(self.dirpath, "geojson.json") - CATALOG_SCHEMA = os.path.join(self.dirpath, "stac-catalog.json") + with open(file_name, "w") as fp: + logging.debug(f"Copying {spec_name} spec from local file to cache") + fp.write(json.dumps(spec)) - return ITEM_SCHEMA, ITEM_GEOJSON_SCHEMA, CATALOG_SCHEMA + return spec - def validate_json(self, stac_content, schema): + def validate_json(self, stac_content, stac_schema): """ - Validate stac - :param stac_content: input stac file content - :param schema of STAC (item, catalog) + Validate STAC. + :param stac_content: input STAC file content + :param stac_schema of STAC (item, catalog, collection) :return: validation message """ - stac_schema = json.loads(schema) try: if "title" in stac_schema and "item" in stac_schema["title"].lower(): logger.debug("Changing GeoJson definition to reference local file") # rewrite relative reference to use local geojson file - stac_schema["definitions"]["core"]["allOf"][0]["oneOf"][0]["$ref"] = ( - "file://" - + cache["geojson_resolver"] - + "/geojson.json#definitions/feature" - ) + stac_schema['id'] = f"item_{self.stac_version.replace('.', '_')}.json" + stac_schema["definitions"]["core"]["allOf"][0]["oneOf"][0][ + "$ref" + ] = f"file://{self.dirpath}/geojson.json#/definitions/feature" logging.info("Validating STAC") validate(stac_content, stac_schema) return True, None @@ -163,9 +136,11 @@ def validate_json(self, stac_content, schema): # See https://github.com/Julian/jsonschema/issues/362 # See https://github.com/Julian/jsonschema/issues/313 # See https://github.com/Julian/jsonschema/issues/98 + # See https://github.com/Julian/jsonschema/issues/343 try: + self.fetch_spec("geojson") self.geojson_resolver = RefResolver( - base_uri=f"file://{cache['geojson_resolver']}/", + base_uri=f"file://{self.dirpath}/geojson.json", referrer="geojson.json", ) validate(stac_content, stac_schema, resolver=self.geojson_resolver) @@ -180,7 +155,15 @@ def validate_json(self, stac_content, schema): logger.exception("STAC error") return False, f"{error}" - def _update_status(self, old_status, new_status): + @staticmethod + def _update_status(old_status, new_status): + """ + Set status messages. + :param old_status: original status + :param new_status: changed status + :return: status dictionary + """ + old_status["catalogs"]["valid"] += new_status["catalogs"]["valid"] old_status["catalogs"]["invalid"] += new_status["catalogs"]["invalid"] old_status["collections"]["valid"] += new_status["collections"]["valid"] @@ -190,8 +173,15 @@ def _update_status(self, old_status, new_status): old_status["unknown"] += new_status["unknown"] return old_status - def _get_childs_urls(self, stac_content, stac_path): - """Return childs items or catalog urls.""" + @staticmethod + def _get_children_urls(stac_content, stac_path): + """ + Return children items or catalog urls. + :param stac_content: contents of STAC file + :param stac_path: path to STAC file + :return: list of urls + """ + urls = [] for link in stac_content.get("links", []): @@ -199,18 +189,26 @@ def _get_childs_urls(self, stac_content, stac_path): urls.append(urljoin(stac_path, link["href"]).strip()) return urls - def is_valid_url(self, url): + @staticmethod + def is_valid_url(url): + """ + Check if path is URL or not. + :param url: path to check + :return: boolean + """ try: result = urlparse(url) return result.scheme and result.netloc and result.path - except: + except Exception as e: return False def fetch_and_parse_file(self, input_path): """ - Fetch and parse STAC file + Fetch and parse STAC file. + :param input_path: STAC file to get and read :return: content or error message """ + err_message = {} data = None @@ -239,6 +237,11 @@ def fetch_and_parse_file(self, input_path): return data, err_message def _validate(self, stac_path): + """ + Check STAC type and appropriate schema to validate against. + :param stac_path: path to STAC file + :return: JSON message and list of children to (potentially) validate + """ fpath = Path(stac_path) @@ -271,7 +274,7 @@ def _validate(self, stac_path): logger.info("STAC is a Catalog") message["asset_type"] = "catalog" is_valid_stac, err_message = self.validate_json( - stac_content, cache[f"catalog-{self.stac_version}"] + stac_content, self.fetch_spec("catalog") ) message["valid_stac"] = is_valid_stac message["error_message"] = err_message @@ -281,17 +284,17 @@ def _validate(self, stac_path): else: status["catalogs"]["invalid"] = 1 - childs = self._get_childs_urls(stac_content, stac_path) + children = self._get_children_urls(stac_content, stac_path) elif type(stac_content) is dict and any( field in Collections_Fields for field in stac_content.keys() ): # Congratulations, It's a Collection! # Collections will validate as catalog. - logger.info("STAC is a Colltection") + logger.info("STAC is a Collection") message["asset_type"] = "collection" is_valid_stac, err_message = self.validate_json( - stac_content, cache[f"catalog-{self.stac_version}"] + stac_content, self.fetch_spec("catalog") ) message["valid_stac"] = is_valid_stac @@ -302,7 +305,7 @@ def _validate(self, stac_path): else: status["collections"]["invalid"] = 1 - childs = self._get_childs_urls(stac_content, stac_path) + children = self._get_children_urls(stac_content, stac_path) elif "error_type" in message: pass @@ -311,8 +314,9 @@ def _validate(self, stac_path): # Congratulations, It's an Item! logger.info("STAC is an Item") message["asset_type"] = "item" + self.fetch_spec("geojson") is_valid_stac, err_message = self.validate_json( - stac_content, cache[f"item-{self.stac_version}"] + stac_content, self.fetch_spec("item") ) message["valid_stac"] = is_valid_stac message["error_message"] = err_message @@ -322,31 +326,34 @@ def _validate(self, stac_path): else: status["items"]["invalid"] = 1 - childs = [] + children = [] message["path"] = stac_path - return message, status, childs + return message, status, children def run(self, concurrent=10): """ - Entry point + Entry point. + :param concurrent: number of threads to use :return: message json - """ - childs = [self.stac_file] + + children = [self.stac_file] logger.info(f"Using {concurrent} threads") while True: with futures.ThreadPoolExecutor(max_workers=int(concurrent)) as executor: - future_tasks = [executor.submit(self._validate, url) for url in childs] - childs = [] + future_tasks = [ + executor.submit(self._validate, url) for url in children + ] + children = [] for task in futures.as_completed(future_tasks): - message, status, new_childs = task.result() + message, status, new_children = task.result() self.status = self._update_status(self.status, status) self.message.append(message) - childs.extend(new_childs) + children.extend(new_children) - if not childs: + if not children: break return json.dumps(self.message) @@ -359,12 +366,12 @@ def main(): verbose = args.get("--verbose") nthreads = args.get("--threads", 10) timer = args.get("--timer") - loglevel = args.get("--loglevel", "CRITICAL") + log_level = args.get("--log_level", "CRITICAL") if timer: start = default_timer() - stac = StacValidate(stac_file, version, loglevel) + stac = StacValidate(stac_file, version, log_level) _ = stac.run(nthreads) shutil.rmtree(stac.dirpath) diff --git a/stac_validator_lambda/stac_handler.py b/stac_validator_lambda/stac_handler.py deleted file mode 100644 index b989896c..00000000 --- a/stac_validator_lambda/stac_handler.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -Description: Lambda handler for invoking the validator - -""" - -__author__ = "James Banting" - - -import json -import uuid -from stac_validator import stac_validator - - -def handler(event, context): - """ - Lambda Handler - :param event: params passed to lambda - :param context: AWS runtime params - :return: dict message - """ - - # Find input params - json_STAC = event.get('json') - url_STAC = event.get('url') - version = event.get('schemaVersion', None) - print(f"STAC verison: {version}") - if version == 'latest': - version = 'master' - - # Check for JSON string - if type(json_STAC) is dict: - local_stac = f"/tmp/{str(uuid.uuid4())}.json" - - with open(local_stac, "w") as f: - json.dump(json_STAC, f) - - stac_file = local_stac - else: - stac_file = url_STAC - - stac_message = stac_validator.StacValidate(stac_file.strip(), version, verbose=True).message - - return stac_message diff --git a/tests/test_stac_validator.py b/tests/test_stac_validator.py index 576ca13a..7176bc85 100644 --- a/tests/test_stac_validator.py +++ b/tests/test_stac_validator.py @@ -3,122 +3,62 @@ """ __author__ = "James Banting" -import stac_validator -import trio import pytest +from stac_validator import stac_validator def _run_validate(url, version="master"): stac = stac_validator.StacValidate(url, version) - trio.run(stac.run) + stac.run() return stac -def test_good_item_validation_v052(): +def test_good_item_validation_v052_verbose(): stac = _run_validate("tests/test_data/good_item_v052.json", "v0.5.2") - assert stac.message == { - "asset_type": "item", - "path": "tests/test_data/good_item_v052.json", - "valid_stac": True, - } + assert stac.message == [ + { + "asset_type": "item", + "valid_stac": True, + "error_message": None, + "path": "tests/test_data/good_item_v052.json", + } + ] -def test_good_item_validation_v060(): +def test_good_item_validation_v060_verbose(): stac = _run_validate("tests/test_data/good_item_v060.json") - assert stac.message == { - "asset_type": "item", - "path": "tests/test_data/good_item_v060.json", - "valid_stac": True, - } + assert stac.message == [ + { + "asset_type": "item", + "valid_stac": True, + "error_message": None, + "path": "tests/test_data/good_item_v060.json", + } + ] -def test_good_catalog_validation_v052(): +def test_good_catalog_validation_v052_verbose(): stac = _run_validate("tests/test_data/good_catalog_v052.json", "v0.5.2") - assert stac.message == { - "asset_type": "catalog", - "path": "tests/test_data/good_catalog_v052.json", - "valid_stac": True, - "children": [], - } + assert stac.message == [ + { + "asset_type": "catalog", + "valid_stac": True, + "error_message": None, + "path": "tests/test_data/good_catalog_v052.json", + } + ] -# Need to fix test around async return - output is valid, but dict is out of order -# def test_nested_catalog_v052(): -# stac = _run_validate( -# "tests/test_data/nested_catalogs/parent_catalog.json", "v0.5.2" -# ) -# truth = { -# "asset_type": "catalog", -# "valid_stac": True, -# "children": [ -# { -# "asset_type": "catalog", -# "valid_stac": False, -# "error": "'name' is a required property of []", -# "children": [], -# "path": "tests/test_data/nested_catalogs/999/invalid_catalog.json", -# }, -# { -# "asset_type": "catalog", -# "valid_stac": True, -# "children": [ -# { -# "asset_type": "item", -# "valid_stac": False, -# "error": "'type' is a required property of []", -# "path": "tests/test_data/nested_catalogs/105/INVALID_CBERS_4_MUX_20180808_057_105_L2.json", -# }, -# { -# "asset_type": "item", -# "valid_stac": True, -# "path": "tests/test_data/nested_catalogs/105/CBERS_4_MUX_20180713_057_105_L2.json", -# }, -# { -# "asset_type": "item", -# "valid_stac": True, -# "path": "tests/test_data/nested_catalogs/105/CBERS_4_MUX_20180808_057_105_L2.json", -# }, -# ], -# "path": "tests/test_data/nested_catalogs/105/catalog.json", -# }, -# { -# "asset_type": "catalog", -# "valid_stac": True, -# "children": [ -# { -# "asset_type": "item", -# "valid_stac": True, -# "path": "tests/test_data/nested_catalogs/122/CBERS_4_MUX_20180713_057_122_L2.json", -# }, -# { -# "asset_type": "item", -# "valid_stac": True, -# "path": "tests/test_data/nested_catalogs/122/CBERS_4_MUX_20180808_057_122_L2.json", -# }, -# { -# "asset_type": "catalog", -# "valid_stac": True, -# "children": [ -# { -# "asset_type": "item", -# "valid_stac": True, -# "path": "tests/test_data/nested_catalogs/122/130/CBERS_4_MUX_20180713_098_122_L2.json", -# }, -# { -# "asset_type": "item", -# "valid_stac": True, -# "path": "tests/test_data/nested_catalogs/122/130/CBERS_4_MUX_20180808_099_122_L2.json", -# }, -# ], -# "path": "tests/test_data/nested_catalogs/122/130/catalog.json", -# }, -# ], -# "path": "tests/test_data/nested_catalogs/122/catalog.json", -# }, -# ], -# "path": "tests/test_data/nested_catalogs/parent_catalog.json", -# } -# assert stac.message == truth +def test_nested_catalog_v052(): + stac = _run_validate( + "tests/test_data/nested_catalogs/parent_catalog.json", "v0.5.2" + ) + assert stac.status == { + "catalogs": {"valid": 4, "invalid": 1}, + "collections": {"valid": 0, "invalid": 0}, + "items": {"valid": 6, "invalid": 1}, + "unknown": 0, + } def test_verbose_v052(): @@ -129,6 +69,7 @@ def test_verbose_v052(): "catalogs": {"valid": 4, "invalid": 1}, "collections": {"valid": 0, "invalid": 0}, "items": {"valid": 6, "invalid": 1}, + "unknown": 0, } @@ -138,18 +79,20 @@ def test_bad_url(): "v0.5.2", ) assert stac.status == { - "valid_stac": False, - "error_type": "InvalidJSON", - "error_message": "https://s3.amazonaws.com/spacenet-stac/spacenet-dataset/AOI_4_Shanghai_MUL-PanSharpen_Cloud is not Valid JSON", - "path": "https:/s3.amazonaws.com/spacenet-stac/spacenet-dataset/AOI_4_Shanghai_MUL-PanSharpen_Cloud", - } - assert stac.message == { - "valid_stac": False, - "error_type": "InvalidJSON", - "error_message": "https://s3.amazonaws.com/spacenet-stac/spacenet-dataset/AOI_4_Shanghai_MUL-PanSharpen_Cloud is not Valid JSON", - "path": "https:/s3.amazonaws.com/spacenet-stac/spacenet-dataset/AOI_4_Shanghai_MUL-PanSharpen_Cloud", + "catalogs": {"valid": 0, "invalid": 0}, + "collections": {"valid": 0, "invalid": 0}, + "items": {"valid": 0, "invalid": 0}, + "unknown": 1, } + assert stac.message == [ + { + "valid_stac": False, + "error_type": "InvalidJSON", + "error_message": "https://s3.amazonaws.com/spacenet-stac/spacenet-dataset/AOI_4_Shanghai_MUL-PanSharpen_Cloud is not Valid JSON", + } + ] + @pytest.mark.stac_spec def test_catalog_master(): @@ -160,10 +103,12 @@ def test_catalog_master(): "catalogs": {"valid": 1, "invalid": 0}, "collections": {"valid": 0, "invalid": 0}, "items": {"valid": 0, "invalid": 0}, + "unknown": 1, } + @pytest.mark.stac_spec -def test_collection_master(): +def test_collection_master_verbose(): stac = _run_validate( "https://raw.githubusercontent.com/radiantearth/stac-spec/master/collection-spec/examples/sentinel2.json" ) @@ -171,8 +116,10 @@ def test_collection_master(): "catalogs": {"valid": 0, "invalid": 0}, "collections": {"valid": 1, "invalid": 0}, "items": {"valid": 0, "invalid": 0}, + "unknown": 0, } + @pytest.mark.item_spec @pytest.mark.stac_spec def test_item_master(): @@ -183,5 +130,5 @@ def test_item_master(): "catalogs": {"valid": 0, "invalid": 0}, "collections": {"valid": 0, "invalid": 0}, "items": {"valid": 1, "invalid": 0}, + "unknown": 0, } -