settfex 0.3.0__tar.gz → 0.3.1__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.
- {settfex-0.3.0 → settfex-0.3.1}/CHANGELOG.md +15 -0
- {settfex-0.3.0 → settfex-0.3.1}/PKG-INFO +2 -3
- {settfex-0.3.0 → settfex-0.3.1}/pyproject.toml +1 -2
- {settfex-0.3.0 → settfex-0.3.1}/settfex/__init__.py +1 -1
- settfex-0.3.1/settfex/utils/session_cache.py +416 -0
- settfex-0.3.1/tests/utils/test_session_cache.py +240 -0
- {settfex-0.3.0 → settfex-0.3.1}/uv.lock +2 -13
- settfex-0.3.0/settfex/utils/session_cache.py +0 -267
- {settfex-0.3.0 → settfex-0.3.1}/.editorconfig +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.github/copilot-instructions.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.github/dependabot.yml +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.github/instructions/core-architectrual-principles.instructions.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.github/instructions/documentation-standards.instructions.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.github/instructions/file-organization.instructions.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.github/instructions/git-commit.instructions.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.github/instructions/python-dependency-management.instructions.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.github/prompts/Coding.prompt.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.github/prompts/Git-Commit-Reviewer.prompt.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.github/prompts/Prompt-Engineer.prompt.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.github/prompts/Python-Architect.prompt.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.github/workflows/ci.yml +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.github/workflows/release.yml +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.github/workflows/security.yml +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.gitignore +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.pre-commit-config.yaml +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/.python-version +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/CODE_OF_CONDUCT.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/COMPREHENSIVE_AUDIT.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/CONTRIBUTING.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/LICENSE +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/MANIFEST.in +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/README.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/RELEASING.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/SECURITY.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/guide/PYTHON_LIBRARY_BEST_PRACTICES.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/index.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/API_PROTECTION_NOTE.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/board_of_director.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/corporate_action.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/financial.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/highlight_data.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/list.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/nvdr_holder.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/price_performance.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/profile_company.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/profile_stock.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/shareholder.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/trading_stat.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/tfex/list.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/tfex/trading_statistics.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/utils/data_fetcher.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/utils/session_caching.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/solution/FINDINGS.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/solution/Performance Boost Session Caching Implementation.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/solution/SESSION_CACHE_SUMMARY.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/docs/solution/SOLUTION_100_PERCENT.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/README.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/01_stock_list.ipynb +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/02_highlight_data.ipynb +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/03_stock_profile.ipynb +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/04_company_profile.ipynb +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/05_corporate_action.ipynb +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/06_shareholder.ipynb +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/07_nvdr_holder.ipynb +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/08_board_of_director.ipynb +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/09_trading_statistics.ipynb +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/10_price_performance.ipynb +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/11_financial.ipynb +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/README.md +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/cpall_balance_sheet.csv +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/market_dashboard_20251005_173221.csv +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/market_dashboard_20251005_210737.csv +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/market_dashboard_20251006_091440.csv +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/thailand_banking_stocks.csv +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/thailand_set_main_board.csv +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/set/thailand_stock_universe.csv +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/tfex/01_series_list.ipynb +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/tfex/02_trading_statistics.ipynb +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/examples/tfex/03_underlying_price.ipynb +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/py.typed +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/__init__.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/__init__.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/constants.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/list.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/__init__.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/board_of_director.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/chart_quotation.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/corporate_action.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/financial/__init__.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/financial/financial.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/highlight_data.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/latest_historical_trading.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/nvdr_holder.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/price_performance.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/profile_company.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/profile_stock.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/shareholder.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/stock.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/trading_stat.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/utils.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/tfex/__init__.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/tfex/constants.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/tfex/list.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/tfex/trading_statistics.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/services/tfex/underlying_price.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/utils/__init__.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/utils/data_fetcher.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/utils/http.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/utils/logging.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/utils/parsing.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/settfex/utils/session_manager.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/__init__.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/conftest.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/services/__init__.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/__init__.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/stock/financial/__init__.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/stock/financial/test_financial.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/test_board_of_director.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/test_client.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/test_corporate_action.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/test_historical.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/test_realtime.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/test_shareholder.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/services/tfex/__init__.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/services/tfex/test_client.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/services/tfex/test_historical.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/services/tfex/test_list.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/services/tfex/test_realtime.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/services/tfex/test_trading_statistics.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/services/tfex/test_underlying_price.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/utils/__init__.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/utils/test_data_fetcher.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/utils/test_formatting.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/utils/test_http.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/utils/test_logging.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/utils/test_parsing.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/utils/test_session_manager.py +0 -0
- {settfex-0.3.0 → settfex-0.3.1}/tests/utils/test_validation.py +0 -0
|
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.1] - 2026-11-02
|
|
9
|
+
|
|
10
|
+
Security backport for the 0.3 line: the same fix as 0.26.1, with nothing else changed.
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- **The session cache no longer unpickles anything, and `diskcache` is no longer a dependency**
|
|
15
|
+
(`settfex.utils.session_cache`). `diskcache` reads cached values back with pickle
|
|
16
|
+
(PYSEC-2026-2447 / CVE-2025-69872 / GHSA-w8v5-vhqr-4h9v). It has no fixed release, so anyone
|
|
17
|
+
able to write into the cache directory could execute code in the process that read it.
|
|
18
|
+
- The store is now stdlib `sqlite3` holding JSON text (`sessions.sqlite3`, created `0600`
|
|
19
|
+
inside a `0700` directory). A row that is not a JSON object is a cache miss, never code.
|
|
20
|
+
- The old `cache.db` is never opened, so a session warms up once after the upgrade.
|
|
21
|
+
- `SessionCache`'s API is unchanged.
|
|
22
|
+
|
|
8
23
|
## [0.3.0] - 2026-06-17
|
|
9
24
|
|
|
10
25
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: settfex
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Async Python library for fetching real-time and historical data from the Stock Exchange of Thailand (SET) and Thailand Futures Exchange (TFEX)
|
|
5
5
|
Project-URL: Homepage, https://github.com/lumduan/settfex
|
|
6
6
|
Project-URL: Documentation, https://github.com/lumduan/settfex#readme
|
|
@@ -27,7 +27,6 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
27
27
|
Classifier: Typing :: Typed
|
|
28
28
|
Requires-Python: >=3.11
|
|
29
29
|
Requires-Dist: curl-cffi>=0.6.0
|
|
30
|
-
Requires-Dist: diskcache>=5.6.0
|
|
31
30
|
Requires-Dist: loguru>=0.7.0
|
|
32
31
|
Requires-Dist: pydantic>=2.0.0
|
|
33
32
|
Provides-Extra: examples
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "settfex"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
description = "Async Python library for fetching real-time and historical data from the Stock Exchange of Thailand (SET) and Thailand Futures Exchange (TFEX)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -8,7 +8,6 @@ dependencies = [
|
|
|
8
8
|
"curl-cffi>=0.6.0",
|
|
9
9
|
"pydantic>=2.0.0",
|
|
10
10
|
"loguru>=0.7.0",
|
|
11
|
-
"diskcache>=5.6.0",
|
|
12
11
|
]
|
|
13
12
|
authors = [
|
|
14
13
|
{ name = "batt", email = "b@candythink.com" }
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session cache for persistent cookie storage: stdlib ``sqlite3`` holding JSON values.
|
|
3
|
+
|
|
4
|
+
This module provides disk-based caching for curl_cffi session cookies,
|
|
5
|
+
enabling fast session reuse without warming up every time.
|
|
6
|
+
|
|
7
|
+
Key features:
|
|
8
|
+
- Persistent storage across program restarts
|
|
9
|
+
- Automatic expiration and refresh
|
|
10
|
+
- Thread-safe, and safe for several processes sharing one cache directory (SQLite WAL)
|
|
11
|
+
- Fallback to warm-up on cache miss or failure
|
|
12
|
+
|
|
13
|
+
**Nothing here unpickles.** Until 0.3.1 the store was ``diskcache``, which reads values back with
|
|
14
|
+
pickle (CVE-2025-69872 / PYSEC-2026-2447, no fixed release upstream): write access to the cache
|
|
15
|
+
directory meant code execution in the reading process. Values are now JSON text, decoded with
|
|
16
|
+
``json.loads``; a row that does not decode to a JSON object is a cache miss, never code. The old
|
|
17
|
+
diskcache database (``cache.db``) is never opened, so it is never unpickled either.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sqlite3
|
|
24
|
+
import stat
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from loguru import logger
|
|
31
|
+
|
|
32
|
+
# The cached cookies are credentials in their own right: another account that can read the
|
|
33
|
+
# directory can replay the session, and one that can write it can plant cookies. 0700 on the
|
|
34
|
+
# directory and 0600 on the database remove both reaches for every other account on the machine.
|
|
35
|
+
_CACHE_DIR_MODE = 0o700
|
|
36
|
+
_CACHE_FILE_MODE = 0o600
|
|
37
|
+
_DB_NAME = "sessions.sqlite3"
|
|
38
|
+
# The pre-0.3.1 diskcache database. Never opened: diskcache would unpickle it.
|
|
39
|
+
_LEGACY_DISKCACHE_DB = "cache.db"
|
|
40
|
+
_SCHEMA = (
|
|
41
|
+
"CREATE TABLE IF NOT EXISTS sessions ("
|
|
42
|
+
" key TEXT PRIMARY KEY,"
|
|
43
|
+
" value TEXT NOT NULL,"
|
|
44
|
+
" stored_at REAL NOT NULL,"
|
|
45
|
+
" expires_at REAL NOT NULL)"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _secure_cache_dir(cache_dir: Path, *, owned: bool) -> None:
|
|
50
|
+
"""Create the cache directory private, and vet the permissions of one that already exists.
|
|
51
|
+
|
|
52
|
+
New directories are always created ``0700``. An existing one is treated differently
|
|
53
|
+
depending on who chose it:
|
|
54
|
+
|
|
55
|
+
* The default ``~/.settfex/cache`` is ours, so loose bits are tightened (and logged).
|
|
56
|
+
* A caller-supplied directory may be shared on purpose — a service account, a mounted
|
|
57
|
+
volume — so a group/other-writable one is *reported and left alone*. Silently breaking a
|
|
58
|
+
deliberate layout would be worse than the warning.
|
|
59
|
+
|
|
60
|
+
Never raises: a cache that cannot be permission-checked is still a usable cache, and this
|
|
61
|
+
module's contract is to degrade rather than fail (see the try/except in every method below).
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
cache_dir: Directory that will hold the session database.
|
|
65
|
+
owned: True when settfex chose the location, False when the caller passed one.
|
|
66
|
+
"""
|
|
67
|
+
cache_dir.mkdir(mode=_CACHE_DIR_MODE, parents=True, exist_ok=True)
|
|
68
|
+
|
|
69
|
+
# POSIX mode bits do not express this on Windows; skip rather than pretend.
|
|
70
|
+
if os.name == "nt":
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
mode = stat.S_IMODE(cache_dir.stat().st_mode)
|
|
75
|
+
# A directory this call just created is already private -- mkdir's mode can only be
|
|
76
|
+
# tightened by the umask, never loosened -- so reaching past here means it pre-existed.
|
|
77
|
+
if not mode & 0o077:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
if owned:
|
|
81
|
+
cache_dir.chmod(_CACHE_DIR_MODE)
|
|
82
|
+
logger.info(
|
|
83
|
+
f"Tightened cache directory permissions: {cache_dir} {mode:04o} -> 0700 "
|
|
84
|
+
f"(cached cookies are credentials)"
|
|
85
|
+
)
|
|
86
|
+
elif mode & 0o022:
|
|
87
|
+
logger.warning(
|
|
88
|
+
f"Cache directory {cache_dir} is group/other-writable ({mode:04o}). The cached "
|
|
89
|
+
f"session cookies are credentials: anyone who can write here can plant cookies "
|
|
90
|
+
f"this process will send. Leaving the mode alone because you chose this path -- "
|
|
91
|
+
f"pass a private directory, or chmod 700 it, if the sharing is not deliberate."
|
|
92
|
+
)
|
|
93
|
+
except OSError as e:
|
|
94
|
+
logger.warning(f"Could not verify permissions on cache directory {cache_dir}: {e}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _secure_db_file(path: Path) -> None:
|
|
98
|
+
"""Create the database file ``0600`` before SQLite opens it; tighten a loose existing one.
|
|
99
|
+
|
|
100
|
+
SQLite creates its ``-wal`` / ``-shm`` files with the main database's permissions, so getting
|
|
101
|
+
this file right covers all three. Never raises (same contract as :func:`_secure_cache_dir`).
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
fd = os.open(path, os.O_CREAT | os.O_WRONLY, _CACHE_FILE_MODE)
|
|
105
|
+
os.close(fd)
|
|
106
|
+
if os.name != "nt" and stat.S_IMODE(path.stat().st_mode) & 0o077:
|
|
107
|
+
path.chmod(_CACHE_FILE_MODE)
|
|
108
|
+
except OSError as e:
|
|
109
|
+
logger.warning(f"Could not verify permissions on cache file {path}: {e}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class SessionCache:
|
|
113
|
+
"""
|
|
114
|
+
Disk-based cache for session cookies and metadata.
|
|
115
|
+
|
|
116
|
+
This class manages persistent storage of curl_cffi session cookies,
|
|
117
|
+
allowing sessions to be reused across program restarts. When cached
|
|
118
|
+
cookies are available and valid, they're used immediately (fast path).
|
|
119
|
+
Otherwise, a new session is warmed up and cached.
|
|
120
|
+
|
|
121
|
+
Values must be JSON-serializable dicts (cookies as ``{name: value}`` strings, the browser
|
|
122
|
+
name, timestamps, counts). They are stored as JSON text in SQLite; nothing is unpickled.
|
|
123
|
+
|
|
124
|
+
Architecture:
|
|
125
|
+
1. Check cache for valid cookies → Use if found (fast)
|
|
126
|
+
2. If not found or expired → Warm up → Cache it
|
|
127
|
+
3. If request fails → Re-warm and update cache
|
|
128
|
+
|
|
129
|
+
Example:
|
|
130
|
+
>>> cache = SessionCache()
|
|
131
|
+
>>>
|
|
132
|
+
>>> # Try to get cached cookies
|
|
133
|
+
>>> cached = cache.get("set_session")
|
|
134
|
+
>>> if cached and not cache.is_expired("set_session"):
|
|
135
|
+
>>> cookies = cached["cookies"]
|
|
136
|
+
>>> else:
|
|
137
|
+
>>> # Warm up new session
|
|
138
|
+
>>> cookies = await warm_up_session()
|
|
139
|
+
>>> cache.set("set_session", {"cookies": cookies, "browser": "chrome120"})
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
cache_dir: str | Path | None = None,
|
|
145
|
+
default_ttl: int = 3600,
|
|
146
|
+
size_limit: int = 100 * 1024 * 1024, # 100MB
|
|
147
|
+
) -> None:
|
|
148
|
+
"""
|
|
149
|
+
Initialize session cache.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
cache_dir: Directory for cache storage (default: ~/.settfex/cache)
|
|
153
|
+
default_ttl: Default time-to-live for cached items in seconds (default: 1 hour)
|
|
154
|
+
size_limit: Maximum total size of stored values in bytes (default: 100MB); the
|
|
155
|
+
oldest entries are evicted beyond it
|
|
156
|
+
"""
|
|
157
|
+
# The `is None` test is repeated rather than reusing `is_default`, because mypy narrows
|
|
158
|
+
# the parameter on the literal check but not through the flag.
|
|
159
|
+
is_default = cache_dir is None
|
|
160
|
+
cache_dir = Path.home() / ".settfex" / "cache" if cache_dir is None else Path(cache_dir)
|
|
161
|
+
|
|
162
|
+
# Create the cache directory 0700 -- see _secure_cache_dir for why the mode matters.
|
|
163
|
+
_secure_cache_dir(cache_dir, owned=is_default)
|
|
164
|
+
|
|
165
|
+
self.path = cache_dir / _DB_NAME
|
|
166
|
+
_secure_db_file(self.path)
|
|
167
|
+
self.default_ttl = default_ttl
|
|
168
|
+
self.size_limit = size_limit
|
|
169
|
+
self._lock = threading.Lock()
|
|
170
|
+
# Autocommit (isolation_level=None) with a busy timeout: several processes on one host
|
|
171
|
+
# may share this directory, and WAL lets their readers and the one writer coexist.
|
|
172
|
+
self._db = sqlite3.connect(
|
|
173
|
+
str(self.path), timeout=10.0, isolation_level=None, check_same_thread=False
|
|
174
|
+
)
|
|
175
|
+
with self._lock:
|
|
176
|
+
self._db.execute("PRAGMA journal_mode=WAL")
|
|
177
|
+
self._db.execute(_SCHEMA)
|
|
178
|
+
|
|
179
|
+
if (cache_dir / _LEGACY_DISKCACHE_DB).exists():
|
|
180
|
+
logger.info(
|
|
181
|
+
f"A pre-0.3.1 diskcache database is present at {cache_dir / _LEGACY_DISKCACHE_DB}"
|
|
182
|
+
f" and is NOT read (diskcache unpickles its values). It can be deleted; the "
|
|
183
|
+
f"session will warm up once and be cached in {_DB_NAME}."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
logger.info(
|
|
187
|
+
f"SessionCache initialized at {cache_dir} "
|
|
188
|
+
f"(ttl={default_ttl}s, size_limit={size_limit // 1024 // 1024}MB)"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def get(self, key: str) -> dict[str, Any] | None:
|
|
192
|
+
"""
|
|
193
|
+
Get cached session data.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
key: Cache key (e.g., "set_session", "tfex_session")
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Cached session data dict or None if not found, expired, or unreadable
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
>>> cached = cache.get("set_session")
|
|
203
|
+
>>> if cached:
|
|
204
|
+
>>> cookies = cached["cookies"]
|
|
205
|
+
>>> browser = cached["browser"]
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
with self._lock:
|
|
209
|
+
row = self._db.execute(
|
|
210
|
+
"SELECT value, expires_at FROM sessions WHERE key = ?", (key,)
|
|
211
|
+
).fetchone()
|
|
212
|
+
if row is not None and row[1] <= time.time():
|
|
213
|
+
self._db.execute("DELETE FROM sessions WHERE key = ?", (key,))
|
|
214
|
+
row = None
|
|
215
|
+
if row is None:
|
|
216
|
+
logger.debug(f"Cache MISS: {key}")
|
|
217
|
+
return None
|
|
218
|
+
value = json.loads(row[0])
|
|
219
|
+
if not isinstance(value, dict):
|
|
220
|
+
logger.warning(f"Cache entry {key!r} is not a JSON object; treating it as a miss")
|
|
221
|
+
return None
|
|
222
|
+
logger.debug(f"Cache HIT: {key}")
|
|
223
|
+
return value
|
|
224
|
+
except (sqlite3.Error, ValueError) as e:
|
|
225
|
+
logger.error(f"Failed to read from cache: {e}")
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
def set(
|
|
229
|
+
self,
|
|
230
|
+
key: str,
|
|
231
|
+
value: dict[str, Any],
|
|
232
|
+
ttl: int | None = None,
|
|
233
|
+
) -> bool:
|
|
234
|
+
"""
|
|
235
|
+
Store session data in cache.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
key: Cache key (e.g., "set_session")
|
|
239
|
+
value: Session data to cache: a JSON-serializable dict with "cookies", "browser",
|
|
240
|
+
etc.
|
|
241
|
+
ttl: Time-to-live in seconds (uses default_ttl if None)
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
True if successfully cached, False otherwise (including a value JSON cannot encode)
|
|
245
|
+
|
|
246
|
+
Example:
|
|
247
|
+
>>> cache.set("set_session", {
|
|
248
|
+
>>> "cookies": {"incap_ses": "xyz"},
|
|
249
|
+
>>> "browser": "chrome120",
|
|
250
|
+
>>> "warmup_time": time.time(),
|
|
251
|
+
>>> "cookie_count": 12
|
|
252
|
+
>>> })
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
ttl = ttl or self.default_ttl
|
|
256
|
+
now = time.time()
|
|
257
|
+
# Add metadata
|
|
258
|
+
value["cached_at"] = now
|
|
259
|
+
value["ttl"] = ttl
|
|
260
|
+
encoded = json.dumps(value)
|
|
261
|
+
|
|
262
|
+
with self._lock:
|
|
263
|
+
self._db.execute(
|
|
264
|
+
"INSERT OR REPLACE INTO sessions (key, value, stored_at, expires_at)"
|
|
265
|
+
" VALUES (?, ?, ?, ?)",
|
|
266
|
+
(key, encoded, now, now + ttl),
|
|
267
|
+
)
|
|
268
|
+
self._db.execute("DELETE FROM sessions WHERE expires_at <= ?", (now,))
|
|
269
|
+
self._evict_over_limit()
|
|
270
|
+
logger.success(f"✓ Cached session: {key} (ttl={ttl}s)")
|
|
271
|
+
return True
|
|
272
|
+
except (sqlite3.Error, TypeError, ValueError) as e:
|
|
273
|
+
logger.error(f"Failed to write to cache: {e}")
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
def _evict_over_limit(self) -> None:
|
|
277
|
+
"""Drop the oldest entries while the stored values exceed ``size_limit``. Lock held."""
|
|
278
|
+
while True:
|
|
279
|
+
total = self._db.execute(
|
|
280
|
+
"SELECT COALESCE(SUM(LENGTH(value)), 0), COUNT(*) FROM sessions"
|
|
281
|
+
).fetchone()
|
|
282
|
+
if total[0] <= self.size_limit or total[1] <= 1:
|
|
283
|
+
return
|
|
284
|
+
self._db.execute(
|
|
285
|
+
"DELETE FROM sessions WHERE key ="
|
|
286
|
+
" (SELECT key FROM sessions ORDER BY stored_at ASC LIMIT 1)"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def delete(self, key: str) -> bool:
|
|
290
|
+
"""
|
|
291
|
+
Delete cached session data.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
key: Cache key to delete
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
True if deleted, False otherwise
|
|
298
|
+
"""
|
|
299
|
+
try:
|
|
300
|
+
with self._lock:
|
|
301
|
+
deleted = self._db.execute("DELETE FROM sessions WHERE key = ?", (key,)).rowcount
|
|
302
|
+
if deleted:
|
|
303
|
+
logger.debug(f"Deleted from cache: {key}")
|
|
304
|
+
return bool(deleted)
|
|
305
|
+
except sqlite3.Error as e:
|
|
306
|
+
# "delete from" below is log text, not SQL (bandit B608 false positive).
|
|
307
|
+
logger.error(f"Failed to delete from cache: {e}") # nosec B608
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
def is_expired(self, key: str, max_age: int | None = None) -> bool:
|
|
311
|
+
"""
|
|
312
|
+
Check if cached session is expired.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
key: Cache key to check
|
|
316
|
+
max_age: Maximum age in seconds (uses default_ttl if None)
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
True if expired or not found, False if still valid
|
|
320
|
+
"""
|
|
321
|
+
cached = self.get(key)
|
|
322
|
+
if cached is None:
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
max_age = max_age or self.default_ttl
|
|
326
|
+
cached_at = cached.get("cached_at", 0)
|
|
327
|
+
age = time.time() - cached_at
|
|
328
|
+
|
|
329
|
+
is_expired = age > max_age
|
|
330
|
+
if is_expired:
|
|
331
|
+
logger.debug(f"Cache expired: {key} (age={age:.0f}s > max={max_age}s)")
|
|
332
|
+
else:
|
|
333
|
+
logger.debug(f"Cache valid: {key} (age={age:.0f}s < max={max_age}s)")
|
|
334
|
+
|
|
335
|
+
return bool(is_expired)
|
|
336
|
+
|
|
337
|
+
def clear(self) -> None:
|
|
338
|
+
"""Clear all cached data."""
|
|
339
|
+
try:
|
|
340
|
+
with self._lock:
|
|
341
|
+
self._db.execute("DELETE FROM sessions")
|
|
342
|
+
logger.info("Cache cleared")
|
|
343
|
+
except sqlite3.Error as e:
|
|
344
|
+
logger.error(f"Failed to clear cache: {e}")
|
|
345
|
+
|
|
346
|
+
def stats(self) -> dict[str, Any]:
|
|
347
|
+
"""
|
|
348
|
+
Get cache statistics.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Dict with ``size`` (bytes of stored values) and ``count`` (number of entries)
|
|
352
|
+
"""
|
|
353
|
+
try:
|
|
354
|
+
with self._lock:
|
|
355
|
+
size, count = self._db.execute(
|
|
356
|
+
"SELECT COALESCE(SUM(LENGTH(value)), 0), COUNT(*) FROM sessions"
|
|
357
|
+
).fetchone()
|
|
358
|
+
return {"size": int(size), "count": int(count)}
|
|
359
|
+
except sqlite3.Error as e:
|
|
360
|
+
logger.error(f"Failed to get cache stats: {e}")
|
|
361
|
+
return {"size": 0, "count": 0}
|
|
362
|
+
|
|
363
|
+
def close(self) -> None:
|
|
364
|
+
"""Close the cache."""
|
|
365
|
+
try:
|
|
366
|
+
with self._lock:
|
|
367
|
+
self._db.close()
|
|
368
|
+
logger.debug("Cache closed")
|
|
369
|
+
except sqlite3.Error as e:
|
|
370
|
+
logger.error(f"Failed to close cache: {e}")
|
|
371
|
+
|
|
372
|
+
def __enter__(self) -> "SessionCache":
|
|
373
|
+
"""Context manager entry."""
|
|
374
|
+
return self
|
|
375
|
+
|
|
376
|
+
def __exit__(self, *args: object) -> None:
|
|
377
|
+
"""Context manager exit."""
|
|
378
|
+
self.close()
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# Global cache instance (singleton pattern)
|
|
382
|
+
_global_cache: SessionCache | None = None
|
|
383
|
+
_cache_lock = asyncio.Lock()
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
async def get_global_cache(
|
|
387
|
+
cache_dir: str | Path | None = None,
|
|
388
|
+
default_ttl: int = 3600,
|
|
389
|
+
) -> SessionCache:
|
|
390
|
+
"""
|
|
391
|
+
Get or create global SessionCache instance.
|
|
392
|
+
|
|
393
|
+
This function provides a singleton cache instance that can be shared
|
|
394
|
+
across all services for optimal performance.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
cache_dir: Directory for cache storage (default: ~/.settfex/cache)
|
|
398
|
+
default_ttl: Default time-to-live in seconds (default: 1 hour)
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
Global SessionCache instance
|
|
402
|
+
|
|
403
|
+
Example:
|
|
404
|
+
>>> cache = await get_global_cache()
|
|
405
|
+
>>> cached = cache.get("set_session")
|
|
406
|
+
"""
|
|
407
|
+
global _global_cache
|
|
408
|
+
|
|
409
|
+
async with _cache_lock:
|
|
410
|
+
if _global_cache is None:
|
|
411
|
+
# SessionCache.__init__ does blocking disk I/O (mkdir + opening the SQLite
|
|
412
|
+
# database); run it off the event loop so cold cache init never blocks the loop.
|
|
413
|
+
_global_cache = await asyncio.to_thread(
|
|
414
|
+
SessionCache, cache_dir=cache_dir, default_ttl=default_ttl
|
|
415
|
+
)
|
|
416
|
+
return _global_cache
|