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.
- wardy_utils-0.4.1/PKG-INFO +167 -0
- wardy_utils-0.4.1/README.md +144 -0
- wardy_utils-0.4.1/pyproject.toml +45 -0
- wardy_utils-0.4.1/src/wardy_utils/__init__.py +0 -0
- wardy_utils-0.4.1/src/wardy_utils/log.py +176 -0
- wardy_utils-0.4.1/src/wardy_utils/py.typed +0 -0
- wardy_utils-0.4.1/src/wardy_utils/web.py +195 -0
|
@@ -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)
|