Skip to content

Commit 86fe0ac

Browse files
refactor: Iran holiday TTL caching, callable TTL on ttl_cache_decorator, and ty/grpc cleanups
1 parent eb8d6ff commit 86fe0ac

11 files changed

Lines changed: 475 additions & 468 deletions

File tree

archipy/adapters/email/adapters.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import logging
33
import os
44
import smtplib
5-
from collections.abc import Callable
65
from datetime import datetime
76
from email import encoders
87
from email.mime.audio import MIMEAudio
@@ -12,7 +11,7 @@
1211
from email.mime.text import MIMEText
1312
from pathlib import Path
1413
from queue import Queue
15-
from typing import Any, BinaryIO, cast, override
14+
from typing import BinaryIO, override
1615

1716
import requests
1817
from jinja2 import Template
@@ -162,8 +161,7 @@ def _process_source(source: str | bytes | BinaryIO | HttpUrl, attachment_type: E
162161
if hasattr(source, "read"):
163162
read_method = source.read
164163
if callable(read_method):
165-
read_callable = cast("Callable[[], Any]", read_method)
166-
result = read_callable()
164+
result = read_method() # ty: ignore[call-top-callable]
167165
if isinstance(result, bytes):
168166
return result
169167
if isinstance(result, str):

archipy/helpers/decorators/cache.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,23 +110,25 @@ def clear_cache(self) -> None:
110110

111111

112112
def ttl_cache_decorator[**P, R](
113-
ttl_seconds: int = 300,
113+
ttl_seconds: int | Callable[[], int] = 300,
114114
maxsize: int = 100,
115-
) -> Callable[[Callable[P, R]], CachedFunction[P, R]]:
115+
) -> Any:
116116
"""Decorator that provides a TTL cache for functions and methods.
117117
118118
The cache is shared across all instances when decorating instance methods.
119119
This is by design to allow efficient caching of expensive operations that
120120
depend only on the method arguments, not the instance state.
121121
122122
Args:
123-
ttl_seconds: Time to live in seconds (default: 5 minutes).
123+
ttl_seconds: Time to live in seconds (default: 5 minutes), or a zero-argument
124+
callable that returns the TTL (evaluated once when the decorator is applied).
124125
After this time, cached entries expire and the function is re-executed.
125126
maxsize: Maximum size of the cache (default: 100).
126127
When the cache is full, the least recently used entry is evicted.
127128
128129
Returns:
129-
Decorated function with TTL caching and a clear_cache() method.
130+
Decorated function with TTL caching and a clear_cache() method (typed as :class:`~typing.Any`
131+
at compile time so ParamSpec-style decorator typing stays sound).
130132
131133
Example:
132134
```python
@@ -157,9 +159,13 @@ def fetch_data(self, key: str) -> dict:
157159
"""
158160
from cachetools import TTLCache
159161

160-
cache: TTLCache = TTLCache(maxsize=maxsize, ttl=ttl_seconds)
162+
if isinstance(ttl_seconds, int):
163+
resolved_ttl = int(ttl_seconds)
164+
else:
165+
resolved_ttl = int(ttl_seconds())
166+
cache: TTLCache = TTLCache(maxsize=maxsize, ttl=resolved_ttl)
161167

162168
def decorator(func: Callable[P, R]) -> CachedFunction[P, R]:
163169
return CachedFunction(func, cache, instance=None)
164170

165-
return decorator # type: ignore[return-value]
171+
return decorator

archipy/helpers/interceptors/grpc/metric/server_interceptor.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import asyncio
22
import time
33
from collections.abc import Callable
4-
from typing import Any, ClassVar, cast
4+
from typing import ClassVar
55

66
import grpc
77

