stac-fastapi-core 6.7.4__tar.gz → 6.7.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/PKG-INFO +8 -8
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/pyproject.toml +10 -7
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/core.py +6 -2
- stac_fastapi_core-6.7.6/stac_fastapi/core/datetime_utils.py +123 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/redis_utils.py +97 -87
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/version.py +1 -1
- stac_fastapi_core-6.7.4/stac_fastapi/core/datetime_utils.py +0 -75
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/.gitignore +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/README.md +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/pytest.ini +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/__init__.py +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/base_database_logic.py +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/base_settings.py +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/basic_auth.py +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/extensions/__init__.py +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/extensions/aggregation.py +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/extensions/collections_search.py +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/extensions/fields.py +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/extensions/filter.py +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/extensions/query.py +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/models/__init__.py +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/models/links.py +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/models/search.py +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/rate_limit.py +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/route_dependencies.py +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/serializers.py +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/session.py +0 -0
- {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/utilities.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stac_fastapi_core
|
|
3
|
-
Version: 6.7.
|
|
3
|
+
Version: 6.7.6
|
|
4
4
|
Summary: Core library for the Elasticsearch and Opensearch stac-fastapi backends.
|
|
5
5
|
Project-URL: Homepage, https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch
|
|
6
6
|
License: MIT
|
|
@@ -9,13 +9,11 @@ Classifier: Intended Audience :: Developers
|
|
|
9
9
|
Classifier: Intended Audience :: Information Technology
|
|
10
10
|
Classifier: Intended Audience :: Science/Research
|
|
11
11
|
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
14
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
15
|
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
-
Requires-Python: >=3.
|
|
16
|
+
Requires-Python: >=3.11
|
|
19
17
|
Requires-Dist: attrs>=23.2.0
|
|
20
18
|
Requires-Dist: fastapi~=0.109.0
|
|
21
19
|
Requires-Dist: geojson-pydantic~=1.0.0
|
|
@@ -24,12 +22,14 @@ Requires-Dist: orjson~=3.11.0
|
|
|
24
22
|
Requires-Dist: overrides~=7.4.0
|
|
25
23
|
Requires-Dist: pydantic<3.0.0,>=2.4.1
|
|
26
24
|
Requires-Dist: pygeofilter~=0.3.1
|
|
27
|
-
Requires-Dist: redis==6.4.0
|
|
28
25
|
Requires-Dist: slowapi~=0.1.9
|
|
29
|
-
Requires-Dist: stac-fastapi-api==6.
|
|
30
|
-
Requires-Dist: stac-fastapi-extensions==6.
|
|
31
|
-
Requires-Dist: stac-fastapi-types==6.
|
|
26
|
+
Requires-Dist: stac-fastapi-api==6.1.1
|
|
27
|
+
Requires-Dist: stac-fastapi-extensions==6.1.1
|
|
28
|
+
Requires-Dist: stac-fastapi-types==6.1.1
|
|
32
29
|
Requires-Dist: stac-pydantic~=3.3.0
|
|
30
|
+
Provides-Extra: redis
|
|
31
|
+
Requires-Dist: redis~=6.4.0; extra == 'redis'
|
|
32
|
+
Requires-Dist: retry~=0.9.2; extra == 'redis'
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
34
|
|
|
35
35
|
# stac-fastapi-core
|
|
@@ -6,15 +6,13 @@ build-backend = "hatchling.build"
|
|
|
6
6
|
name = "stac_fastapi_core"
|
|
7
7
|
description = "Core library for the Elasticsearch and Opensearch stac-fastapi backends."
|
|
8
8
|
readme = "README.md"
|
|
9
|
-
requires-python = ">=3.
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
10
|
license = {text = "MIT"}
|
|
11
11
|
authors = []
|
|
12
12
|
classifiers = [
|
|
13
13
|
"Intended Audience :: Developers",
|
|
14
14
|
"Intended Audience :: Information Technology",
|
|
15
15
|
"Intended Audience :: Science/Research",
|
|
16
|
-
"Programming Language :: Python :: 3.9",
|
|
17
|
-
"Programming Language :: Python :: 3.10",
|
|
18
16
|
"Programming Language :: Python :: 3.11",
|
|
19
17
|
"Programming Language :: Python :: 3.12",
|
|
20
18
|
"Programming Language :: Python :: 3.13",
|
|
@@ -35,16 +33,21 @@ dependencies = [
|
|
|
35
33
|
"attrs>=23.2.0",
|
|
36
34
|
"pydantic>=2.4.1,<3.0.0",
|
|
37
35
|
"stac_pydantic~=3.3.0",
|
|
38
|
-
"stac-fastapi.types==6.
|
|
39
|
-
"stac-fastapi.api==6.
|
|
40
|
-
"stac-fastapi.extensions==6.
|
|
36
|
+
"stac-fastapi.types==6.1.1",
|
|
37
|
+
"stac-fastapi.api==6.1.1",
|
|
38
|
+
"stac-fastapi.extensions==6.1.1",
|
|
41
39
|
"orjson~=3.11.0",
|
|
42
40
|
"overrides~=7.4.0",
|
|
43
41
|
"geojson-pydantic~=1.0.0",
|
|
44
42
|
"pygeofilter~=0.3.1",
|
|
45
43
|
"jsonschema~=4.0.0",
|
|
46
44
|
"slowapi~=0.1.9",
|
|
47
|
-
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[project.optional-dependencies]
|
|
48
|
+
redis = [
|
|
49
|
+
"redis~=6.4.0",
|
|
50
|
+
"retry~=0.9.2",
|
|
48
51
|
]
|
|
49
52
|
|
|
50
53
|
[project.urls]
|
|
@@ -24,7 +24,6 @@ from stac_fastapi.core.base_database_logic import BaseDatabaseLogic
|
|
|
24
24
|
from stac_fastapi.core.base_settings import ApiBaseSettings
|
|
25
25
|
from stac_fastapi.core.datetime_utils import format_datetime_range
|
|
26
26
|
from stac_fastapi.core.models.links import PagingLinks
|
|
27
|
-
from stac_fastapi.core.redis_utils import redis_pagination_links
|
|
28
27
|
from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer
|
|
29
28
|
from stac_fastapi.core.session import Session
|
|
30
29
|
from stac_fastapi.core.utilities import filter_fields, get_bool_env
|
|
@@ -426,6 +425,8 @@ class CoreClient(AsyncBaseCoreClient):
|
|
|
426
425
|
]
|
|
427
426
|
|
|
428
427
|
if redis_enable:
|
|
428
|
+
from stac_fastapi.core.redis_utils import redis_pagination_links
|
|
429
|
+
|
|
429
430
|
await redis_pagination_links(
|
|
430
431
|
current_url=str(request.url),
|
|
431
432
|
token=token,
|
|
@@ -790,9 +791,10 @@ class CoreClient(AsyncBaseCoreClient):
|
|
|
790
791
|
search=search, collection_ids=search_request.collections
|
|
791
792
|
)
|
|
792
793
|
|
|
794
|
+
datetime_parsed = format_datetime_range(date_str=search_request.datetime)
|
|
793
795
|
try:
|
|
794
796
|
search, datetime_search = self.database.apply_datetime_filter(
|
|
795
|
-
search=search, datetime=
|
|
797
|
+
search=search, datetime=datetime_parsed
|
|
796
798
|
)
|
|
797
799
|
except (ValueError, TypeError) as e:
|
|
798
800
|
# Handle invalid interval formats if return_date fails
|
|
@@ -903,6 +905,8 @@ class CoreClient(AsyncBaseCoreClient):
|
|
|
903
905
|
links.extend(collection_links)
|
|
904
906
|
|
|
905
907
|
if redis_enable:
|
|
908
|
+
from stac_fastapi.core.redis_utils import redis_pagination_links
|
|
909
|
+
|
|
906
910
|
await redis_pagination_links(
|
|
907
911
|
current_url=str(request.url),
|
|
908
912
|
token=token_param,
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Utility functions to handle datetime parsing."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from stac_fastapi.core.utilities import get_bool_env
|
|
6
|
+
from stac_fastapi.types.rfc3339 import rfc3339_str_to_datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def format_datetime_range(date_str: str) -> str:
|
|
10
|
+
"""
|
|
11
|
+
Convert a datetime range string into a normalized UTC string for API requests using rfc3339_str_to_datetime.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
date_str (str): A string containing two datetime values separated by a '/'.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
str: A string formatted as 'YYYY-MM-DDTHH:MM:SSZ/YYYY-MM-DDTHH:MM:SSZ', with '..' used if any element is None.
|
|
18
|
+
"""
|
|
19
|
+
use_datetime_nanos = get_bool_env("USE_DATETIME_NANOS", default=True)
|
|
20
|
+
|
|
21
|
+
if use_datetime_nanos:
|
|
22
|
+
MIN_DATE_NANOS = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
23
|
+
MAX_DATE_NANOS = datetime(2262, 4, 11, 23, 47, 16, 854775, tzinfo=timezone.utc)
|
|
24
|
+
|
|
25
|
+
def normalize(dt):
|
|
26
|
+
"""Normalize datetime string and preserve nano second precision."""
|
|
27
|
+
dt = dt.strip()
|
|
28
|
+
if not dt or dt == "..":
|
|
29
|
+
return ".."
|
|
30
|
+
dt_utc = rfc3339_str_to_datetime(dt).astimezone(timezone.utc)
|
|
31
|
+
if dt_utc < MIN_DATE_NANOS:
|
|
32
|
+
dt_utc = MIN_DATE_NANOS
|
|
33
|
+
if dt_utc > MAX_DATE_NANOS:
|
|
34
|
+
dt_utc = MAX_DATE_NANOS
|
|
35
|
+
dt_normalized = dt_utc.isoformat(timespec="auto").replace("+00:00", "Z")
|
|
36
|
+
if "." not in dt_normalized:
|
|
37
|
+
dt_normalized = dt_normalized.replace("Z", ".0Z")
|
|
38
|
+
return dt_normalized
|
|
39
|
+
|
|
40
|
+
if not isinstance(date_str, str):
|
|
41
|
+
return f"{MIN_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}/{MAX_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}"
|
|
42
|
+
|
|
43
|
+
if "/" not in date_str:
|
|
44
|
+
return f"{normalize(date_str)}/{normalize(date_str)}"
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
start, end = date_str.split("/", 1)
|
|
48
|
+
except Exception:
|
|
49
|
+
return f"{MIN_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}/{MAX_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}"
|
|
50
|
+
|
|
51
|
+
normalized_start = normalize(start)
|
|
52
|
+
normalized_end = normalize(end)
|
|
53
|
+
|
|
54
|
+
if normalized_start == "..":
|
|
55
|
+
normalized_start = MIN_DATE_NANOS.isoformat(timespec="auto").replace(
|
|
56
|
+
"+00:00", "Z"
|
|
57
|
+
)
|
|
58
|
+
if normalized_end == "..":
|
|
59
|
+
normalized_end = MAX_DATE_NANOS.isoformat(timespec="auto").replace(
|
|
60
|
+
"+00:00", "Z"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return f"{normalized_start}/{normalized_end}"
|
|
64
|
+
|
|
65
|
+
else:
|
|
66
|
+
|
|
67
|
+
def normalize(dt):
|
|
68
|
+
"""Normalize datetime string and preserve millisecond precision."""
|
|
69
|
+
dt = dt.strip()
|
|
70
|
+
if not dt or dt == "..":
|
|
71
|
+
return ".."
|
|
72
|
+
dt_obj = rfc3339_str_to_datetime(dt)
|
|
73
|
+
dt_utc = dt_obj.astimezone(timezone.utc)
|
|
74
|
+
return dt_utc.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
75
|
+
|
|
76
|
+
if not isinstance(date_str, str):
|
|
77
|
+
return "../.."
|
|
78
|
+
|
|
79
|
+
if "/" not in date_str:
|
|
80
|
+
return f"{normalize(date_str)}/{normalize(date_str)}"
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
start, end = date_str.split("/", 1)
|
|
84
|
+
except Exception:
|
|
85
|
+
return "../.."
|
|
86
|
+
return f"{normalize(start)}/{normalize(end)}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Borrowed from pystac - https://github.com/stac-utils/pystac/blob/f5e4cf4a29b62e9ef675d4a4dac7977b09f53c8f/pystac/utils.py#L370-L394
|
|
90
|
+
def datetime_to_str(dt: datetime, timespec: str = "auto") -> str:
|
|
91
|
+
"""Convert a :class:`datetime.datetime` instance to an ISO8601 string in the `RFC 3339, section 5.6.
|
|
92
|
+
|
|
93
|
+
<https://datatracker.ietf.org/doc/html/rfc3339#section-5.6>`__ format required by
|
|
94
|
+
the :stac-spec:`STAC Spec <master/item-spec/common-metadata.md#date-and-time>`.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
dt : The datetime to convert.
|
|
98
|
+
timespec: An optional argument that specifies the number of additional
|
|
99
|
+
terms of the time to include. Valid options are 'auto', 'hours',
|
|
100
|
+
'minutes', 'seconds', 'milliseconds' and 'microseconds'. The default value
|
|
101
|
+
is 'auto'.
|
|
102
|
+
Returns:
|
|
103
|
+
str: The ISO8601 (RFC 3339) formatted string representing the datetime.
|
|
104
|
+
"""
|
|
105
|
+
if dt.tzinfo is None:
|
|
106
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
107
|
+
|
|
108
|
+
timestamp = dt.isoformat(timespec=timespec)
|
|
109
|
+
zulu = "+00:00"
|
|
110
|
+
if timestamp.endswith(zulu):
|
|
111
|
+
timestamp = f"{timestamp[: -len(zulu)]}Z"
|
|
112
|
+
|
|
113
|
+
return timestamp
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def now_in_utc() -> datetime:
|
|
117
|
+
"""Return a datetime value of now with the UTC timezone applied."""
|
|
118
|
+
return datetime.now(timezone.utc)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def now_to_rfc3339_str() -> str:
|
|
122
|
+
"""Return an RFC 3339 string representing now."""
|
|
123
|
+
return datetime_to_str(now_in_utc())
|
|
@@ -2,25 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
|
-
from
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import Callable, List, Optional, Tuple, cast
|
|
6
7
|
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
|
7
8
|
|
|
8
9
|
from pydantic import Field, field_validator
|
|
9
10
|
from pydantic_settings import BaseSettings
|
|
10
11
|
from redis import asyncio as aioredis
|
|
11
12
|
from redis.asyncio.sentinel import Sentinel
|
|
13
|
+
from redis.exceptions import ConnectionError as RedisConnectionError
|
|
14
|
+
from redis.exceptions import TimeoutError as RedisTimeoutError
|
|
15
|
+
from retry import retry # type: ignore
|
|
12
16
|
|
|
13
17
|
logger = logging.getLogger(__name__)
|
|
14
18
|
|
|
15
19
|
|
|
16
|
-
class
|
|
17
|
-
"""
|
|
20
|
+
class RedisCommonSettings(BaseSettings):
|
|
21
|
+
"""Common configuration for Redis Sentinel and Redis Standalone."""
|
|
18
22
|
|
|
19
|
-
REDIS_SENTINEL_HOSTS: str = ""
|
|
20
|
-
REDIS_SENTINEL_PORTS: str = "26379"
|
|
21
|
-
REDIS_SENTINEL_MASTER_NAME: str = "master"
|
|
22
23
|
REDIS_DB: int = 15
|
|
23
|
-
|
|
24
24
|
REDIS_MAX_CONNECTIONS: Optional[int] = None
|
|
25
25
|
REDIS_RETRY_TIMEOUT: bool = True
|
|
26
26
|
REDIS_DECODE_RESPONSES: bool = True
|
|
@@ -28,9 +28,13 @@ class RedisSentinelSettings(BaseSettings):
|
|
|
28
28
|
REDIS_HEALTH_CHECK_INTERVAL: int = Field(default=30, gt=0)
|
|
29
29
|
REDIS_SELF_LINK_TTL: int = 1800
|
|
30
30
|
|
|
31
|
+
REDIS_QUERY_RETRIES_NUM: int = Field(default=3, gt=0)
|
|
32
|
+
REDIS_QUERY_INITIAL_DELAY: float = Field(default=1.0, gt=0)
|
|
33
|
+
REDIS_QUERY_BACKOFF: float = Field(default=2.0, gt=1)
|
|
34
|
+
|
|
31
35
|
@field_validator("REDIS_DB")
|
|
32
36
|
@classmethod
|
|
33
|
-
def
|
|
37
|
+
def validate_db(cls, v: int) -> int:
|
|
34
38
|
"""Validate REDIS_DB is not negative integer."""
|
|
35
39
|
if v < 0:
|
|
36
40
|
raise ValueError("REDIS_DB must be a positive integer")
|
|
@@ -46,12 +50,20 @@ class RedisSentinelSettings(BaseSettings):
|
|
|
46
50
|
|
|
47
51
|
@field_validator("REDIS_SELF_LINK_TTL")
|
|
48
52
|
@classmethod
|
|
49
|
-
def
|
|
50
|
-
"""Validate REDIS_SELF_LINK_TTL is
|
|
53
|
+
def validate_self_link_ttl(cls, v: int) -> int:
|
|
54
|
+
"""Validate REDIS_SELF_LINK_TTL is negative."""
|
|
51
55
|
if v < 0:
|
|
52
56
|
raise ValueError("REDIS_SELF_LINK_TTL must be a positive integer")
|
|
53
57
|
return v
|
|
54
58
|
|
|
59
|
+
|
|
60
|
+
class RedisSentinelSettings(RedisCommonSettings):
|
|
61
|
+
"""Configuration for connecting to Redis Sentinel."""
|
|
62
|
+
|
|
63
|
+
REDIS_SENTINEL_HOSTS: str = ""
|
|
64
|
+
REDIS_SENTINEL_PORTS: str = "26379"
|
|
65
|
+
REDIS_SENTINEL_MASTER_NAME: str = "master"
|
|
66
|
+
|
|
55
67
|
def get_sentinel_hosts(self) -> List[str]:
|
|
56
68
|
"""Parse Redis Sentinel hosts from string to list."""
|
|
57
69
|
if not self.REDIS_SENTINEL_HOSTS:
|
|
@@ -96,19 +108,11 @@ class RedisSentinelSettings(BaseSettings):
|
|
|
96
108
|
return [(str(host), int(port)) for host, port in zip(hosts, ports)]
|
|
97
109
|
|
|
98
110
|
|
|
99
|
-
class RedisSettings(
|
|
111
|
+
class RedisSettings(RedisCommonSettings):
|
|
100
112
|
"""Configuration for connecting Redis."""
|
|
101
113
|
|
|
102
114
|
REDIS_HOST: str = ""
|
|
103
115
|
REDIS_PORT: int = 6379
|
|
104
|
-
REDIS_DB: int = 15
|
|
105
|
-
|
|
106
|
-
REDIS_MAX_CONNECTIONS: Optional[int] = None
|
|
107
|
-
REDIS_RETRY_TIMEOUT: bool = True
|
|
108
|
-
REDIS_DECODE_RESPONSES: bool = True
|
|
109
|
-
REDIS_CLIENT_NAME: str = "stac-fastapi-app"
|
|
110
|
-
REDIS_HEALTH_CHECK_INTERVAL: int = Field(default=30, gt=0)
|
|
111
|
-
REDIS_SELF_LINK_TTL: int = 1800
|
|
112
116
|
|
|
113
117
|
@field_validator("REDIS_PORT")
|
|
114
118
|
@classmethod
|
|
@@ -118,89 +122,93 @@ class RedisSettings(BaseSettings):
|
|
|
118
122
|
raise ValueError("REDIS_PORT must be a positive integer")
|
|
119
123
|
return v
|
|
120
124
|
|
|
121
|
-
@field_validator("REDIS_DB")
|
|
122
|
-
@classmethod
|
|
123
|
-
def validate_db_standalone(cls, v: int) -> int:
|
|
124
|
-
"""Validate REDIS_DB is not a negative integer."""
|
|
125
|
-
if v < 0:
|
|
126
|
-
raise ValueError("REDIS_DB must be a positive integer")
|
|
127
|
-
return v
|
|
128
|
-
|
|
129
|
-
@field_validator("REDIS_MAX_CONNECTIONS", mode="before")
|
|
130
|
-
@classmethod
|
|
131
|
-
def validate_max_connections(cls, v):
|
|
132
|
-
"""Handle empty/None values for REDIS_MAX_CONNECTIONS."""
|
|
133
|
-
if v in ["", "null", "Null", "NULL", "none", "None", "NONE", None]:
|
|
134
|
-
return None
|
|
135
|
-
return v
|
|
136
|
-
|
|
137
|
-
@field_validator("REDIS_SELF_LINK_TTL")
|
|
138
|
-
@classmethod
|
|
139
|
-
def validate_self_link_ttl_standalone(cls, v: int) -> int:
|
|
140
|
-
"""Validate REDIS_SELF_LINK_TTL is negative."""
|
|
141
|
-
if v < 0:
|
|
142
|
-
raise ValueError("REDIS_SELF_LINK_TTL must be a positive integer")
|
|
143
|
-
return v
|
|
144
|
-
|
|
145
125
|
|
|
146
126
|
# Configure only one Redis configuration
|
|
147
127
|
sentinel_settings = RedisSentinelSettings()
|
|
148
|
-
|
|
128
|
+
settings: RedisCommonSettings = cast(
|
|
129
|
+
RedisCommonSettings,
|
|
130
|
+
sentinel_settings if sentinel_settings.REDIS_SENTINEL_HOSTS else RedisSettings(),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def redis_retry(func: Callable) -> Callable:
|
|
135
|
+
"""Retry with back-off decorator for Redis connections."""
|
|
136
|
+
|
|
137
|
+
@wraps(func)
|
|
138
|
+
@retry(
|
|
139
|
+
exceptions=(RedisConnectionError, RedisTimeoutError),
|
|
140
|
+
tries=settings.REDIS_QUERY_RETRIES_NUM,
|
|
141
|
+
delay=settings.REDIS_QUERY_INITIAL_DELAY,
|
|
142
|
+
backoff=settings.REDIS_QUERY_BACKOFF,
|
|
143
|
+
logger=logger,
|
|
144
|
+
)
|
|
145
|
+
async def wrapper(*args, **kwargs):
|
|
146
|
+
return await func(*args, **kwargs)
|
|
149
147
|
|
|
148
|
+
return wrapper
|
|
150
149
|
|
|
151
|
-
|
|
150
|
+
|
|
151
|
+
@redis_retry
|
|
152
|
+
async def _connect_redis_internal() -> Optional[aioredis.Redis]:
|
|
152
153
|
"""Return a Redis connection Redis or Redis Sentinel."""
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
)
|
|
154
|
+
if sentinel_settings.REDIS_SENTINEL_HOSTS:
|
|
155
|
+
sentinel_nodes = settings.get_sentinel_nodes()
|
|
156
|
+
sentinel = Sentinel(
|
|
157
|
+
sentinel_nodes,
|
|
158
|
+
decode_responses=settings.REDIS_DECODE_RESPONSES,
|
|
159
|
+
)
|
|
160
160
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
161
|
+
redis = sentinel.master_for(
|
|
162
|
+
service_name=settings.REDIS_SENTINEL_MASTER_NAME,
|
|
163
|
+
db=settings.REDIS_DB,
|
|
164
|
+
decode_responses=settings.REDIS_DECODE_RESPONSES,
|
|
165
|
+
retry_on_timeout=settings.REDIS_RETRY_TIMEOUT,
|
|
166
|
+
client_name=settings.REDIS_CLIENT_NAME,
|
|
167
|
+
max_connections=settings.REDIS_MAX_CONNECTIONS,
|
|
168
|
+
health_check_interval=settings.REDIS_HEALTH_CHECK_INTERVAL,
|
|
169
|
+
)
|
|
170
|
+
logger.info("Connected to Redis Sentinel")
|
|
171
|
+
|
|
172
|
+
elif settings.REDIS_HOST:
|
|
173
|
+
pool = aioredis.ConnectionPool(
|
|
174
|
+
host=settings.REDIS_HOST,
|
|
175
|
+
port=settings.REDIS_PORT,
|
|
176
|
+
db=settings.REDIS_DB,
|
|
177
|
+
max_connections=settings.REDIS_MAX_CONNECTIONS,
|
|
178
|
+
decode_responses=settings.REDIS_DECODE_RESPONSES,
|
|
179
|
+
retry_on_timeout=settings.REDIS_RETRY_TIMEOUT,
|
|
180
|
+
health_check_interval=settings.REDIS_HEALTH_CHECK_INTERVAL,
|
|
181
|
+
)
|
|
182
|
+
redis = aioredis.Redis(
|
|
183
|
+
connection_pool=pool, client_name=settings.REDIS_CLIENT_NAME
|
|
184
|
+
)
|
|
185
|
+
logger.info("Connected to Redis")
|
|
186
|
+
else:
|
|
187
|
+
logger.warning("No Redis configuration found")
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
return redis
|
|
189
191
|
|
|
190
|
-
return redis
|
|
191
192
|
|
|
193
|
+
async def connect_redis() -> Optional[aioredis.Redis]:
|
|
194
|
+
"""Handle Redis connection."""
|
|
195
|
+
try:
|
|
196
|
+
return await _connect_redis_internal()
|
|
197
|
+
except (
|
|
198
|
+
aioredis.ConnectionError,
|
|
199
|
+
aioredis.TimeoutError,
|
|
200
|
+
) as e:
|
|
201
|
+
logger.error(f"Redis connection failed after retries: {e}")
|
|
192
202
|
except aioredis.ConnectionError as e:
|
|
193
203
|
logger.error(f"Redis connection error: {e}")
|
|
194
204
|
return None
|
|
195
205
|
except aioredis.AuthenticationError as e:
|
|
196
206
|
logger.error(f"Redis authentication error: {e}")
|
|
197
207
|
return None
|
|
198
|
-
except aioredis.TimeoutError as e:
|
|
199
|
-
logger.error(f"Redis timeout error: {e}")
|
|
200
|
-
return None
|
|
201
208
|
except Exception as e:
|
|
202
209
|
logger.error(f"Failed to connect to Redis: {e}")
|
|
203
210
|
return None
|
|
211
|
+
return None
|
|
204
212
|
|
|
205
213
|
|
|
206
214
|
def get_redis_key(url: str, token: str) -> str:
|
|
@@ -230,19 +238,21 @@ def build_url_with_token(base_url: str, token: str) -> str:
|
|
|
230
238
|
)
|
|
231
239
|
|
|
232
240
|
|
|
241
|
+
@redis_retry
|
|
233
242
|
async def save_prev_link(
|
|
234
243
|
redis: aioredis.Redis, next_url: str, current_url: str, next_token: str
|
|
235
244
|
) -> None:
|
|
236
245
|
"""Save the current page as the previous link for the next URL."""
|
|
237
246
|
if next_url and next_token:
|
|
238
247
|
if sentinel_settings.REDIS_SENTINEL_HOSTS:
|
|
239
|
-
ttl_seconds =
|
|
240
|
-
elif
|
|
241
|
-
ttl_seconds =
|
|
248
|
+
ttl_seconds = settings.REDIS_SELF_LINK_TTL
|
|
249
|
+
elif settings.REDIS_HOST:
|
|
250
|
+
ttl_seconds = settings.REDIS_SELF_LINK_TTL
|
|
242
251
|
key = get_redis_key(next_url, next_token)
|
|
243
252
|
await redis.setex(key, ttl_seconds, current_url)
|
|
244
253
|
|
|
245
254
|
|
|
255
|
+
@redis_retry
|
|
246
256
|
async def get_prev_link(
|
|
247
257
|
redis: aioredis.Redis, current_url: str, current_token: str
|
|
248
258
|
) -> Optional[str]:
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""library version."""
|
|
2
|
-
__version__ = "6.7.
|
|
2
|
+
__version__ = "6.7.6"
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
"""Utility functions to handle datetime parsing."""
|
|
2
|
-
|
|
3
|
-
from datetime import datetime, timezone
|
|
4
|
-
|
|
5
|
-
from stac_fastapi.types.rfc3339 import rfc3339_str_to_datetime
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def format_datetime_range(date_str: str) -> str:
|
|
9
|
-
"""
|
|
10
|
-
Convert a datetime range string into a normalized UTC string for API requests using rfc3339_str_to_datetime.
|
|
11
|
-
|
|
12
|
-
Args:
|
|
13
|
-
date_str (str): A string containing two datetime values separated by a '/'.
|
|
14
|
-
|
|
15
|
-
Returns:
|
|
16
|
-
str: A string formatted as 'YYYY-MM-DDTHH:MM:SSZ/YYYY-MM-DDTHH:MM:SSZ', with '..' used if any element is None.
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
def normalize(dt):
|
|
20
|
-
"""Normalize datetime string and preserve millisecond precision."""
|
|
21
|
-
dt = dt.strip()
|
|
22
|
-
if not dt or dt == "..":
|
|
23
|
-
return ".."
|
|
24
|
-
dt_obj = rfc3339_str_to_datetime(dt)
|
|
25
|
-
dt_utc = dt_obj.astimezone(timezone.utc)
|
|
26
|
-
return dt_utc.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
27
|
-
|
|
28
|
-
if not isinstance(date_str, str):
|
|
29
|
-
return "../.."
|
|
30
|
-
|
|
31
|
-
if "/" not in date_str:
|
|
32
|
-
return f"{normalize(date_str)}/{normalize(date_str)}"
|
|
33
|
-
|
|
34
|
-
try:
|
|
35
|
-
start, end = date_str.split("/", 1)
|
|
36
|
-
except Exception:
|
|
37
|
-
return "../.."
|
|
38
|
-
return f"{normalize(start)}/{normalize(end)}"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
# Borrowed from pystac - https://github.com/stac-utils/pystac/blob/f5e4cf4a29b62e9ef675d4a4dac7977b09f53c8f/pystac/utils.py#L370-L394
|
|
42
|
-
def datetime_to_str(dt: datetime, timespec: str = "auto") -> str:
|
|
43
|
-
"""Convert a :class:`datetime.datetime` instance to an ISO8601 string in the `RFC 3339, section 5.6.
|
|
44
|
-
|
|
45
|
-
<https://datatracker.ietf.org/doc/html/rfc3339#section-5.6>`__ format required by
|
|
46
|
-
the :stac-spec:`STAC Spec <master/item-spec/common-metadata.md#date-and-time>`.
|
|
47
|
-
|
|
48
|
-
Args:
|
|
49
|
-
dt : The datetime to convert.
|
|
50
|
-
timespec: An optional argument that specifies the number of additional
|
|
51
|
-
terms of the time to include. Valid options are 'auto', 'hours',
|
|
52
|
-
'minutes', 'seconds', 'milliseconds' and 'microseconds'. The default value
|
|
53
|
-
is 'auto'.
|
|
54
|
-
Returns:
|
|
55
|
-
str: The ISO8601 (RFC 3339) formatted string representing the datetime.
|
|
56
|
-
"""
|
|
57
|
-
if dt.tzinfo is None:
|
|
58
|
-
dt = dt.replace(tzinfo=timezone.utc)
|
|
59
|
-
|
|
60
|
-
timestamp = dt.isoformat(timespec=timespec)
|
|
61
|
-
zulu = "+00:00"
|
|
62
|
-
if timestamp.endswith(zulu):
|
|
63
|
-
timestamp = f"{timestamp[: -len(zulu)]}Z"
|
|
64
|
-
|
|
65
|
-
return timestamp
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def now_in_utc() -> datetime:
|
|
69
|
-
"""Return a datetime value of now with the UTC timezone applied."""
|
|
70
|
-
return datetime.now(timezone.utc)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def now_to_rfc3339_str() -> str:
|
|
74
|
-
"""Return an RFC 3339 string representing now."""
|
|
75
|
-
return datetime_to_str(now_in_utc())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/base_database_logic.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/extensions/__init__.py
RENAMED
|
File without changes
|
{stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/extensions/aggregation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|