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.
Files changed (141) hide show
  1. {settfex-0.3.0 → settfex-0.3.1}/CHANGELOG.md +15 -0
  2. {settfex-0.3.0 → settfex-0.3.1}/PKG-INFO +2 -3
  3. {settfex-0.3.0 → settfex-0.3.1}/pyproject.toml +1 -2
  4. {settfex-0.3.0 → settfex-0.3.1}/settfex/__init__.py +1 -1
  5. settfex-0.3.1/settfex/utils/session_cache.py +416 -0
  6. settfex-0.3.1/tests/utils/test_session_cache.py +240 -0
  7. {settfex-0.3.0 → settfex-0.3.1}/uv.lock +2 -13
  8. settfex-0.3.0/settfex/utils/session_cache.py +0 -267
  9. {settfex-0.3.0 → settfex-0.3.1}/.editorconfig +0 -0
  10. {settfex-0.3.0 → settfex-0.3.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  11. {settfex-0.3.0 → settfex-0.3.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  12. {settfex-0.3.0 → settfex-0.3.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  13. {settfex-0.3.0 → settfex-0.3.1}/.github/copilot-instructions.md +0 -0
  14. {settfex-0.3.0 → settfex-0.3.1}/.github/dependabot.yml +0 -0
  15. {settfex-0.3.0 → settfex-0.3.1}/.github/instructions/core-architectrual-principles.instructions.md +0 -0
  16. {settfex-0.3.0 → settfex-0.3.1}/.github/instructions/documentation-standards.instructions.md +0 -0
  17. {settfex-0.3.0 → settfex-0.3.1}/.github/instructions/file-organization.instructions.md +0 -0
  18. {settfex-0.3.0 → settfex-0.3.1}/.github/instructions/git-commit.instructions.md +0 -0
  19. {settfex-0.3.0 → settfex-0.3.1}/.github/instructions/python-dependency-management.instructions.md +0 -0
  20. {settfex-0.3.0 → settfex-0.3.1}/.github/prompts/Coding.prompt.md +0 -0
  21. {settfex-0.3.0 → settfex-0.3.1}/.github/prompts/Git-Commit-Reviewer.prompt.md +0 -0
  22. {settfex-0.3.0 → settfex-0.3.1}/.github/prompts/Prompt-Engineer.prompt.md +0 -0
  23. {settfex-0.3.0 → settfex-0.3.1}/.github/prompts/Python-Architect.prompt.md +0 -0
  24. {settfex-0.3.0 → settfex-0.3.1}/.github/workflows/ci.yml +0 -0
  25. {settfex-0.3.0 → settfex-0.3.1}/.github/workflows/release.yml +0 -0
  26. {settfex-0.3.0 → settfex-0.3.1}/.github/workflows/security.yml +0 -0
  27. {settfex-0.3.0 → settfex-0.3.1}/.gitignore +0 -0
  28. {settfex-0.3.0 → settfex-0.3.1}/.pre-commit-config.yaml +0 -0
  29. {settfex-0.3.0 → settfex-0.3.1}/.python-version +0 -0
  30. {settfex-0.3.0 → settfex-0.3.1}/CODE_OF_CONDUCT.md +0 -0
  31. {settfex-0.3.0 → settfex-0.3.1}/COMPREHENSIVE_AUDIT.md +0 -0
  32. {settfex-0.3.0 → settfex-0.3.1}/CONTRIBUTING.md +0 -0
  33. {settfex-0.3.0 → settfex-0.3.1}/LICENSE +0 -0
  34. {settfex-0.3.0 → settfex-0.3.1}/MANIFEST.in +0 -0
  35. {settfex-0.3.0 → settfex-0.3.1}/README.md +0 -0
  36. {settfex-0.3.0 → settfex-0.3.1}/RELEASING.md +0 -0
  37. {settfex-0.3.0 → settfex-0.3.1}/SECURITY.md +0 -0
  38. {settfex-0.3.0 → settfex-0.3.1}/docs/guide/PYTHON_LIBRARY_BEST_PRACTICES.md +0 -0
  39. {settfex-0.3.0 → settfex-0.3.1}/docs/index.md +0 -0
  40. {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/API_PROTECTION_NOTE.md +0 -0
  41. {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/board_of_director.md +0 -0
  42. {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/corporate_action.md +0 -0
  43. {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/financial.md +0 -0
  44. {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/highlight_data.md +0 -0
  45. {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/list.md +0 -0
  46. {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/nvdr_holder.md +0 -0
  47. {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/price_performance.md +0 -0
  48. {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/profile_company.md +0 -0
  49. {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/profile_stock.md +0 -0
  50. {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/shareholder.md +0 -0
  51. {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/set/trading_stat.md +0 -0
  52. {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/tfex/list.md +0 -0
  53. {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/services/tfex/trading_statistics.md +0 -0
  54. {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/utils/data_fetcher.md +0 -0
  55. {settfex-0.3.0 → settfex-0.3.1}/docs/settfex/utils/session_caching.md +0 -0
  56. {settfex-0.3.0 → settfex-0.3.1}/docs/solution/FINDINGS.md +0 -0
  57. {settfex-0.3.0 → settfex-0.3.1}/docs/solution/Performance Boost Session Caching Implementation.md +0 -0
  58. {settfex-0.3.0 → settfex-0.3.1}/docs/solution/SESSION_CACHE_SUMMARY.md +0 -0
  59. {settfex-0.3.0 → settfex-0.3.1}/docs/solution/SOLUTION_100_PERCENT.md +0 -0
  60. {settfex-0.3.0 → settfex-0.3.1}/examples/README.md +0 -0
  61. {settfex-0.3.0 → settfex-0.3.1}/examples/set/01_stock_list.ipynb +0 -0
  62. {settfex-0.3.0 → settfex-0.3.1}/examples/set/02_highlight_data.ipynb +0 -0
  63. {settfex-0.3.0 → settfex-0.3.1}/examples/set/03_stock_profile.ipynb +0 -0
  64. {settfex-0.3.0 → settfex-0.3.1}/examples/set/04_company_profile.ipynb +0 -0
  65. {settfex-0.3.0 → settfex-0.3.1}/examples/set/05_corporate_action.ipynb +0 -0
  66. {settfex-0.3.0 → settfex-0.3.1}/examples/set/06_shareholder.ipynb +0 -0
  67. {settfex-0.3.0 → settfex-0.3.1}/examples/set/07_nvdr_holder.ipynb +0 -0
  68. {settfex-0.3.0 → settfex-0.3.1}/examples/set/08_board_of_director.ipynb +0 -0
  69. {settfex-0.3.0 → settfex-0.3.1}/examples/set/09_trading_statistics.ipynb +0 -0
  70. {settfex-0.3.0 → settfex-0.3.1}/examples/set/10_price_performance.ipynb +0 -0
  71. {settfex-0.3.0 → settfex-0.3.1}/examples/set/11_financial.ipynb +0 -0
  72. {settfex-0.3.0 → settfex-0.3.1}/examples/set/README.md +0 -0
  73. {settfex-0.3.0 → settfex-0.3.1}/examples/set/cpall_balance_sheet.csv +0 -0
  74. {settfex-0.3.0 → settfex-0.3.1}/examples/set/market_dashboard_20251005_173221.csv +0 -0
  75. {settfex-0.3.0 → settfex-0.3.1}/examples/set/market_dashboard_20251005_210737.csv +0 -0
  76. {settfex-0.3.0 → settfex-0.3.1}/examples/set/market_dashboard_20251006_091440.csv +0 -0
  77. {settfex-0.3.0 → settfex-0.3.1}/examples/set/thailand_banking_stocks.csv +0 -0
  78. {settfex-0.3.0 → settfex-0.3.1}/examples/set/thailand_set_main_board.csv +0 -0
  79. {settfex-0.3.0 → settfex-0.3.1}/examples/set/thailand_stock_universe.csv +0 -0
  80. {settfex-0.3.0 → settfex-0.3.1}/examples/tfex/01_series_list.ipynb +0 -0
  81. {settfex-0.3.0 → settfex-0.3.1}/examples/tfex/02_trading_statistics.ipynb +0 -0
  82. {settfex-0.3.0 → settfex-0.3.1}/examples/tfex/03_underlying_price.ipynb +0 -0
  83. {settfex-0.3.0 → settfex-0.3.1}/settfex/py.typed +0 -0
  84. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/__init__.py +0 -0
  85. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/__init__.py +0 -0
  86. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/constants.py +0 -0
  87. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/list.py +0 -0
  88. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/__init__.py +0 -0
  89. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/board_of_director.py +0 -0
  90. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/chart_quotation.py +0 -0
  91. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/corporate_action.py +0 -0
  92. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/financial/__init__.py +0 -0
  93. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/financial/financial.py +0 -0
  94. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/highlight_data.py +0 -0
  95. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/latest_historical_trading.py +0 -0
  96. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/nvdr_holder.py +0 -0
  97. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/price_performance.py +0 -0
  98. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/profile_company.py +0 -0
  99. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/profile_stock.py +0 -0
  100. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/shareholder.py +0 -0
  101. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/stock.py +0 -0
  102. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/trading_stat.py +0 -0
  103. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/set/stock/utils.py +0 -0
  104. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/tfex/__init__.py +0 -0
  105. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/tfex/constants.py +0 -0
  106. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/tfex/list.py +0 -0
  107. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/tfex/trading_statistics.py +0 -0
  108. {settfex-0.3.0 → settfex-0.3.1}/settfex/services/tfex/underlying_price.py +0 -0
  109. {settfex-0.3.0 → settfex-0.3.1}/settfex/utils/__init__.py +0 -0
  110. {settfex-0.3.0 → settfex-0.3.1}/settfex/utils/data_fetcher.py +0 -0
  111. {settfex-0.3.0 → settfex-0.3.1}/settfex/utils/http.py +0 -0
  112. {settfex-0.3.0 → settfex-0.3.1}/settfex/utils/logging.py +0 -0
  113. {settfex-0.3.0 → settfex-0.3.1}/settfex/utils/parsing.py +0 -0
  114. {settfex-0.3.0 → settfex-0.3.1}/settfex/utils/session_manager.py +0 -0
  115. {settfex-0.3.0 → settfex-0.3.1}/tests/__init__.py +0 -0
  116. {settfex-0.3.0 → settfex-0.3.1}/tests/conftest.py +0 -0
  117. {settfex-0.3.0 → settfex-0.3.1}/tests/services/__init__.py +0 -0
  118. {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/__init__.py +0 -0
  119. {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/stock/financial/__init__.py +0 -0
  120. {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/stock/financial/test_financial.py +0 -0
  121. {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/test_board_of_director.py +0 -0
  122. {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/test_client.py +0 -0
  123. {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/test_corporate_action.py +0 -0
  124. {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/test_historical.py +0 -0
  125. {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/test_realtime.py +0 -0
  126. {settfex-0.3.0 → settfex-0.3.1}/tests/services/set/test_shareholder.py +0 -0
  127. {settfex-0.3.0 → settfex-0.3.1}/tests/services/tfex/__init__.py +0 -0
  128. {settfex-0.3.0 → settfex-0.3.1}/tests/services/tfex/test_client.py +0 -0
  129. {settfex-0.3.0 → settfex-0.3.1}/tests/services/tfex/test_historical.py +0 -0
  130. {settfex-0.3.0 → settfex-0.3.1}/tests/services/tfex/test_list.py +0 -0
  131. {settfex-0.3.0 → settfex-0.3.1}/tests/services/tfex/test_realtime.py +0 -0
  132. {settfex-0.3.0 → settfex-0.3.1}/tests/services/tfex/test_trading_statistics.py +0 -0
  133. {settfex-0.3.0 → settfex-0.3.1}/tests/services/tfex/test_underlying_price.py +0 -0
  134. {settfex-0.3.0 → settfex-0.3.1}/tests/utils/__init__.py +0 -0
  135. {settfex-0.3.0 → settfex-0.3.1}/tests/utils/test_data_fetcher.py +0 -0
  136. {settfex-0.3.0 → settfex-0.3.1}/tests/utils/test_formatting.py +0 -0
  137. {settfex-0.3.0 → settfex-0.3.1}/tests/utils/test_http.py +0 -0
  138. {settfex-0.3.0 → settfex-0.3.1}/tests/utils/test_logging.py +0 -0
  139. {settfex-0.3.0 → settfex-0.3.1}/tests/utils/test_parsing.py +0 -0
  140. {settfex-0.3.0 → settfex-0.3.1}/tests/utils/test_session_manager.py +0 -0
  141. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: settfex
3
- Version: 0.3.0
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.0"
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" }
@@ -19,7 +19,7 @@ Usage:
19
19
  >>> asyncio.run(main())
20
20
  """
21
21
 
22
- __version__ = "0.3.0"
22
+ __version__ = "0.3.1"
23
23
  __author__ = "batt"
24
24
  __license__ = "MIT"
25
25
 
@@ -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