@@ -90,8 +90,8 @@ def intercept(
9090
result = method(request, context)
9191

9292
if hasattr(context, "code") and callable(context.code):
93-
code_method = cast("Callable[[], Any]", context.code)
94-
code_obj = code_method()
93+
code_method = context.code
94+
code_obj = code_method() # ty: ignore[call-top-callable]
9595
if code_obj is not None:
9696
code_name = getattr(code_obj, "name", None)
9797
if code_name is not None:
@@ -198,8 +198,8 @@ async def intercept(
198198
if code_name is not None:
199199
status_code = code_name
200200
elif hasattr(e, "code") and callable(e.code):
201-
code_method = cast("Callable[[], Any]", e.code)
202-
code_obj = code_method()
201+
code_method = e.code
202+
code_obj = code_method() # ty: ignore[call-top-callable]
203203
if code_obj is not None:
204204
code_name = getattr(code_obj, "name", None)
205205
if code_name is not None:

archipy/helpers/utils/datetime_utils.py

Lines changed: 44 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,29 @@
11
import time
22
from collections.abc import Generator
33
from datetime import UTC, date, datetime, timedelta
4-
from typing import Any, ClassVar
4+
from typing import Any
55

66
import jdatetime
77
import requests
88
from requests.adapters import HTTPAdapter
99
from urllib3.util.retry import Retry
1010

1111
from archipy.configs.base_config import BaseConfig
12+
from archipy.helpers.decorators.cache import ttl_cache_decorator
1213
from archipy.models.errors import UnknownError
1314

15+
_HOLIDAY_CACHE_MAXSIZE = 2048
16+
17+
18+
def _iran_holiday_historical_cache_ttl_seconds() -> int:
19+
"""Return DATETIME.HISTORICAL_CACHE_TTL from the global config (evaluated at decorate time)."""
20+
return BaseConfig.global_config().DATETIME.HISTORICAL_CACHE_TTL
21+
22+
23+
def _iran_holiday_standard_cache_ttl_seconds() -> int:
24+
"""Return DATETIME.CACHE_TTL from the global config (evaluated at decorate time)."""
25+
return BaseConfig.global_config().DATETIME.CACHE_TTL
26+
1427

1528
class DatetimeUtils:
1629
"""A utility class for handling date and time operations, including conversions, caching, and API integrations.
@@ -19,9 +32,6 @@ class DatetimeUtils:
1932
utility functions for timezone-aware datetime objects, date ranges, and string formatting.
2033
"""
2134

22-
"""A class-level cache for storing holiday statuses to avoid redundant API calls."""
23-
_holiday_cache: ClassVar[dict[str, tuple[bool, datetime]]] = {}
24-
2535
@staticmethod
2636
def convert_to_jalali(target_date: date) -> jdatetime.date:
2737
"""Converts a Gregorian date to a Jalali (Persian) date.
@@ -46,83 +56,42 @@ def is_holiday_in_iran(cls, target_date: date) -> bool:
4656
Returns:
4757
bool: True if the date is a holiday, False otherwise.
4858
"""
49-
# Convert to Jalali date first
50-
jalali_date = cls.convert_to_jalali(target_date)
5159
date_str = target_date.strftime("%Y-%m-%d")
52-
current_time = cls.get_datetime_utc_now()
53-
54-
# Check cache first
55-
is_cached, is_holiday = cls._check_cache(date_str, current_time)
56-
if is_cached:
57-
return is_holiday
58-
59-
# Fetch holiday status and cache it
60-
return cls._fetch_and_cache_holiday_status(jalali_date, date_str, current_time)
61-
62-
@classmethod
63-
def _check_cache(cls, date_str: str, current_time: datetime) -> tuple[bool, bool]:
64-
"""Checks the cache for holiday status to avoid redundant API calls.
65-
66-
Args:
67-
date_str (str): The date string to check in the cache.
68-
current_time (datetime): The current time to compare against cache expiration.
69-
70-
Returns:
71-
tuple[bool, bool]: A tuple where the first element indicates if the cache was hit,
72-
and the second element is the cached holiday status.
73-
"""
74-
cached_data = cls._holiday_cache.get(date_str)
75-
if cached_data:
76-
is_holiday, expiry_time = cached_data
77-
if current_time < expiry_time:
78-
return True, is_holiday
79-
80-
# Remove expired cache entry
81-
del cls._holiday_cache[date_str]
82-
83-
return False, False
60+
utc_today = cls.get_datetime_utc_now().date()
61+
is_historical = target_date <= utc_today
62+
if is_historical:
63+
return cls._fetch_holiday_in_iran_historical(date_str)
64+
return cls._fetch_holiday_in_iran_standard(date_str)
8465

85-
@classmethod
86-
def _fetch_and_cache_holiday_status(
87-
cls,
88-
jalali_date: jdatetime.date,
89-
date_str: str,
90-
current_time: datetime,
91-
) -> bool:
92-
"""Fetches holiday status from the API and caches the result.
93-
94-
This method calls an external API to determine if the given Jalali date is a holiday.
95-
If the API call is successful, the result is cached with an expiration time to avoid
96-
redundant API calls. If the API call fails, an `UnknownError` is raised.
97-
98-
Args:
99-
jalali_date (jdatetime.date): The Jalali date to check for holiday status.
100-
date_str (str): The date string to use as a cache key.
101-
current_time (datetime): The current time to set cache expiration.
102-
103-
Returns:
104-
bool: True if the date is a holiday, False otherwise.
105-
106-
Raises:
107-
UnknownError: If the API request fails due to a network issue or other request-related errors.
108-
"""
66+
@staticmethod
67+
@ttl_cache_decorator(
68+
ttl_seconds=_iran_holiday_historical_cache_ttl_seconds,
69+
maxsize=_HOLIDAY_CACHE_MAXSIZE,
70+
)
71+
def _fetch_holiday_in_iran_historical(date_str: str) -> bool:
72+
"""Resolve holiday flag for a date on or before UTC today; cached with historical TTL."""
73+
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
74+
jalali_date = DatetimeUtils.convert_to_jalali(target_date)
10975
try:
110-
config: Any = BaseConfig.global_config()
111-
response = cls._call_holiday_api(jalali_date)
112-
is_holiday = cls._parse_holiday_response(response, jalali_date)
113-
114-
# Determine cache TTL based on whether the date is historical
115-
target_date = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC).date()
116-
is_historical = target_date <= current_time.date()
117-
cache_ttl = config.DATETIME.HISTORICAL_CACHE_TTL if is_historical else config.DATETIME.CACHE_TTL
118-
119-
# Cache the result with appropriate expiration
120-
expiry_time = current_time + timedelta(seconds=cache_ttl)
121-
cls._holiday_cache[date_str] = (is_holiday, expiry_time)
76+
response = DatetimeUtils._call_holiday_api(jalali_date)
12277
except requests.RequestException as exception:
12378
raise UnknownError from exception
79+
return DatetimeUtils._parse_holiday_response(response, jalali_date)
12480

125-
return is_holiday
81+
@staticmethod
82+
@ttl_cache_decorator(
83+
ttl_seconds=_iran_holiday_standard_cache_ttl_seconds,
84+
maxsize=_HOLIDAY_CACHE_MAXSIZE,
85+
)
86+
def _fetch_holiday_in_iran_standard(date_str: str) -> bool:
87+
"""Resolve holiday flag for a strictly future calendar date; cached with standard TTL."""
88+
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
89+
jalali_date = DatetimeUtils.convert_to_jalali(target_date)
90+
try:
91+
response = DatetimeUtils._call_holiday_api(jalali_date)
92+
except requests.RequestException as exception:
93+
raise UnknownError from exception
94+
return DatetimeUtils._parse_holiday_response(response, jalali_date)
12695

12796
@staticmethod
12897
def _call_holiday_api(jalali_date: jdatetime.date) -> dict[str, Any]:

archipy/helpers/utils/keycloak_utils.py

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33
from collections.abc import Callable
44
from contextvars import ContextVar
5-
from typing import TYPE_CHECKING, Any, cast
5+
from typing import TYPE_CHECKING, Any
66

77
if TYPE_CHECKING:
88
from grpc import ServicerContext
@@ -47,6 +47,24 @@
4747
logger = logging.getLogger(__name__)
4848

4949

50+
def _abort_grpc_sync_if_servicer_context(error: BaseError, context: object) -> None:
51+
"""Invoke ``error.abort_grpc_sync`` when ``context`` is a sync gRPC servicer context."""
52+
if not GRPC_AVAILABLE or not hasattr(error, "abort_grpc_sync"):
53+
return
54+
if isinstance(context, ServicerContext):
55+
servicer_ctx = context
56+
error.abort_grpc_sync(servicer_ctx) # ty: ignore[invalid-argument-type]
57+
58+
59+
async def _abort_grpc_async_if_servicer_context(error: BaseError, context: object) -> None:
60+
"""Invoke ``error.abort_grpc_async`` when ``context`` is an async gRPC servicer context."""
61+
if not GRPC_AVAILABLE or not hasattr(error, "abort_grpc_async"):
62+
return
63+
if isinstance(context, AsyncServicerContext):
64+
aio_ctx = context
65+
await error.abort_grpc_async(aio_ctx) # ty: ignore[invalid-argument-type]
66+
67+
5068
class AuthContext(BaseModel):
5169
"""Authentication context passed to business logic."""
5270

@@ -312,10 +330,10 @@ async def dependency(
312330
@staticmethod
313331
def _extract_token_from_metadata(context: object) -> str | None:
314332
"""Extract Bearer token from gRPC metadata."""
315-
if not hasattr(context, "invocation_metadata") or not callable(context.invocation_metadata):
333+
get_metadata = getattr(context, "invocation_metadata", None)
334+
if get_metadata is None or not callable(get_metadata):
316335
return None
317-
invocation_metadata_method = cast("Callable[[], Any]", context.invocation_metadata)
318-
invocation_metadata_result = invocation_metadata_method()
336+
invocation_metadata_result = get_metadata()
319337
if invocation_metadata_result is None:
320338
return None
321339
# Convert metadata tuples to dict, handling both str and bytes keys
@@ -478,10 +496,8 @@ def wrapper(self: object, request: object, context: object) -> object:
478496
return func(self, request, context)
479497

480498
except Exception as e:
481-
if isinstance(e, BaseError) and hasattr(e, "abort_grpc_sync") and GRPC_AVAILABLE:
482-
# Only call abort if context is actually a ServicerContext
483-
if hasattr(context, "abort"):
484-
e.abort_grpc_sync(context) # type: ignore[arg-type]
499+
if isinstance(e, BaseError):
500+
_abort_grpc_sync_if_servicer_context(e, context)
485501
raise InternalError(
486502
lang=lang,
487503
additional_data={"original_error": str(e), "error_type": type(e).__name__},
@@ -623,19 +639,18 @@ async def wrapper(self: object, request: object, context: object) -> object:
623639
return await func(self, request, context)
624640

625641
except Exception as e:
626-
if context is None:
642+
grpc_ctx = context
643+
if grpc_ctx is None:
627644
raise
628-
if isinstance(e, BaseError) and GRPC_AVAILABLE:
629-
if isinstance(context, AsyncServicerContext):
630-
await e.abort_grpc_async(context) # type: ignore[arg-type]
631-
return None # abort_grpc_async will terminate, but satisfy type checker
632-
if GRPC_AVAILABLE and isinstance(context, AsyncServicerContext):
633-
# False positive: isinstance narrows type at runtime
645+
if isinstance(e, BaseError) and GRPC_AVAILABLE and isinstance(grpc_ctx, AsyncServicerContext):
646+
await _abort_grpc_async_if_servicer_context(e, grpc_ctx)
647+
return None # abort_grpc_async will terminate, but satisfy type checker
648+
if GRPC_AVAILABLE and isinstance(grpc_ctx, AsyncServicerContext):
634649
error_instance = InternalError(
635650
lang=lang,
636651
additional_data={"original_error": str(e), "error_type": type(e).__name__},
637652
)
638-
await error_instance.abort_grpc_async(context) # type: ignore[arg-type]
653+
await _abort_grpc_async_if_servicer_context(error_instance, grpc_ctx)
639654
return None # abort_grpc_async will terminate, but satisfy type checker
640655
raise
641656

archipy/helpers/utils/string_utils.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -323,11 +323,13 @@ def replace_numbers_with_mask(cls, text: str, mask: str | None = None) -> str:
323323
str: The text with numbers masked.
324324
"""
325325
mask = mask or "MASK_NUMBERS"
326-
numbers = re.findall("[0-9]+", text)
327-
result: str = text
328-
for number in sorted(numbers, key=len, reverse=True):
329-
result = result.replace(number, f" {mask} ") # type: ignore[arg-type]
330-
return result
326+
work_text = str(text)
327+
numbers: list[str] = re.findall("[0-9]+", work_text)
328+
replacement = f" {mask} "
329+
for raw_number in sorted(numbers, key=len, reverse=True):
330+
number = str(raw_number)
331+
work_text = re.sub(re.escape(number), replacement, work_text)
332+
return work_text
331333

332334
@classmethod
333335
def is_string_none_or_empty(cls, text: str) -> bool:

archipy/models/dtos/range_dtos.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def validate_range(self) -> Self:
4545
# Use comparison with proper type handling
4646
# The protocol ensures both values support comparison
4747
try:
48-
if self.from_ > self.to: # type: ignore[operator]
48+
if self.from_ > self.to: # ty: ignore[unsupported-operator]
4949
raise OutOfRangeError(field_name="from_")
5050
except TypeError:
5151
# If comparison fails, skip validation (shouldn't happen with proper types)

features/datetime_utils.feature

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Feature: Datetime Utilities
7474
Then the result should be cached with historical TTL
7575

7676
Scenario: Verify current dates use standard cache TTL
77-
# Test that current/future dates get cached with standard CACHE_TTL
78-
Given a current Gregorian date "2026-03-21"
77+
# Test that strictly future calendar dates get standard CACHE_TTL (today/past use historical TTL).
78+
Given a Gregorian date strictly after today
7979
When we check if the date is a holiday in Iran with cache verification
8080
Then the result should be cached with standard TTL

0 commit comments

Comments
 (0)