financechatbotkit 2.0.0__py3-none-any.whl
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.
- financechatbotkit-2.0.0.dist-info/METADATA +11 -0
- financechatbotkit-2.0.0.dist-info/RECORD +39 -0
- financechatbotkit-2.0.0.dist-info/WHEEL +5 -0
- financechatbotkit-2.0.0.dist-info/entry_points.txt +2 -0
- financechatbotkit-2.0.0.dist-info/top_level.txt +2 -0
- orchestrator/__init__.py +29 -0
- orchestrator/bond/__init__.py +8 -0
- orchestrator/bond/base_reader.py +139 -0
- orchestrator/bond/getBondBasiInfo.py +84 -0
- orchestrator/bond/getBondWithOptiCallRede.py +83 -0
- orchestrator/bond/getEarlExerOpti.py +90 -0
- orchestrator/bond/getIssuIssuItemStat.py +85 -0
- orchestrator/bond/getOptiExer.py +83 -0
- orchestrator/bond/getOptiExerPricAdju.py +84 -0
- orchestrator/bond/workflow.py +252 -0
- orchestrator/exceptions.py +17 -0
- orchestrator/fnguide/__init__.py +21 -0
- orchestrator/fnguide/workflow.py +391 -0
- orchestrator/mapping/__init__.py +22 -0
- orchestrator/mapping/data/__init__.py +1 -0
- orchestrator/mapping/data/corp_codes_raw.json +693170 -0
- orchestrator/mapping/update_raw_data.py +96 -0
- orchestrator/mapping/workflow.py +303 -0
- orchestrator/price/__init__.py +15 -0
- orchestrator/price/workflow.py +250 -0
- telebotkit/__init__.py +51 -0
- telebotkit/bot/__init__.py +38 -0
- telebotkit/bot/client.py +217 -0
- telebotkit/bot/reply.py +36 -0
- telebotkit/bot/router.py +125 -0
- telebotkit/bot/safety.py +28 -0
- telebotkit/bot/telegram.py +41 -0
- telebotkit/firestore/__init__.py +45 -0
- telebotkit/firestore/client.py +141 -0
- telebotkit/firestore/documents.py +164 -0
- telebotkit/firestore/fetch.py +228 -0
- telebotkit/firestore/locks.py +74 -0
- telebotkit/firestore/upload.py +75 -0
- telebotkit/sheets.py +219 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Shared Firestore client bootstrap and error helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
_SERVICE_ACCOUNT_BASE_FIELDS = {
|
|
13
|
+
"type": "service_account",
|
|
14
|
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
15
|
+
"token_uri": "https://oauth2.googleapis.com/token",
|
|
16
|
+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FirestoreQuotaError(Exception):
|
|
21
|
+
"""Raised when a Firestore operation fails due to quota or write limits."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def raise_if_quota_error(error: Exception) -> None:
|
|
25
|
+
"""Re-raise quota-related Google API exceptions in a stable app-level form."""
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from google.api_core import exceptions as google_api_exceptions # type: ignore
|
|
29
|
+
|
|
30
|
+
if isinstance(error, google_api_exceptions.ResourceExhausted):
|
|
31
|
+
raise FirestoreQuotaError(str(error)) from error
|
|
32
|
+
except ImportError:
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_service_account_info_from_env() -> dict[str, Any] | None:
|
|
37
|
+
"""Construct service-account credentials from discrete GOOGLE_* env vars."""
|
|
38
|
+
|
|
39
|
+
client_email = os.environ.get("GOOGLE_CLIENT_EMAIL", "").strip()
|
|
40
|
+
if not client_email:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
private_key = os.environ.get("GOOGLE_PRIVATE_KEY", "").strip()
|
|
44
|
+
if "\\n" in private_key and "\n" not in private_key:
|
|
45
|
+
private_key = private_key.replace("\\n", "\n")
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
**_SERVICE_ACCOUNT_BASE_FIELDS,
|
|
49
|
+
"project_id": os.environ.get("GOOGLE_PROJECT_ID", "").strip(),
|
|
50
|
+
"private_key_id": os.environ.get("GOOGLE_PRIVATE_KEY_ID", "").strip(),
|
|
51
|
+
"private_key": private_key,
|
|
52
|
+
"client_email": client_email,
|
|
53
|
+
"client_id": os.environ.get("GOOGLE_CLIENT_ID", "").strip(),
|
|
54
|
+
"client_x509_cert_url": os.environ.get("GOOGLE_CLIENT_X509_CERT_URL", "").strip(),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FirestoreClientProvider:
|
|
59
|
+
"""Lazily create and cache a Firestore client."""
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self._client = None
|
|
63
|
+
|
|
64
|
+
def reset(self) -> None:
|
|
65
|
+
self._client = None
|
|
66
|
+
|
|
67
|
+
def get_client(self):
|
|
68
|
+
if self._client is None:
|
|
69
|
+
project_id = (
|
|
70
|
+
os.environ.get("GOOGLE_PROJECT_ID")
|
|
71
|
+
or os.environ.get("FIRESTORE_PROJECT_ID")
|
|
72
|
+
or None
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
from google.cloud import firestore # type: ignore
|
|
76
|
+
|
|
77
|
+
service_account_info = load_service_account_info_from_env()
|
|
78
|
+
if service_account_info:
|
|
79
|
+
from google.oauth2 import service_account # type: ignore
|
|
80
|
+
|
|
81
|
+
credentials = service_account.Credentials.from_service_account_info(
|
|
82
|
+
service_account_info,
|
|
83
|
+
scopes=["https://www.googleapis.com/auth/datastore"],
|
|
84
|
+
)
|
|
85
|
+
self._client = firestore.Client(
|
|
86
|
+
project=project_id or service_account_info.get("project_id") or None,
|
|
87
|
+
credentials=credentials,
|
|
88
|
+
)
|
|
89
|
+
logger.info("Firestore client created from individual GOOGLE_* env vars.")
|
|
90
|
+
else:
|
|
91
|
+
credentials_json = os.environ.get(
|
|
92
|
+
"GOOGLE_APPLICATION_CREDENTIALS_JSON",
|
|
93
|
+
"",
|
|
94
|
+
).strip()
|
|
95
|
+
if credentials_json:
|
|
96
|
+
from google.oauth2 import service_account # type: ignore
|
|
97
|
+
|
|
98
|
+
info = json.loads(credentials_json)
|
|
99
|
+
credentials = service_account.Credentials.from_service_account_info(
|
|
100
|
+
info,
|
|
101
|
+
scopes=["https://www.googleapis.com/auth/datastore"],
|
|
102
|
+
)
|
|
103
|
+
self._client = firestore.Client(
|
|
104
|
+
project=project_id,
|
|
105
|
+
credentials=credentials,
|
|
106
|
+
)
|
|
107
|
+
logger.info(
|
|
108
|
+
"Firestore client created from GOOGLE_APPLICATION_CREDENTIALS_JSON."
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
self._client = firestore.Client(project=project_id)
|
|
112
|
+
logger.info(
|
|
113
|
+
"Firestore client created from Application Default Credentials."
|
|
114
|
+
)
|
|
115
|
+
return self._client
|
|
116
|
+
|
|
117
|
+
FirestoreClientFactory = FirestoreClientProvider
|
|
118
|
+
|
|
119
|
+
_DEFAULT_CLIENT_PROVIDER = FirestoreClientProvider()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_client():
|
|
123
|
+
"""Return the shared cached Firestore client."""
|
|
124
|
+
|
|
125
|
+
return _DEFAULT_CLIENT_PROVIDER.get_client()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def reset_client() -> None:
|
|
129
|
+
"""Reset the shared cached Firestore client."""
|
|
130
|
+
|
|
131
|
+
_DEFAULT_CLIENT_PROVIDER.reset()
|
|
132
|
+
|
|
133
|
+
__all__ = [
|
|
134
|
+
"FirestoreClientFactory",
|
|
135
|
+
"FirestoreClientProvider",
|
|
136
|
+
"FirestoreQuotaError",
|
|
137
|
+
"get_client",
|
|
138
|
+
"load_service_account_info_from_env",
|
|
139
|
+
"raise_if_quota_error",
|
|
140
|
+
"reset_client",
|
|
141
|
+
]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Typed Firestore repository helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
from telebotkit.firestore.client import get_client
|
|
9
|
+
from telebotkit.firestore.fetch import FetchResult, fetch_document, invalidate_document_cache
|
|
10
|
+
|
|
11
|
+
DocumentT = TypeVar("DocumentT")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DocumentStore(Generic[DocumentT]):
|
|
15
|
+
"""Typed access to a Firestore collection with explicit document IDs."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
collection_name: str,
|
|
21
|
+
from_dict: Callable[[dict[str, Any]], DocumentT],
|
|
22
|
+
to_dict: Callable[[DocumentT], dict[str, Any]],
|
|
23
|
+
client_provider: Callable[[], Any] = get_client,
|
|
24
|
+
cache_ttl_seconds: float | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._collection_name = collection_name
|
|
27
|
+
self._from_dict = from_dict
|
|
28
|
+
self._to_dict = to_dict
|
|
29
|
+
self._client_provider = client_provider
|
|
30
|
+
self._cache_ttl_seconds = cache_ttl_seconds
|
|
31
|
+
|
|
32
|
+
def get_result(
|
|
33
|
+
self,
|
|
34
|
+
document_id: str,
|
|
35
|
+
*,
|
|
36
|
+
force_refresh: bool = False,
|
|
37
|
+
) -> FetchResult[DocumentT]:
|
|
38
|
+
return fetch_document(
|
|
39
|
+
self._collection_name,
|
|
40
|
+
document_id,
|
|
41
|
+
parse=self._from_dict,
|
|
42
|
+
client_provider=self._client_provider,
|
|
43
|
+
cache_ttl_seconds=self._cache_ttl_seconds,
|
|
44
|
+
force_refresh=force_refresh,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def get(
|
|
48
|
+
self,
|
|
49
|
+
document_id: str,
|
|
50
|
+
*,
|
|
51
|
+
force_refresh: bool = False,
|
|
52
|
+
) -> DocumentT | None:
|
|
53
|
+
result = self.get_result(document_id, force_refresh=force_refresh)
|
|
54
|
+
if result.error is not None:
|
|
55
|
+
raise result.error
|
|
56
|
+
return result.data
|
|
57
|
+
|
|
58
|
+
def get_or_create(
|
|
59
|
+
self,
|
|
60
|
+
document_id: str,
|
|
61
|
+
factory: Callable[[], DocumentT],
|
|
62
|
+
) -> DocumentT:
|
|
63
|
+
document = self.get(document_id)
|
|
64
|
+
return document if document is not None else factory()
|
|
65
|
+
|
|
66
|
+
def save(
|
|
67
|
+
self,
|
|
68
|
+
document_id: str,
|
|
69
|
+
value: DocumentT,
|
|
70
|
+
*,
|
|
71
|
+
merge: bool = False,
|
|
72
|
+
) -> None:
|
|
73
|
+
self._collection().document(document_id).set(self._to_dict(value), merge=merge)
|
|
74
|
+
invalidate_document_cache(self._collection_name, document_id)
|
|
75
|
+
|
|
76
|
+
def update(
|
|
77
|
+
self,
|
|
78
|
+
document_id: str,
|
|
79
|
+
fields: dict[str, Any],
|
|
80
|
+
*,
|
|
81
|
+
merge: bool = True,
|
|
82
|
+
) -> None:
|
|
83
|
+
self._collection().document(document_id).set(fields, merge=merge)
|
|
84
|
+
invalidate_document_cache(self._collection_name, document_id)
|
|
85
|
+
|
|
86
|
+
def set_fields(
|
|
87
|
+
self,
|
|
88
|
+
document_id: str,
|
|
89
|
+
payload: dict[str, Any],
|
|
90
|
+
*,
|
|
91
|
+
merge: bool = True,
|
|
92
|
+
) -> None:
|
|
93
|
+
self.update(document_id, payload, merge=merge)
|
|
94
|
+
|
|
95
|
+
def list_all(self) -> list[DocumentT]:
|
|
96
|
+
return [
|
|
97
|
+
self._from_dict(document.to_dict() or {})
|
|
98
|
+
for document in self._collection().stream()
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
def document(self, document_id: str):
|
|
102
|
+
return self._collection().document(document_id)
|
|
103
|
+
|
|
104
|
+
def _collection(self):
|
|
105
|
+
return self._client_provider().collection(self._collection_name)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class SharedDocumentStore(Generic[DocumentT]):
|
|
109
|
+
"""Typed repository for a single shared Firestore document."""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
collection_name: str,
|
|
115
|
+
document_id: str,
|
|
116
|
+
from_dict: Callable[[dict[str, Any]], DocumentT],
|
|
117
|
+
to_dict: Callable[[DocumentT], dict[str, Any]],
|
|
118
|
+
client_provider: Callable[[], Any] = get_client,
|
|
119
|
+
cache_ttl_seconds: float | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
self._store = DocumentStore(
|
|
122
|
+
collection_name=collection_name,
|
|
123
|
+
from_dict=from_dict,
|
|
124
|
+
to_dict=to_dict,
|
|
125
|
+
client_provider=client_provider,
|
|
126
|
+
cache_ttl_seconds=cache_ttl_seconds,
|
|
127
|
+
)
|
|
128
|
+
self._document_id = document_id
|
|
129
|
+
|
|
130
|
+
def get_result(
|
|
131
|
+
self,
|
|
132
|
+
*,
|
|
133
|
+
force_refresh: bool = False,
|
|
134
|
+
) -> FetchResult[DocumentT]:
|
|
135
|
+
return self._store.get_result(
|
|
136
|
+
self._document_id,
|
|
137
|
+
force_refresh=force_refresh,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def get(self, *, force_refresh: bool = False) -> DocumentT | None:
|
|
141
|
+
return self._store.get(
|
|
142
|
+
self._document_id,
|
|
143
|
+
force_refresh=force_refresh,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def get_or_create(self, factory: Callable[[], DocumentT]) -> DocumentT:
|
|
147
|
+
return self._store.get_or_create(self._document_id, factory)
|
|
148
|
+
|
|
149
|
+
def save(self, value: DocumentT, *, merge: bool = False) -> None:
|
|
150
|
+
self._store.save(self._document_id, value, merge=merge)
|
|
151
|
+
|
|
152
|
+
def update(self, fields: dict[str, Any], *, merge: bool = True) -> None:
|
|
153
|
+
self._store.update(self._document_id, fields, merge=merge)
|
|
154
|
+
|
|
155
|
+
def set_fields(self, payload: dict[str, Any], *, merge: bool = True) -> None:
|
|
156
|
+
self.update(payload, merge=merge)
|
|
157
|
+
|
|
158
|
+
def document(self):
|
|
159
|
+
return self._store.document(self._document_id)
|
|
160
|
+
|
|
161
|
+
__all__ = [
|
|
162
|
+
"DocumentStore",
|
|
163
|
+
"SharedDocumentStore",
|
|
164
|
+
]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Higher-level Firestore document fetch helpers with optional caching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Callable, Generic, TypeVar
|
|
9
|
+
|
|
10
|
+
from telebotkit.firestore.client import FirestoreQuotaError, get_client, raise_if_quota_error
|
|
11
|
+
|
|
12
|
+
DocumentT = TypeVar("DocumentT")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class FetchResult(Generic[DocumentT]):
|
|
17
|
+
"""Result of fetching one Firestore document."""
|
|
18
|
+
|
|
19
|
+
collection_name: str
|
|
20
|
+
document_id: str
|
|
21
|
+
found: bool
|
|
22
|
+
data: DocumentT | None = None
|
|
23
|
+
raw_data: dict[str, Any] | None = None
|
|
24
|
+
error: Exception | None = None
|
|
25
|
+
from_cache: bool = False
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def ok(self) -> bool:
|
|
29
|
+
return self.error is None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def missing(self) -> bool:
|
|
33
|
+
return self.error is None and not self.found
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def has_data(self) -> bool:
|
|
37
|
+
return self.error is None and self.found and self.data is not None
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def is_quota_error(self) -> bool:
|
|
41
|
+
return isinstance(self.error, FirestoreQuotaError)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class _CacheEntry:
|
|
46
|
+
found: bool
|
|
47
|
+
raw_data: dict[str, Any] | None
|
|
48
|
+
expires_at: float
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_DOCUMENT_CACHE: dict[tuple[str, str], _CacheEntry] = {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def clear_document_cache() -> None:
|
|
55
|
+
"""Drop all cached document fetch entries."""
|
|
56
|
+
|
|
57
|
+
_DOCUMENT_CACHE.clear()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def invalidate_document_cache(collection_name: str, document_id: str) -> None:
|
|
61
|
+
"""Drop one cached document fetch entry."""
|
|
62
|
+
|
|
63
|
+
_DOCUMENT_CACHE.pop((collection_name, document_id), None)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def fetch_document(
|
|
67
|
+
collection_name: str,
|
|
68
|
+
document_id: str,
|
|
69
|
+
*,
|
|
70
|
+
parse: Callable[[dict[str, Any]], DocumentT] | None = None,
|
|
71
|
+
client_provider: Callable[[], Any] = get_client,
|
|
72
|
+
cache_ttl_seconds: float | None = None,
|
|
73
|
+
force_refresh: bool = False,
|
|
74
|
+
) -> FetchResult[Any]:
|
|
75
|
+
"""Fetch one Firestore document as raw data or a parsed value."""
|
|
76
|
+
|
|
77
|
+
cache_key = (collection_name, document_id)
|
|
78
|
+
if not force_refresh and cache_ttl_seconds and cache_ttl_seconds > 0:
|
|
79
|
+
cached_entry = _DOCUMENT_CACHE.get(cache_key)
|
|
80
|
+
if cached_entry is not None:
|
|
81
|
+
if cached_entry.expires_at > time.monotonic():
|
|
82
|
+
return _result_from_entry(
|
|
83
|
+
collection_name=collection_name,
|
|
84
|
+
document_id=document_id,
|
|
85
|
+
entry=cached_entry,
|
|
86
|
+
parse=parse,
|
|
87
|
+
from_cache=True,
|
|
88
|
+
)
|
|
89
|
+
invalidate_document_cache(collection_name, document_id)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
document = client_provider().collection(collection_name).document(document_id).get()
|
|
93
|
+
if not document.exists:
|
|
94
|
+
_cache_entry(
|
|
95
|
+
cache_key,
|
|
96
|
+
found=False,
|
|
97
|
+
raw_data=None,
|
|
98
|
+
cache_ttl_seconds=cache_ttl_seconds,
|
|
99
|
+
)
|
|
100
|
+
return FetchResult(
|
|
101
|
+
collection_name=collection_name,
|
|
102
|
+
document_id=document_id,
|
|
103
|
+
found=False,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
raw_data = dict(document.to_dict() or {})
|
|
107
|
+
_cache_entry(
|
|
108
|
+
cache_key,
|
|
109
|
+
found=True,
|
|
110
|
+
raw_data=raw_data,
|
|
111
|
+
cache_ttl_seconds=cache_ttl_seconds,
|
|
112
|
+
)
|
|
113
|
+
return _build_result(
|
|
114
|
+
collection_name=collection_name,
|
|
115
|
+
document_id=document_id,
|
|
116
|
+
found=True,
|
|
117
|
+
raw_data=raw_data,
|
|
118
|
+
parse=parse,
|
|
119
|
+
from_cache=False,
|
|
120
|
+
)
|
|
121
|
+
except Exception as error:
|
|
122
|
+
normalized_error = _normalize_error(error)
|
|
123
|
+
return FetchResult(
|
|
124
|
+
collection_name=collection_name,
|
|
125
|
+
document_id=document_id,
|
|
126
|
+
found=False,
|
|
127
|
+
error=normalized_error,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _result_from_entry(
|
|
132
|
+
*,
|
|
133
|
+
collection_name: str,
|
|
134
|
+
document_id: str,
|
|
135
|
+
entry: _CacheEntry,
|
|
136
|
+
parse: Callable[[dict[str, Any]], DocumentT] | None,
|
|
137
|
+
from_cache: bool,
|
|
138
|
+
) -> FetchResult[Any]:
|
|
139
|
+
return _build_result(
|
|
140
|
+
collection_name=collection_name,
|
|
141
|
+
document_id=document_id,
|
|
142
|
+
found=entry.found,
|
|
143
|
+
raw_data=deepcopy(entry.raw_data),
|
|
144
|
+
parse=parse,
|
|
145
|
+
from_cache=from_cache,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _build_result(
|
|
150
|
+
*,
|
|
151
|
+
collection_name: str,
|
|
152
|
+
document_id: str,
|
|
153
|
+
found: bool,
|
|
154
|
+
raw_data: dict[str, Any] | None,
|
|
155
|
+
parse: Callable[[dict[str, Any]], DocumentT] | None,
|
|
156
|
+
from_cache: bool,
|
|
157
|
+
) -> FetchResult[Any]:
|
|
158
|
+
if not found:
|
|
159
|
+
return FetchResult(
|
|
160
|
+
collection_name=collection_name,
|
|
161
|
+
document_id=document_id,
|
|
162
|
+
found=False,
|
|
163
|
+
from_cache=from_cache,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
safe_raw = deepcopy(raw_data) if raw_data is not None else {}
|
|
167
|
+
if parse is None:
|
|
168
|
+
return FetchResult(
|
|
169
|
+
collection_name=collection_name,
|
|
170
|
+
document_id=document_id,
|
|
171
|
+
found=True,
|
|
172
|
+
data=safe_raw,
|
|
173
|
+
raw_data=safe_raw,
|
|
174
|
+
from_cache=from_cache,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
parsed = parse(deepcopy(safe_raw))
|
|
179
|
+
except Exception as error:
|
|
180
|
+
return FetchResult(
|
|
181
|
+
collection_name=collection_name,
|
|
182
|
+
document_id=document_id,
|
|
183
|
+
found=True,
|
|
184
|
+
raw_data=safe_raw,
|
|
185
|
+
error=error,
|
|
186
|
+
from_cache=from_cache,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return FetchResult(
|
|
190
|
+
collection_name=collection_name,
|
|
191
|
+
document_id=document_id,
|
|
192
|
+
found=True,
|
|
193
|
+
data=parsed,
|
|
194
|
+
raw_data=safe_raw,
|
|
195
|
+
from_cache=from_cache,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _cache_entry(
|
|
200
|
+
cache_key: tuple[str, str],
|
|
201
|
+
*,
|
|
202
|
+
found: bool,
|
|
203
|
+
raw_data: dict[str, Any] | None,
|
|
204
|
+
cache_ttl_seconds: float | None,
|
|
205
|
+
) -> None:
|
|
206
|
+
if not cache_ttl_seconds or cache_ttl_seconds <= 0:
|
|
207
|
+
return
|
|
208
|
+
_DOCUMENT_CACHE[cache_key] = _CacheEntry(
|
|
209
|
+
found=found,
|
|
210
|
+
raw_data=deepcopy(raw_data),
|
|
211
|
+
expires_at=time.monotonic() + cache_ttl_seconds,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _normalize_error(error: Exception) -> Exception:
|
|
216
|
+
try:
|
|
217
|
+
raise_if_quota_error(error)
|
|
218
|
+
except Exception as normalized_error:
|
|
219
|
+
return normalized_error
|
|
220
|
+
return error
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
__all__ = [
|
|
224
|
+
"FetchResult",
|
|
225
|
+
"clear_document_cache",
|
|
226
|
+
"fetch_document",
|
|
227
|
+
"invalidate_document_cache",
|
|
228
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Firestore-backed short-lived lease helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from telebotkit.firestore.client import get_client
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LeaseStore:
|
|
15
|
+
"""Acquire and renew short-lived Firestore document leases."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
collection_name: str,
|
|
21
|
+
client_provider=get_client,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._collection_name = collection_name
|
|
24
|
+
self._client_provider = client_provider
|
|
25
|
+
|
|
26
|
+
def acquire(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
lease_name: str,
|
|
30
|
+
owner_id: str,
|
|
31
|
+
ttl_seconds: float,
|
|
32
|
+
) -> bool:
|
|
33
|
+
from google.cloud import firestore # type: ignore
|
|
34
|
+
|
|
35
|
+
client = self._client_provider()
|
|
36
|
+
document_reference = client.collection(self._collection_name).document(lease_name)
|
|
37
|
+
transaction = client.transaction()
|
|
38
|
+
now = time.time()
|
|
39
|
+
expires_at = now + max(ttl_seconds, 1.0)
|
|
40
|
+
|
|
41
|
+
@firestore.transactional
|
|
42
|
+
def _acquire(transaction_context: Any) -> bool:
|
|
43
|
+
snapshot = document_reference.get(transaction=transaction_context)
|
|
44
|
+
if snapshot.exists:
|
|
45
|
+
data = snapshot.to_dict() or {}
|
|
46
|
+
current_owner = str(data.get("owner_id") or "")
|
|
47
|
+
current_expires_at = float(data.get("expires_at") or 0.0)
|
|
48
|
+
if current_owner and current_owner != owner_id and current_expires_at > now:
|
|
49
|
+
return False
|
|
50
|
+
transaction_context.set(
|
|
51
|
+
document_reference,
|
|
52
|
+
{
|
|
53
|
+
"owner_id": owner_id,
|
|
54
|
+
"expires_at": expires_at,
|
|
55
|
+
"updated_at": now,
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
return bool(_acquire(transaction))
|
|
61
|
+
|
|
62
|
+
def try_acquire(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
lease_name: str,
|
|
66
|
+
owner_id: str,
|
|
67
|
+
ttl_seconds: float,
|
|
68
|
+
) -> bool:
|
|
69
|
+
return self.acquire(
|
|
70
|
+
lease_name=lease_name,
|
|
71
|
+
owner_id=owner_id,
|
|
72
|
+
ttl_seconds=ttl_seconds,
|
|
73
|
+
)
|
|
74
|
+
__all__ = ["LeaseStore"]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Firestore upload helpers for prevalidated JSON payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from telebotkit.firestore.client import get_client
|
|
8
|
+
from telebotkit.sheets import load_typed_rows_json, validate_typed_rows_payload
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def upload_typed_rows_payload(
|
|
12
|
+
*,
|
|
13
|
+
payload: dict[str, Any],
|
|
14
|
+
collection_name: str,
|
|
15
|
+
document_id: str,
|
|
16
|
+
document_type: str = "typed_rows",
|
|
17
|
+
records_field: str = "records",
|
|
18
|
+
metadata_field: str = "metadata",
|
|
19
|
+
key_field_name: str = "key",
|
|
20
|
+
) -> str:
|
|
21
|
+
validated = validate_typed_rows_payload(
|
|
22
|
+
payload,
|
|
23
|
+
document_type=document_type,
|
|
24
|
+
records_field=records_field,
|
|
25
|
+
metadata_field=metadata_field,
|
|
26
|
+
key_field_name=key_field_name,
|
|
27
|
+
)
|
|
28
|
+
get_client().collection(collection_name).document(document_id).set(
|
|
29
|
+
{
|
|
30
|
+
"document_type": document_type,
|
|
31
|
+
"schema_version": int(validated.get("schema_version", 1) or 1),
|
|
32
|
+
records_field: validated.get(records_field) or {},
|
|
33
|
+
metadata_field: validated.get(metadata_field) or {},
|
|
34
|
+
},
|
|
35
|
+
merge=True,
|
|
36
|
+
)
|
|
37
|
+
meta = validated.get(metadata_field) or {}
|
|
38
|
+
return (
|
|
39
|
+
"업로드 완료:\n"
|
|
40
|
+
f"- Firestore: {collection_name}/{document_id}\n"
|
|
41
|
+
f"- record_count: {meta.get('record_count')}\n"
|
|
42
|
+
f"- size_bytes: {meta.get('size_bytes')}\n"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def upload_typed_rows_json(
|
|
47
|
+
*,
|
|
48
|
+
json_path: str,
|
|
49
|
+
collection_name: str,
|
|
50
|
+
document_id: str,
|
|
51
|
+
document_type: str = "typed_rows",
|
|
52
|
+
records_field: str = "records",
|
|
53
|
+
metadata_field: str = "metadata",
|
|
54
|
+
key_field_name: str = "key",
|
|
55
|
+
) -> str:
|
|
56
|
+
payload = load_typed_rows_json(
|
|
57
|
+
json_path,
|
|
58
|
+
document_type=document_type,
|
|
59
|
+
records_field=records_field,
|
|
60
|
+
metadata_field=metadata_field,
|
|
61
|
+
key_field_name=key_field_name,
|
|
62
|
+
)
|
|
63
|
+
result = upload_typed_rows_payload(
|
|
64
|
+
payload=payload,
|
|
65
|
+
collection_name=collection_name,
|
|
66
|
+
document_id=document_id,
|
|
67
|
+
document_type=document_type,
|
|
68
|
+
records_field=records_field,
|
|
69
|
+
metadata_field=metadata_field,
|
|
70
|
+
key_field_name=key_field_name,
|
|
71
|
+
)
|
|
72
|
+
return result.replace("업로드 완료:\n", f"업로드 완료:\n- JSON: {json_path}\n", 1)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = ["upload_typed_rows_json", "upload_typed_rows_payload"]
|