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.
Files changed (28) hide show
  1. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/PKG-INFO +8 -8
  2. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/pyproject.toml +10 -7
  3. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/core.py +6 -2
  4. stac_fastapi_core-6.7.6/stac_fastapi/core/datetime_utils.py +123 -0
  5. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/redis_utils.py +97 -87
  6. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/version.py +1 -1
  7. stac_fastapi_core-6.7.4/stac_fastapi/core/datetime_utils.py +0 -75
  8. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/.gitignore +0 -0
  9. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/README.md +0 -0
  10. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/pytest.ini +0 -0
  11. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/__init__.py +0 -0
  12. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/base_database_logic.py +0 -0
  13. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/base_settings.py +0 -0
  14. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/basic_auth.py +0 -0
  15. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/extensions/__init__.py +0 -0
  16. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/extensions/aggregation.py +0 -0
  17. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/extensions/collections_search.py +0 -0
  18. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/extensions/fields.py +0 -0
  19. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/extensions/filter.py +0 -0
  20. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/extensions/query.py +0 -0
  21. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/models/__init__.py +0 -0
  22. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/models/links.py +0 -0
  23. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/models/search.py +0 -0
  24. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/rate_limit.py +0 -0
  25. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/route_dependencies.py +0 -0
  26. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/serializers.py +0 -0
  27. {stac_fastapi_core-6.7.4 → stac_fastapi_core-6.7.6}/stac_fastapi/core/session.py +0 -0
  28. {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.4
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.9
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.0.0
30
- Requires-Dist: stac-fastapi-extensions==6.0.0
31
- Requires-Dist: stac-fastapi-types==6.0.0
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"
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.0.0",
39
- "stac-fastapi.api==6.0.0",
40
- "stac-fastapi.extensions==6.0.0",
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
- "redis==6.4.0",
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=search_request.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 typing import List, Optional, Tuple
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 RedisSentinelSettings(BaseSettings):
17
- """Configuration for connecting to Redis Sentinel."""
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 validate_db_sentinel(cls, v: int) -> int:
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 validate_self_link_ttl_sentinel(cls, v: int) -> int:
50
- """Validate REDIS_SELF_LINK_TTL is not a negative integer."""
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(BaseSettings):
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
- standalone_settings = RedisSettings()
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
- async def connect_redis() -> Optional[aioredis.Redis]:
150
+
151
+ @redis_retry
152
+ async def _connect_redis_internal() -> Optional[aioredis.Redis]:
152
153
  """Return a Redis connection Redis or Redis Sentinel."""
153
- try:
154
- if sentinel_settings.REDIS_SENTINEL_HOSTS:
155
- sentinel_nodes = sentinel_settings.get_sentinel_nodes()
156
- sentinel = Sentinel(
157
- sentinel_nodes,
158
- decode_responses=sentinel_settings.REDIS_DECODE_RESPONSES,
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
- redis = sentinel.master_for(
162
- service_name=sentinel_settings.REDIS_SENTINEL_MASTER_NAME,
163
- db=sentinel_settings.REDIS_DB,
164
- decode_responses=sentinel_settings.REDIS_DECODE_RESPONSES,
165
- retry_on_timeout=sentinel_settings.REDIS_RETRY_TIMEOUT,
166
- client_name=sentinel_settings.REDIS_CLIENT_NAME,
167
- max_connections=sentinel_settings.REDIS_MAX_CONNECTIONS,
168
- health_check_interval=sentinel_settings.REDIS_HEALTH_CHECK_INTERVAL,
169
- )
170
- logger.info("Connected to Redis Sentinel")
171
-
172
- elif standalone_settings.REDIS_HOST:
173
- pool = aioredis.ConnectionPool(
174
- host=standalone_settings.REDIS_HOST,
175
- port=standalone_settings.REDIS_PORT,
176
- db=standalone_settings.REDIS_DB,
177
- max_connections=standalone_settings.REDIS_MAX_CONNECTIONS,
178
- decode_responses=standalone_settings.REDIS_DECODE_RESPONSES,
179
- retry_on_timeout=standalone_settings.REDIS_RETRY_TIMEOUT,
180
- health_check_interval=standalone_settings.REDIS_HEALTH_CHECK_INTERVAL,
181
- )
182
- redis = aioredis.Redis(
183
- connection_pool=pool, client_name=standalone_settings.REDIS_CLIENT_NAME
184
- )
185
- logger.info("Connected to Redis")
186
- else:
187
- logger.warning("No Redis configuration found")
188
- return None
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 = sentinel_settings.REDIS_SELF_LINK_TTL
240
- elif standalone_settings.REDIS_HOST:
241
- ttl_seconds = standalone_settings.REDIS_SELF_LINK_TTL
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.4"
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())