wardy-utils 0.4.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.
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: wardy-utils
3
+ Version: 0.4.1
4
+ Summary: General Utilities
5
+ Author: Wardy
6
+ Author-email: Wardy <wardy3+gitlab@gmail.com>
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Dist: pydantic-settings>=2.12.0
16
+ Requires-Dist: loguru>=0.7.3 ; extra == 'log'
17
+ Requires-Dist: hishel[httpx]>=1.1.8 ; extra == 'web'
18
+ Requires-Dist: httpx[brotli,http2,zstd]>=0.28.1 ; extra == 'web'
19
+ Requires-Python: >=3.13
20
+ Provides-Extra: log
21
+ Provides-Extra: web
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Wardy Utils
25
+
26
+ **Wardy Utils** is a collection of general-purpose utilities designed to simplify and enhance your Python scripting experience. This library provides reusable components that can be integrated into your projects to save time and effort.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install wardy-utils
32
+ ```
33
+
34
+ The base package only includes `pydantic-settings`. Install extras for additional functionality:
35
+
36
+ ```bash
37
+ # Logging with Loguru
38
+ pip install wardy-utils[log]
39
+
40
+ # HTTP client with caching
41
+ pip install wardy-utils[web]
42
+
43
+ # Both
44
+ pip install wardy-utils[log,web]
45
+ ```
46
+
47
+ ## Features
48
+
49
+ ### Logging (`wardy-utils[log]`)
50
+
51
+ Flexible logging setup using [Loguru](https://github.com/Delgan/loguru), with optional [Logfire](https://logfire.pydantic.dev/) integration for cloud logging and metrics.
52
+
53
+ **Key features:**
54
+ - Simple setup for rotating file and stderr logging
55
+ - Intercepts standard Python logging and redirects to Loguru
56
+ - Optional Logfire integration for cloud log aggregation
57
+ - Automatic instrumentation of available libraries (system-metrics, psycopg, httpx, sqlalchemy, redis, asyncpg)
58
+
59
+ **Basic usage:**
60
+
61
+ ```python
62
+ from wardy_utils.log import configure_logging, logger
63
+
64
+ # Set up logging to both stderr and a rotating file
65
+ configure_logging("myapp")
66
+
67
+ logger.info("Hello from Loguru!")
68
+
69
+ # Standard logging is also intercepted:
70
+ import logging
71
+ logging.warning("This will also go to Loguru!")
72
+ ```
73
+
74
+ **With Logfire cloud logging:**
75
+
76
+ ```python
77
+ from wardy_utils.log import configure_logging, logger
78
+
79
+ # service_name is required when using Logfire
80
+ configure_logging("myapp", service_name="my-service")
81
+
82
+ logger.info("This goes to Logfire too!")
83
+ ```
84
+
85
+ Set `WARDY_UTILS_LOG_LOGFIRE_TOKEN` to enable Logfire. You must have `logfire` installed separately:
86
+
87
+ ```bash
88
+ pip install logfire[system-metrics,psycopg,httpx] # with desired extras
89
+ ```
90
+
91
+ **Customization:**
92
+
93
+ ```python
94
+ configure_logging(
95
+ "myapp",
96
+ service_name="my-service", # Required for Logfire
97
+ standard_format="[{time}] {level} - {message}",
98
+ detail_format="{time} {file}:{line} {level} {message}",
99
+ log_rotation="1 day",
100
+ log_retention="7 days",
101
+ )
102
+ ```
103
+
104
+ **Environment variables:**
105
+
106
+ | Variable | Description | Default |
107
+ | -------- | ----------- | ------- |
108
+ | `WARDY_UTILS_LOG_LOGFIRE_TOKEN` | Token for Logfire cloud logging | (disabled) |
109
+
110
+ ### HTTP Client (`wardy-utils[web]`)
111
+
112
+ A high-level HTTP client built on [httpx](https://www.python-httpx.org/) and [hishel](https://hishel.com/) with built-in caching.
113
+
114
+ **Basic usage:**
115
+
116
+ ```python
117
+ from wardy_utils.web import cached_client
118
+
119
+ client = cached_client()
120
+ response = client.get("https://example.com")
121
+ print(response.text)
122
+ ```
123
+
124
+ **Pre-configured singletons:**
125
+
126
+ ```python
127
+ from wardy_utils.web import sync_client, async_client
128
+
129
+ # Sync client with default settings
130
+ response = sync_client.get("https://example.com")
131
+
132
+ # Or use async
133
+ async def fetch():
134
+ response = await async_client.get("https://example.com")
135
+ ```
136
+
137
+ Available singletons:
138
+ - `sync_client`: Default sync client
139
+ - `sync_force_client`: Sync client that ignores origin cache headers
140
+ - `async_client`: Default async client
141
+ - `async_force_client`: Async client that ignores origin cache headers
142
+
143
+ **Environment variables:**
144
+
145
+ | Variable | Description | Default |
146
+ | -------- | ----------- | ------- |
147
+ | `WARDY_UTILS_WEB_CACHE_DIR` | Directory for the sqlite cache file | In-memory |
148
+ | `WARDY_UTILS_WEB_CACHE_FILENAME` | Filename for the sqlite cache file | `wardy_cache.db` |
149
+ | `WARDY_UTILS_WEB_CACHE_TTL` | Cache TTL in seconds | `1800` (30 min) |
150
+ | `WARDY_UTILS_WEB_TIMEOUT` | Request timeout in seconds | `45` |
151
+ | `WARDY_UTILS_WEB_FORCE_CACHE` | If truthy, ignore origin cache-control headers | Disabled |
152
+ | `WARDY_UTILS_WEB_HTTP2` | Enable or disable HTTP/2 | Enabled |
153
+
154
+ **Proxy support:** The client inherits proxy settings from standard `http_proxy`, `https_proxy`, and `all_proxy` environment variables.
155
+
156
+ ## Requirements
157
+
158
+ - Python 3.13 or higher
159
+
160
+ ## License
161
+
162
+ This project is licensed under the MIT License.
163
+
164
+ ## Author
165
+
166
+ Created by **Wardy**
167
+ Email: [wardy3+gitlab@gmail.com](mailto:wardy3+gitlab@gmail.com)
@@ -0,0 +1,144 @@
1
+ # Wardy Utils
2
+
3
+ **Wardy Utils** is a collection of general-purpose utilities designed to simplify and enhance your Python scripting experience. This library provides reusable components that can be integrated into your projects to save time and effort.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install wardy-utils
9
+ ```
10
+
11
+ The base package only includes `pydantic-settings`. Install extras for additional functionality:
12
+
13
+ ```bash
14
+ # Logging with Loguru
15
+ pip install wardy-utils[log]
16
+
17
+ # HTTP client with caching
18
+ pip install wardy-utils[web]
19
+
20
+ # Both
21
+ pip install wardy-utils[log,web]
22
+ ```
23
+
24
+ ## Features
25
+
26
+ ### Logging (`wardy-utils[log]`)
27
+
28
+ Flexible logging setup using [Loguru](https://github.com/Delgan/loguru), with optional [Logfire](https://logfire.pydantic.dev/) integration for cloud logging and metrics.
29
+
30
+ **Key features:**
31
+ - Simple setup for rotating file and stderr logging
32
+ - Intercepts standard Python logging and redirects to Loguru
33
+ - Optional Logfire integration for cloud log aggregation
34
+ - Automatic instrumentation of available libraries (system-metrics, psycopg, httpx, sqlalchemy, redis, asyncpg)
35
+
36
+ **Basic usage:**
37
+
38
+ ```python
39
+ from wardy_utils.log import configure_logging, logger
40
+
41
+ # Set up logging to both stderr and a rotating file
42
+ configure_logging("myapp")
43
+
44
+ logger.info("Hello from Loguru!")
45
+
46
+ # Standard logging is also intercepted:
47
+ import logging
48
+ logging.warning("This will also go to Loguru!")
49
+ ```
50
+
51
+ **With Logfire cloud logging:**
52
+
53
+ ```python
54
+ from wardy_utils.log import configure_logging, logger
55
+
56
+ # service_name is required when using Logfire
57
+ configure_logging("myapp", service_name="my-service")
58
+
59
+ logger.info("This goes to Logfire too!")
60
+ ```
61
+
62
+ Set `WARDY_UTILS_LOG_LOGFIRE_TOKEN` to enable Logfire. You must have `logfire` installed separately:
63
+
64
+ ```bash
65
+ pip install logfire[system-metrics,psycopg,httpx] # with desired extras
66
+ ```
67
+
68
+ **Customization:**
69
+
70
+ ```python
71
+ configure_logging(
72
+ "myapp",
73
+ service_name="my-service", # Required for Logfire
74
+ standard_format="[{time}] {level} - {message}",
75
+ detail_format="{time} {file}:{line} {level} {message}",
76
+ log_rotation="1 day",
77
+ log_retention="7 days",
78
+ )
79
+ ```
80
+
81
+ **Environment variables:**
82
+
83
+ | Variable | Description | Default |
84
+ | -------- | ----------- | ------- |
85
+ | `WARDY_UTILS_LOG_LOGFIRE_TOKEN` | Token for Logfire cloud logging | (disabled) |
86
+
87
+ ### HTTP Client (`wardy-utils[web]`)
88
+
89
+ A high-level HTTP client built on [httpx](https://www.python-httpx.org/) and [hishel](https://hishel.com/) with built-in caching.
90
+
91
+ **Basic usage:**
92
+
93
+ ```python
94
+ from wardy_utils.web import cached_client
95
+
96
+ client = cached_client()
97
+ response = client.get("https://example.com")
98
+ print(response.text)
99
+ ```
100
+
101
+ **Pre-configured singletons:**
102
+
103
+ ```python
104
+ from wardy_utils.web import sync_client, async_client
105
+
106
+ # Sync client with default settings
107
+ response = sync_client.get("https://example.com")
108
+
109
+ # Or use async
110
+ async def fetch():
111
+ response = await async_client.get("https://example.com")
112
+ ```
113
+
114
+ Available singletons:
115
+ - `sync_client`: Default sync client
116
+ - `sync_force_client`: Sync client that ignores origin cache headers
117
+ - `async_client`: Default async client
118
+ - `async_force_client`: Async client that ignores origin cache headers
119
+
120
+ **Environment variables:**
121
+
122
+ | Variable | Description | Default |
123
+ | -------- | ----------- | ------- |
124
+ | `WARDY_UTILS_WEB_CACHE_DIR` | Directory for the sqlite cache file | In-memory |
125
+ | `WARDY_UTILS_WEB_CACHE_FILENAME` | Filename for the sqlite cache file | `wardy_cache.db` |
126
+ | `WARDY_UTILS_WEB_CACHE_TTL` | Cache TTL in seconds | `1800` (30 min) |
127
+ | `WARDY_UTILS_WEB_TIMEOUT` | Request timeout in seconds | `45` |
128
+ | `WARDY_UTILS_WEB_FORCE_CACHE` | If truthy, ignore origin cache-control headers | Disabled |
129
+ | `WARDY_UTILS_WEB_HTTP2` | Enable or disable HTTP/2 | Enabled |
130
+
131
+ **Proxy support:** The client inherits proxy settings from standard `http_proxy`, `https_proxy`, and `all_proxy` environment variables.
132
+
133
+ ## Requirements
134
+
135
+ - Python 3.13 or higher
136
+
137
+ ## License
138
+
139
+ This project is licensed under the MIT License.
140
+
141
+ ## Author
142
+
143
+ Created by **Wardy**
144
+ Email: [wardy3+gitlab@gmail.com](mailto:wardy3+gitlab@gmail.com)
@@ -0,0 +1,45 @@
1
+ [project]
2
+ authors = [{ name = "Wardy", email = "wardy3+gitlab@gmail.com" }]
3
+ description = "General Utilities"
4
+ license = "MIT"
5
+ name = "wardy-utils"
6
+ readme = { file = "README.md", content-type = "text/markdown" }
7
+ requires-python = ">=3.13"
8
+ version = "0.4.1"
9
+
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Topic :: Software Development :: Libraries :: Python Modules",
18
+ ]
19
+
20
+ dependencies = ["pydantic-settings>=2.12.0"]
21
+
22
+ [project.optional-dependencies]
23
+ log = ["loguru>=0.7.3"]
24
+ web = ["hishel[httpx]>=1.1.8", "httpx[brotli,http2,zstd]>=0.28.1"]
25
+
26
+ [dependency-groups]
27
+ dev = ["common-tools[dev]>=0.1.0"]
28
+ test = ["common-tools[test]>=0.3.0"]
29
+
30
+ [tool.uv.sources]
31
+ common-tools = { path = "../common-tools" }
32
+
33
+ [build-system]
34
+ build-backend = "uv_build"
35
+ requires = ["uv_build>=0.9.7,<0.10.0"]
36
+
37
+ [tool.pytest.ini_options]
38
+ addopts = "-v --capture=tee-sys --durations=3 -p no:pastebin -p no:nose -p no:doctest"
39
+ asyncio_default_fixture_loop_scope = "function"
40
+ log_level = "DEBUG"
41
+ pythonpath = [".", "src", "tests"]
42
+ xfail_strict = true
43
+
44
+ [tool.coverage.report]
45
+ exclude_lines = ["@.*overload", "pragma: no cover", 'class \w+\((typing.|tp.)?Protocol\)']
File without changes
@@ -0,0 +1,176 @@
1
+ """Set up logging using the Loguru library.
2
+
3
+ Requires the 'log' extra: pip install wardy-utils[log]
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import inspect
9
+ import logging
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Final
13
+
14
+ from pydantic_settings import BaseSettings, SettingsConfigDict
15
+
16
+ if TYPE_CHECKING: # pragma: no cover
17
+ from collections.abc import Callable
18
+
19
+ try:
20
+ from loguru import logger
21
+ except ImportError as e: # pragma: no cover
22
+ msg = "loguru is required for wardy_utils.log. Install with: pip install wardy-utils[log]"
23
+ raise ImportError(msg) from e
24
+
25
+ try:
26
+ import logfire
27
+ except ImportError: # pragma: no cover
28
+ logfire = None
29
+
30
+
31
+ # ----- Settings -----
32
+
33
+
34
+ class LogSettings(BaseSettings):
35
+ """Environment-driven settings for logging."""
36
+
37
+ logfire_token: str = ""
38
+ logfire_service_name: str = ""
39
+ logfire_env_prefix: str = ""
40
+
41
+ model_config = SettingsConfigDict(env_prefix="WARDY_UTILS_LOG_", case_sensitive=False)
42
+
43
+
44
+ # ----- Constants -----
45
+
46
+ STANDARD: Final = "[{time:HH:mm:ss}] {level} - {message}"
47
+ DETAIL: Final = "{time} {file:>25}:{line:<4} {level:<8} {message}"
48
+
49
+ # ----- Public API -----
50
+
51
+ __all__ = [
52
+ "DETAIL",
53
+ "STANDARD",
54
+ "LogSettings",
55
+ "configure_logfire",
56
+ "configure_logging",
57
+ "logger",
58
+ ]
59
+
60
+
61
+ def configure_logging(
62
+ log_filename: str | Path,
63
+ *,
64
+ service_name: str | None = None,
65
+ standard_format: str = STANDARD,
66
+ detail_format: str = DETAIL,
67
+ log_rotation: str = "1 hour",
68
+ log_retention: str = "7 days",
69
+ ) -> None:
70
+ """Setup Loguru logging for the application.
71
+
72
+ Args:
73
+ log_filename: Base name for the log file (will have .log suffix added).
74
+ service_name: Service name for Logfire cloud logging. Required if using Logfire.
75
+ standard_format: Format for stderr output.
76
+ detail_format: Format for file and Logfire output.
77
+ log_rotation: When to rotate log files.
78
+ log_retention: How long to keep old log files.
79
+
80
+ Environment variables (prefix WARDY_UTILS_LOG_):
81
+ LOGFIRE_TOKEN: Token for Logfire cloud logging.
82
+ """
83
+ settings = LogSettings()
84
+
85
+ # Capture things like Hishel logging
86
+ intercept_logging()
87
+
88
+ # Replace the default StdErr handler.
89
+ logger.remove()
90
+ logger.add(sys.stderr, level="WARNING", format=standard_format)
91
+
92
+ # Add a rotating file handler.
93
+ log_filename = Path(log_filename).with_suffix(".log")
94
+ logger.add(
95
+ log_filename,
96
+ level="DEBUG",
97
+ format=detail_format,
98
+ rotation=log_rotation,
99
+ retention=log_retention,
100
+ )
101
+
102
+ # Set up Logfire if token is configured
103
+ if settings.logfire_token:
104
+ configure_logfire(settings.logfire_token, service_name, detail_format)
105
+
106
+
107
+ def configure_logfire(token: str, service_name: str | None, log_format: str) -> None:
108
+ """Configure Logfire cloud logging with available instrumentations.
109
+
110
+ Args:
111
+ token: Logfire API token.
112
+ service_name: Service name for Logfire. Required.
113
+ log_format: Log format string for Logfire handler.
114
+
115
+ Raises:
116
+ ImportError: If logfire is not installed.
117
+ ValueError: If service_name is not provided.
118
+ """
119
+ if logfire is None:
120
+ msg = "logfire is required for cloud logging. Install with: pip install logfire"
121
+ raise ImportError(msg)
122
+
123
+ if not service_name:
124
+ msg = "service_name is required for Logfire"
125
+ raise ValueError(msg)
126
+
127
+ logfire.configure(token=token, service_name=service_name)
128
+ logger.add(logfire.loguru_handler()["sink"], level="TRACE", format=log_format)
129
+
130
+ # Instrument available integrations
131
+ _try_instrument("system_metrics", logfire.instrument_system_metrics)
132
+ _try_instrument("psycopg", logfire.instrument_psycopg)
133
+ _try_instrument("httpx", logfire.instrument_httpx)
134
+ _try_instrument("sqlalchemy", logfire.instrument_sqlalchemy)
135
+ _try_instrument("redis", logfire.instrument_redis)
136
+ _try_instrument("asyncpg", logfire.instrument_asyncpg)
137
+
138
+
139
+ def _try_instrument(name: str, func: Callable[[], None]) -> None:
140
+ """Try to instrument a library, logging the result."""
141
+ try:
142
+ func()
143
+ except RuntimeError:
144
+ logger.debug(f"Logfire: {name} not available")
145
+ else:
146
+ logger.debug(f"Logfire: instrumented {name}")
147
+
148
+
149
+ # ----- Interface to the standard logging module -----
150
+
151
+
152
+ class InterceptHandler(logging.Handler):
153
+ """Send logs to Loguru."""
154
+
155
+ def emit(self, record: logging.LogRecord) -> None:
156
+ """Emit a log record."""
157
+ # Get corresponding Loguru level if it exists.
158
+ level: str | int
159
+ try:
160
+ level = logger.level(record.levelname).name
161
+ except ValueError: # pragma: no cover
162
+ level = record.levelno
163
+
164
+ # Find caller from where originated the logged message.
165
+ frame, depth = inspect.currentframe(), 0
166
+ while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__):
167
+ frame = frame.f_back
168
+ depth += 1
169
+
170
+ logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
171
+
172
+
173
+ def intercept_logging() -> None:
174
+ """Intercept standard logging and send it to Loguru."""
175
+ # Configure the root logger
176
+ logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
File without changes
@@ -0,0 +1,195 @@
1
+ """General internet helpers.
2
+
3
+ Requires the 'web' extra: pip install wardy-utils[web]
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import threading
10
+ from dataclasses import dataclass
11
+ from datetime import timedelta
12
+ from pathlib import Path
13
+ from typing import Final, Literal, overload
14
+
15
+ from pydantic_settings import BaseSettings, SettingsConfigDict
16
+
17
+ try:
18
+ from hishel import AsyncSqliteStorage, FilterPolicy, SyncSqliteStorage
19
+ from hishel.httpx import AsyncCacheClient, SyncCacheClient
20
+ except ImportError as e: # pragma: no cover
21
+ msg = "hishel/httpx required for wardy_utils.web. Install: pip install wardy-utils[web]"
22
+ raise ImportError(msg) from e
23
+
24
+ logging.getLogger("httpcore").setLevel(logging.INFO)
25
+ # hishel INFO level is appropriate - DEBUG is verbose about cached responses
26
+ logging.getLogger("hishel").setLevel(logging.INFO)
27
+
28
+ # ----- Constants -----
29
+
30
+ CACHE_TTL_DEFAULT_SECONDS: Final = timedelta(minutes=30).total_seconds()
31
+ HTTP_TIMEOUT_SECONDS: Final = timedelta(seconds=45).total_seconds()
32
+
33
+
34
+ class WebSettings(BaseSettings):
35
+ """Environment-driven defaults for HTTP clients."""
36
+
37
+ cache_dir: str | Path = ""
38
+ cache_filename: str = "wardy_cache.db"
39
+ cache_ttl: float = CACHE_TTL_DEFAULT_SECONDS
40
+ timeout: float = HTTP_TIMEOUT_SECONDS
41
+ force_cache: bool = False
42
+ http2: bool = True
43
+
44
+ model_config = SettingsConfigDict(env_prefix="WARDY_UTILS_WEB_", case_sensitive=False)
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ClientConfig:
49
+ """Configuration for a cached HTTP client."""
50
+
51
+ sync: bool
52
+ force: bool
53
+ cache_db: str
54
+ ttl: float
55
+ timeout: float
56
+ http2: bool
57
+
58
+
59
+ _CLIENTS: dict[ClientConfig, SyncCacheClient | AsyncCacheClient] = {}
60
+ _CLIENTS_LOCK = threading.Lock()
61
+
62
+
63
+ def _resolve_config(
64
+ *,
65
+ sync: bool,
66
+ force: bool | None,
67
+ cache_dir: str | None,
68
+ ttl: float | None,
69
+ timeout: float | None,
70
+ http2: bool | None,
71
+ ) -> ClientConfig:
72
+ env = WebSettings()
73
+
74
+ resolved_ttl = ttl if ttl is not None else env.cache_ttl
75
+ resolved_timeout = timeout if timeout is not None else env.timeout
76
+ resolved_force = force if force is not None else env.force_cache
77
+ resolved_http2 = http2 if http2 is not None else env.http2
78
+
79
+ resolved_cache_dir = cache_dir if cache_dir is not None else env.cache_dir
80
+
81
+ if resolved_cache_dir:
82
+ cache_path = Path(resolved_cache_dir)
83
+ cache_path.mkdir(parents=True, exist_ok=True)
84
+ cache_db = str(cache_path / env.cache_filename)
85
+ else:
86
+ cache_db = ":memory:"
87
+
88
+ return ClientConfig(
89
+ sync=sync,
90
+ force=resolved_force,
91
+ cache_db=cache_db,
92
+ ttl=resolved_ttl,
93
+ timeout=resolved_timeout,
94
+ http2=resolved_http2,
95
+ )
96
+
97
+
98
+ def _build_client(config: ClientConfig) -> SyncCacheClient | AsyncCacheClient:
99
+ storage: SyncSqliteStorage | AsyncSqliteStorage
100
+ if config.cache_db != ":memory:":
101
+ Path(config.cache_db).touch(exist_ok=True)
102
+
103
+ if config.sync:
104
+ storage = SyncSqliteStorage(database_path=config.cache_db, default_ttl=config.ttl)
105
+ else:
106
+ storage = AsyncSqliteStorage(database_path=config.cache_db, default_ttl=config.ttl)
107
+
108
+ policy = FilterPolicy() if config.force else None
109
+ client_class = SyncCacheClient if config.sync else AsyncCacheClient
110
+
111
+ return client_class(
112
+ follow_redirects=True,
113
+ storage=storage,
114
+ policy=policy,
115
+ timeout=config.timeout,
116
+ http2=config.http2,
117
+ )
118
+
119
+
120
+ # ----- Handle httpx client and hishel caching -----
121
+
122
+
123
+ @overload
124
+ def cached_client() -> SyncCacheClient: ...
125
+ @overload
126
+ def cached_client(
127
+ *,
128
+ sync: Literal[True],
129
+ force: bool | None = ...,
130
+ ttl: float | None = ...,
131
+ timeout: float | None = ...,
132
+ cache_dir: str | None = ...,
133
+ http2: bool | None = ...,
134
+ ) -> SyncCacheClient: ...
135
+ @overload
136
+ def cached_client(
137
+ *,
138
+ sync: Literal[False],
139
+ force: bool | None = ...,
140
+ ttl: float | None = ...,
141
+ timeout: float | None = ...,
142
+ cache_dir: str | None = ...,
143
+ http2: bool | None = ...,
144
+ ) -> AsyncCacheClient: ...
145
+
146
+
147
+ def cached_client(
148
+ *,
149
+ sync: bool = True,
150
+ force: bool | None = None,
151
+ ttl: float | None = None,
152
+ timeout: float | None = None,
153
+ cache_dir: str | None = None,
154
+ http2: bool | None = None,
155
+ ) -> SyncCacheClient | AsyncCacheClient:
156
+ """Return a cached HTTPX client (sync or async).
157
+
158
+ By default, uses in-memory cache (safe for production/FastAPI).
159
+ In test environments, set WARDY_UTILS_CACHE_DIR to enable
160
+ persistent filesystem cache across test runs.
161
+
162
+ Args:
163
+ sync (bool): Whether to use sync or async client. Defaults to True.
164
+ force (bool | None): Whether to force cache regardless of origin headers.
165
+ Uses FilterPolicy which ignores cache-control directives.
166
+ Defaults to env WARDY_UTILS_FORCE_CACHE or False.
167
+ ttl (float | None): Time to live for cache entries in seconds.
168
+ Defaults to env WARDY_UTILS_CACHE_TTL or 30 minutes.
169
+ timeout (float | None): Timeout for requests in seconds.
170
+ Defaults to env WARDY_UTILS_TIMEOUT or 45 seconds.
171
+ cache_dir (str | None): Directory to use for persistent cache. If not set,
172
+ uses env WARDY_UTILS_CACHE_DIR or in-memory cache.
173
+ http2 (bool | None): Toggle HTTP/2 support. Defaults to env WARDY_UTILS_HTTP2 or True.
174
+
175
+ Returns:
176
+ SyncCacheClient | AsyncCacheClient: A cached HTTPX client.
177
+ """
178
+ config = _resolve_config(
179
+ sync=sync, force=force, cache_dir=cache_dir, ttl=ttl, timeout=timeout, http2=http2
180
+ )
181
+
182
+ with _CLIENTS_LOCK:
183
+ client = _CLIENTS.get(config)
184
+
185
+ if client is None or getattr(client, "is_closed", False):
186
+ client = _build_client(config)
187
+ _CLIENTS[config] = client
188
+
189
+ return client
190
+
191
+
192
+ sync_client = cached_client(sync=True)
193
+ sync_force_client = cached_client(sync=True, force=True)
194
+ async_client = cached_client(sync=False)
195
+ async_force_client = cached_client(sync=False, force=True)