unique_toolkit 2026.24.0.dev6__py3-none-any.whl → 2026.24.0.dev8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unique_toolkit/experimental/README.md +11 -9
- unique_toolkit/experimental/resources/__init__.py +3 -1
- unique_toolkit/experimental/resources/feature_flags/README.md +183 -0
- unique_toolkit/experimental/resources/feature_flags/__init__.py +43 -0
- unique_toolkit/experimental/resources/feature_flags/_graphql_client.py +57 -0
- unique_toolkit/experimental/resources/feature_flags/_ttl_cache.py +65 -0
- unique_toolkit/experimental/resources/feature_flags/client.py +215 -0
- unique_toolkit/experimental/resources/feature_flags/schemas.py +17 -0
- unique_toolkit/experimental/resources/feature_flags/settings.py +33 -0
- unique_toolkit/monitoring/gunicorn.py +24 -0
- unique_toolkit/monitoring/middleware.py +4 -3
- unique_toolkit/monitoring/registry.py +58 -4
- {unique_toolkit-2026.24.0.dev6.dist-info → unique_toolkit-2026.24.0.dev8.dist-info}/METADATA +3 -2
- {unique_toolkit-2026.24.0.dev6.dist-info → unique_toolkit-2026.24.0.dev8.dist-info}/RECORD +15 -7
- {unique_toolkit-2026.24.0.dev6.dist-info → unique_toolkit-2026.24.0.dev8.dist-info}/WHEEL +0 -0
|
@@ -31,7 +31,7 @@ flowchart TD
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
internal["<b>_internal/</b><br/><i>package-private primitives</i>"]
|
|
34
|
-
sdk[("<b>unique_sdk
|
|
34
|
+
sdk[("<b>unique_sdk / config-backend / …</b><br/><i>internal Unique AI backends</i>")]
|
|
35
35
|
|
|
36
36
|
integrations ==> facades
|
|
37
37
|
integrations -.-> primitives
|
|
@@ -75,9 +75,9 @@ API. Think: HTTP plumbing, Pydantic helpers, time/string/exception utilities.
|
|
|
75
75
|
users, promote it into `resources/`, `components/`, or
|
|
76
76
|
`integrations/` — whichever layer fits — and re-export it there.
|
|
77
77
|
|
|
78
|
-
### `resources/<name>/` — typed adapters over
|
|
78
|
+
### `resources/<name>/` — typed adapters over internal Unique AI backends
|
|
79
79
|
|
|
80
|
-
One subfolder per
|
|
80
|
+
One subfolder per backend resource family. The canonical shape is:
|
|
81
81
|
|
|
82
82
|
```
|
|
83
83
|
resources/<name>/
|
|
@@ -87,15 +87,17 @@ resources/<name>/
|
|
|
87
87
|
__init__.py # re-exports
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
"Backend" is deliberately broad: `unique_sdk`, configuration-backend,
|
|
91
|
+
and any other internal Unique AI service are all valid targets. A module
|
|
92
|
+
belongs here **iff** it is a thin, typed adapter — pure schemas + a
|
|
93
|
+
slightly nicer call surface — with no cross-cutting orchestration of its
|
|
94
|
+
own.
|
|
93
95
|
|
|
94
96
|
- **May import:** `unique_sdk`, `_internal/`, stdlib, third-party libs.
|
|
95
97
|
- **Must not import:** sibling resources (see `facades/` below for the
|
|
96
98
|
exception), `components/`, `integrations/`.
|
|
97
99
|
- **Concrete examples in-tree today:** `user/`, `group/`,
|
|
98
|
-
`content_folder/`, `scheduled_task/`.
|
|
100
|
+
`content_folder/`, `scheduled_task/`, `feature_flags/`.
|
|
99
101
|
- **Resources** should be stateless classes
|
|
100
102
|
|
|
101
103
|
### `resources/facades/<name>/` — aggregating resources
|
|
@@ -208,7 +210,7 @@ way to the bottom is a signal that the module probably does not belong in
|
|
|
208
210
|
```mermaid
|
|
209
211
|
flowchart TD
|
|
210
212
|
start(["I'm adding new code"])
|
|
211
|
-
q1{"
|
|
213
|
+
q1{"Typed adapter over an internal<br/>Unique AI backend<br/>(unique_sdk, config-backend, …)?"}
|
|
212
214
|
q2{"Resource-shaped facade —<br/>composes 2+ primitives with<br/>one-liner delegation methods?"}
|
|
213
215
|
q3{"Adds behaviour beyond the SDK<br/>(orchestration, parallelism,<br/>derived views, evaluation, …)<br/><b>and</b> framework-agnostic?"}
|
|
214
216
|
q4{"Adapter for a specific<br/>third-party framework?"}
|
|
@@ -223,7 +225,7 @@ flowchart TD
|
|
|
223
225
|
|
|
224
226
|
start --> q1
|
|
225
227
|
q1 -- yes --> primitive
|
|
226
|
-
q1 -- no
|
|
228
|
+
q1 -- no --> q2
|
|
227
229
|
q2 -- yes --> facade
|
|
228
230
|
q2 -- no --> q3
|
|
229
231
|
q3 -- yes --> component
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Typed adapters over ``unique_sdk
|
|
1
|
+
"""Typed adapters over internal Unique AI backends (``unique_sdk``, configuration-backend, etc.).
|
|
2
2
|
|
|
3
3
|
Thin wrappers around individual SDK resources that add little beyond
|
|
4
4
|
Pydantic schemas and sync/async function pairs. The canonical shape is::
|
|
@@ -53,6 +53,8 @@ Planned contents (current home → new home):
|
|
|
53
53
|
* ``scheduled_task`` ← :mod:`unique_toolkit.experimental.scheduled_task`
|
|
54
54
|
* ``facades.identity`` ← :mod:`unique_toolkit.experimental.identity`
|
|
55
55
|
(the :class:`Identity` facade bundling the :class:`Users` and :class:`Groups` services)
|
|
56
|
+
* ``feature_flags`` — configuration-backend GraphQL adapter; evaluates feature flags
|
|
57
|
+
with TTL cache, stale-value fallback, and env-var fallback
|
|
56
58
|
|
|
57
59
|
One thing that *looks* like it should live here but does not:
|
|
58
60
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# `feature_flags` — Python feature flag client
|
|
2
|
+
|
|
3
|
+
Remote feature flag evaluation backed by configuration-backend's GraphQL API,
|
|
4
|
+
with in-process TTL caching and graceful env-var fallback.
|
|
5
|
+
|
|
6
|
+
Mirrors the OpenFeature `evaluate` / `isEnabled` semantics used by the
|
|
7
|
+
Node.js `@unique/feature-flags` package. The OpenFeature Python SDK is
|
|
8
|
+
deliberately **not** used as a dependency — the interface is implemented
|
|
9
|
+
directly to avoid SDK overhead.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Environment variables
|
|
14
|
+
|
|
15
|
+
| Variable | Required | Default | Description |
|
|
16
|
+
|---|---|---|---|
|
|
17
|
+
| `CONFIGURATION_BACKEND_URL` | **Yes** | — | Base URL of your configuration-backend instance. |
|
|
18
|
+
| `FEATURE_FLAG_SERVICE_ID` | **Yes** | — | Service identifier sent as `x-service-id`. Must match a registered value in configuration-backend's `Service` enum. |
|
|
19
|
+
| `FEATURE_FLAG_CACHE_TTL_MS` | No | `5000` | In-process cache TTL in milliseconds. |
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## `flag` argument convention
|
|
24
|
+
|
|
25
|
+
The `flag` argument to `evaluate()` / `is_enabled()` must be the
|
|
26
|
+
**upper-snake env-var-style key**, e.g.:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
FEATURE_FLAG_ENABLE_MY_FEATURE
|
|
30
|
+
FEATURE_FLAG_ENABLE_OTHER_FEATURE_UN_12345
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
This matches both the configuration-backend registry key convention and the
|
|
34
|
+
existing env-var names in Python services, so the env-var fallback path
|
|
35
|
+
requires no transformation.
|
|
36
|
+
|
|
37
|
+
Define flag name constants in your service (not in the toolkit) to avoid
|
|
38
|
+
magic strings:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
# in your service
|
|
42
|
+
class MyServiceFlags:
|
|
43
|
+
ENABLE_MY_FEATURE = "FEATURE_FLAG_ENABLE_MY_FEATURE"
|
|
44
|
+
ENABLE_OTHER_FEATURE = "FEATURE_FLAG_ENABLE_OTHER_FEATURE_UN_12345"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
### Constructor injection (recommended for testing)
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from unique_toolkit.experimental.resources.feature_flags import FeatureFlagClient
|
|
55
|
+
|
|
56
|
+
client = FeatureFlagClient(
|
|
57
|
+
url="https://your-configuration-backend",
|
|
58
|
+
service_id="my-service",
|
|
59
|
+
ttl_ms=5_000,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
result = await client.evaluate(
|
|
63
|
+
"FEATURE_FLAG_ENABLE_MY_FEATURE",
|
|
64
|
+
company_id=company_id,
|
|
65
|
+
user_id=user_id,
|
|
66
|
+
)
|
|
67
|
+
# result.value → bool
|
|
68
|
+
# result.reason → "remote" | "cached" | "stale" | "fallback"
|
|
69
|
+
|
|
70
|
+
enabled = await client.is_enabled(
|
|
71
|
+
"FEATURE_FLAG_ENABLE_MY_FEATURE",
|
|
72
|
+
company_id=company_id,
|
|
73
|
+
user_id=user_id,
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### `from_settings()` factory (recommended for services)
|
|
78
|
+
|
|
79
|
+
`from_settings()` is a process-level singleton — repeated calls always return
|
|
80
|
+
the same instance, keeping the TTL cache warm across requests.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
# Call lazily — inside a request handler or app startup hook,
|
|
84
|
+
# NOT at module import time. Env vars must be fully injected before
|
|
85
|
+
# the first call or a ValueError is raised.
|
|
86
|
+
client = FeatureFlagClient.from_settings()
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
> **⚠️ Do not call `from_settings()` at module level.** In some container
|
|
90
|
+
> runtimes, env vars are not yet available when Python modules are imported.
|
|
91
|
+
> Call it inside your app's startup hook or on first request.
|
|
92
|
+
|
|
93
|
+
### `bind_settings()` — per-request bound client
|
|
94
|
+
|
|
95
|
+
For services that use `UniqueSettings` / `AuthContext`, bind the singleton
|
|
96
|
+
once per request to avoid passing `company_id` / `user_id` at every call site:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from unique_toolkit.experimental.resources.feature_flags import (
|
|
100
|
+
BoundFeatureFlagClient,
|
|
101
|
+
FeatureFlagClient,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# once at startup
|
|
105
|
+
client = FeatureFlagClient.from_settings()
|
|
106
|
+
|
|
107
|
+
# once per request (settings carries company_id / user_id)
|
|
108
|
+
bound: BoundFeatureFlagClient = client.bind_settings(settings)
|
|
109
|
+
|
|
110
|
+
if await bound.is_enabled("FEATURE_FLAG_ENABLE_MY_FEATURE"):
|
|
111
|
+
...
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Evaluation order
|
|
117
|
+
|
|
118
|
+
1. **TTL cache** — returns the cached value if present (within the TTL window).
|
|
119
|
+
2. **Remote** — calls configuration-backend's `evaluateFlag` GraphQL query.
|
|
120
|
+
One retry on transient 5xx errors.
|
|
121
|
+
3. **Stale cache** — on failure, returns the last successfully fetched value
|
|
122
|
+
for this `(flag, company_id, user_id)` key if one exists.
|
|
123
|
+
4. **Env-var fallback** — if no stale value is available, reads the flag's
|
|
124
|
+
env var. Supports plain booleans (`"true"` / `"false"`) and
|
|
125
|
+
comma-separated company-ID allowlists (`"company1,company2"`), consistent
|
|
126
|
+
with `unique_toolkit.agentic.feature_flags.FeatureFlags`.
|
|
127
|
+
|
|
128
|
+
```mermaid
|
|
129
|
+
flowchart TD
|
|
130
|
+
A(["evaluate(flag, company_id)"]) --> B{TTL cache hit?}
|
|
131
|
+
|
|
132
|
+
B -- yes --> C([return value, reason=cached])
|
|
133
|
+
|
|
134
|
+
B -- no --> D[fetch from\nconfiguration-backend GraphQL]
|
|
135
|
+
D -- 200 OK --> E[store in TTL cache\n+ stale cache]
|
|
136
|
+
E --> F([return value, reason=remote])
|
|
137
|
+
|
|
138
|
+
D -- 5xx --> G[retry once]
|
|
139
|
+
G -- 200 OK --> E
|
|
140
|
+
G -- 5xx again --> L[⚠ log.warning\nfetch failed — trying stale cache]
|
|
141
|
+
|
|
142
|
+
D -- timeout / network error --> L
|
|
143
|
+
L --> H{stale cache hit?}
|
|
144
|
+
|
|
145
|
+
H -- yes --> M[⚠ log.warning\nstale cache hit]
|
|
146
|
+
M --> I([return stale value, reason=stale])
|
|
147
|
+
|
|
148
|
+
H -- no --> N[⚠ log.warning\nno stale value — env var fallback]
|
|
149
|
+
N --> J[read env var\nbool or company-ID allowlist]
|
|
150
|
+
J --> K([return env value, reason=fallback])
|
|
151
|
+
|
|
152
|
+
style C fill:#22c55e,color:#fff
|
|
153
|
+
style F fill:#22c55e,color:#fff
|
|
154
|
+
style I fill:#f59e0b,color:#fff
|
|
155
|
+
style K fill:#ef4444,color:#fff
|
|
156
|
+
style L fill:#fef9c3,color:#713f12
|
|
157
|
+
style M fill:#fef9c3,color:#713f12
|
|
158
|
+
style N fill:#fef9c3,color:#713f12
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## `FlagEvaluation.reason` values
|
|
164
|
+
|
|
165
|
+
| Value | Meaning |
|
|
166
|
+
|---|---|
|
|
167
|
+
| `"remote"` | Value was freshly fetched from configuration-backend. |
|
|
168
|
+
| `"cached"` | Value was returned from the in-process TTL cache. |
|
|
169
|
+
| `"stale"` | Transport error; last-known-good value for this company returned. |
|
|
170
|
+
| `"fallback"` | No prior value available; env-var default used. |
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Adopting in another Python service
|
|
175
|
+
|
|
176
|
+
1. Install `unique-toolkit` (cachetools is a core dep, no extra group needed): `uv add unique-toolkit`
|
|
177
|
+
2. Set env vars: `CONFIGURATION_BACKEND_URL`, `FEATURE_FLAG_SERVICE_ID`
|
|
178
|
+
3. Add the service ID to configuration-backend's `Service` enum (`tyk-auth`)
|
|
179
|
+
and to the `@AllowAccess` whitelist on the `evaluateFlag` resolver.
|
|
180
|
+
4. Register any new flag keys in `feature-flag.registry.ts`.
|
|
181
|
+
5. Define flag name constants in your service (see convention above).
|
|
182
|
+
6. Replace `os.getenv("FEATURE_FLAG_*", "false")` call sites with
|
|
183
|
+
`await client.is_enabled("FEATURE_FLAG_*", company_id=..., user_id=...)`.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Feature flag client backed by configuration-backend's GraphQL API.
|
|
2
|
+
|
|
3
|
+
Evaluates flags remotely with a 5 s TTL cache, stale-value fallback on
|
|
4
|
+
transport errors, and env-var fallback when no prior value exists.
|
|
5
|
+
Env-var values support both plain booleans and comma-separated company-ID
|
|
6
|
+
allowlists, consistent with ``unique_toolkit.agentic.feature_flags``.
|
|
7
|
+
|
|
8
|
+
Required env vars::
|
|
9
|
+
|
|
10
|
+
CONFIGURATION_BACKEND_URL=https://<your-configuration-backend>
|
|
11
|
+
FEATURE_FLAG_SERVICE_ID=your-service-name
|
|
12
|
+
|
|
13
|
+
Quick start — explicit IDs::
|
|
14
|
+
|
|
15
|
+
from unique_toolkit.experimental.resources.feature_flags import FeatureFlagClient
|
|
16
|
+
|
|
17
|
+
client = FeatureFlagClient.from_settings() # singleton; call at startup or first request
|
|
18
|
+
enabled = await client.is_enabled("FEATURE_FLAG_ENABLE_X", company_id=company_id)
|
|
19
|
+
|
|
20
|
+
Per-request binding (when you have UniqueSettings / AuthContext)::
|
|
21
|
+
|
|
22
|
+
bound = client.bind_settings(settings) # bind once per request
|
|
23
|
+
enabled = await bound.is_enabled("FEATURE_FLAG_ENABLE_X")
|
|
24
|
+
|
|
25
|
+
Define flag name constants in your service to avoid magic strings::
|
|
26
|
+
|
|
27
|
+
class MyServiceFlags:
|
|
28
|
+
ENABLE_X = "FEATURE_FLAG_ENABLE_X"
|
|
29
|
+
ENABLE_Y = "FEATURE_FLAG_ENABLE_Y"
|
|
30
|
+
|
|
31
|
+
See README.md for the full evaluation-order diagram and adoption steps.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from .client import BoundFeatureFlagClient, FeatureFlagClient
|
|
35
|
+
from .schemas import FlagEvaluation
|
|
36
|
+
from .settings import FeatureFlagSettings
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"BoundFeatureFlagClient",
|
|
40
|
+
"FeatureFlagClient",
|
|
41
|
+
"FlagEvaluation",
|
|
42
|
+
"FeatureFlagSettings",
|
|
43
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
async def evaluate_flag(
|
|
5
|
+
*,
|
|
6
|
+
http: httpx.AsyncClient,
|
|
7
|
+
url: str,
|
|
8
|
+
flag: str,
|
|
9
|
+
service_id: str,
|
|
10
|
+
company_id: str,
|
|
11
|
+
user_id: str | None = None,
|
|
12
|
+
) -> bool:
|
|
13
|
+
"""Call configuration-backend's ``evaluateFlag`` GraphQL query.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
http: Shared HTTP client (caller owns lifecycle).
|
|
17
|
+
url: Base URL of configuration-backend (no trailing slash).
|
|
18
|
+
flag: Upper-snake flag key, e.g. ``FEATURE_FLAG_ENABLE_MY_FEATURE``.
|
|
19
|
+
service_id: Value sent as ``x-service-id`` header.
|
|
20
|
+
company_id: Value sent as ``x-company-id`` header.
|
|
21
|
+
user_id: Value sent as ``x-user-id`` header. Omitted when ``None``.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The boolean evaluation result from the server.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
httpx.HTTPError: On transport or HTTP-level errors.
|
|
28
|
+
RuntimeError: On GraphQL errors or a non-boolean response value.
|
|
29
|
+
"""
|
|
30
|
+
headers: dict[str, str] = {
|
|
31
|
+
"x-service-id": service_id,
|
|
32
|
+
"x-company-id": company_id,
|
|
33
|
+
"Content-Type": "application/json",
|
|
34
|
+
}
|
|
35
|
+
if user_id is not None:
|
|
36
|
+
headers["x-user-id"] = user_id
|
|
37
|
+
|
|
38
|
+
response = await http.post(
|
|
39
|
+
f"{url}/graphql",
|
|
40
|
+
json={
|
|
41
|
+
"query": "query EvaluateFlag($key: String!) { evaluateFlag(key: $key) }",
|
|
42
|
+
"variables": {"key": flag},
|
|
43
|
+
},
|
|
44
|
+
headers=headers,
|
|
45
|
+
)
|
|
46
|
+
response.raise_for_status()
|
|
47
|
+
data = response.json()
|
|
48
|
+
|
|
49
|
+
if "errors" in data:
|
|
50
|
+
raise RuntimeError(f"GraphQL error evaluating '{flag}': {data['errors']}")
|
|
51
|
+
|
|
52
|
+
value = data.get("data", {}).get("evaluateFlag")
|
|
53
|
+
if not isinstance(value, bool):
|
|
54
|
+
raise RuntimeError(
|
|
55
|
+
f"evaluateFlag returned unexpected value for '{flag}': {value!r}"
|
|
56
|
+
)
|
|
57
|
+
return value
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import Awaitable, Callable
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from cachetools import LRUCache, TTLCache
|
|
6
|
+
|
|
7
|
+
# Sentinel for distinguishing a stored False/None from a missing key.
|
|
8
|
+
# Never stored as a value; only used as the default in .get() calls.
|
|
9
|
+
_MISSING = object()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AsyncTTLCache:
|
|
13
|
+
"""Per-key async TTL cache with stampede protection.
|
|
14
|
+
|
|
15
|
+
Uses a per-key asyncio.Lock to ensure only one coroutine fetches a
|
|
16
|
+
value on a cache miss; subsequent waiters reuse the fetched result.
|
|
17
|
+
Both the value cache and the lock dict are bounded by ``maxsize``.
|
|
18
|
+
|
|
19
|
+
Keys may be any hashable — strings, tuples, etc.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, *, maxsize: int = 1024, ttl_ms: float = 5_000) -> None:
|
|
23
|
+
self._cache: TTLCache[Any, Any] = TTLCache(maxsize=maxsize, ttl=ttl_ms / 1000)
|
|
24
|
+
self._stale: LRUCache[Any, Any] = LRUCache(maxsize=maxsize)
|
|
25
|
+
self._locks: LRUCache[Any, asyncio.Lock] = LRUCache(maxsize=maxsize)
|
|
26
|
+
self._dict_lock = asyncio.Lock()
|
|
27
|
+
|
|
28
|
+
async def _get_key_lock(self, key: Any) -> asyncio.Lock:
|
|
29
|
+
async with self._dict_lock:
|
|
30
|
+
lock = self._locks.get(key)
|
|
31
|
+
if lock is None:
|
|
32
|
+
lock = asyncio.Lock()
|
|
33
|
+
self._locks[key] = lock
|
|
34
|
+
return lock
|
|
35
|
+
|
|
36
|
+
async def get_or_fetch(
|
|
37
|
+
self,
|
|
38
|
+
key: Any,
|
|
39
|
+
fetcher: Callable[[], Awaitable[Any]],
|
|
40
|
+
) -> tuple[Any, bool]:
|
|
41
|
+
"""Return ``(value, from_cache)``, fetching on a miss with per-key stampede protection."""
|
|
42
|
+
cached = self._cache.get(key, _MISSING)
|
|
43
|
+
if cached is not _MISSING:
|
|
44
|
+
self._stale[key] = cached # keep stale warm while TTL cache is hot
|
|
45
|
+
return cached, True
|
|
46
|
+
|
|
47
|
+
# Acquire per-key lock; re-check inside in case another waiter already fetched.
|
|
48
|
+
key_lock = await self._get_key_lock(key)
|
|
49
|
+
async with key_lock:
|
|
50
|
+
cached = self._cache.get(key, _MISSING)
|
|
51
|
+
if cached is not _MISSING:
|
|
52
|
+
self._stale[key] = cached # keep stale warm while TTL cache is hot
|
|
53
|
+
return cached, True
|
|
54
|
+
|
|
55
|
+
value = await fetcher()
|
|
56
|
+
self._cache[key] = value
|
|
57
|
+
self._stale[key] = value
|
|
58
|
+
return value, False
|
|
59
|
+
|
|
60
|
+
def get_stale(self, key: Any) -> tuple[Any, bool]:
|
|
61
|
+
"""Return ``(value, True)`` if a prior successful fetch exists for *key*, else ``(None, False)``."""
|
|
62
|
+
value = self._stale.get(key, _MISSING)
|
|
63
|
+
if value is _MISSING:
|
|
64
|
+
return None, False
|
|
65
|
+
return value, True
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from tenacity import retry, retry_if_exception, stop_after_attempt
|
|
9
|
+
|
|
10
|
+
from unique_toolkit.app.unique_settings import AuthContextProtocol, UniqueSettings
|
|
11
|
+
|
|
12
|
+
from ._graphql_client import evaluate_flag
|
|
13
|
+
from ._ttl_cache import AsyncTTLCache
|
|
14
|
+
from .schemas import FlagEvaluation
|
|
15
|
+
from .settings import FeatureFlagSettings
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FeatureFlagClient:
|
|
21
|
+
"""Feature flag client backed by configuration-backend's GraphQL API.
|
|
22
|
+
|
|
23
|
+
Mirrors the OpenFeature ``evaluate`` / ``isEnabled`` semantics used by the
|
|
24
|
+
Node.js ``@unique/feature-flags`` package without taking a dependency on
|
|
25
|
+
the OpenFeature Python SDK.
|
|
26
|
+
|
|
27
|
+
Evaluation order:
|
|
28
|
+
1. Check the in-process TTL cache; return a cached value if present.
|
|
29
|
+
2. Call configuration-backend's ``evaluateFlag`` GraphQL query (one
|
|
30
|
+
retry on transient 5xx errors).
|
|
31
|
+
3. On failure, return the last successfully fetched value (stale cache)
|
|
32
|
+
if one exists, otherwise fall back to the env-var default.
|
|
33
|
+
|
|
34
|
+
The ``flag`` argument must be the upper-snake env-var-style key, e.g.
|
|
35
|
+
``FEATURE_FLAG_ENABLE_MY_FEATURE``. This matches both the
|
|
36
|
+
configuration-backend registry key convention and the existing env-var
|
|
37
|
+
names, so env-var fallback requires no transformation.
|
|
38
|
+
|
|
39
|
+
Example (with UniqueSettings)::
|
|
40
|
+
|
|
41
|
+
client = FeatureFlagClient.from_settings()
|
|
42
|
+
bound = client.bind_settings(settings)
|
|
43
|
+
if await bound.is_enabled("FEATURE_FLAG_ENABLE_MY_FEATURE"):
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
Example (explicit IDs)::
|
|
47
|
+
|
|
48
|
+
result = await client.evaluate(
|
|
49
|
+
"FEATURE_FLAG_ENABLE_MY_FEATURE",
|
|
50
|
+
company_id="acme",
|
|
51
|
+
user_id="user-123",
|
|
52
|
+
)
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
_instance: ClassVar[FeatureFlagClient | None] = None
|
|
56
|
+
|
|
57
|
+
def __init__(self, *, url: str, service_id: str, ttl_ms: int = 5_000) -> None:
|
|
58
|
+
self._url = url
|
|
59
|
+
self._service_id = service_id
|
|
60
|
+
self._cache = AsyncTTLCache(ttl_ms=ttl_ms)
|
|
61
|
+
self._http = httpx.AsyncClient(timeout=httpx.Timeout(2.0, connect=1.0))
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_settings(cls) -> FeatureFlagClient:
|
|
65
|
+
"""Construct (or return the cached) singleton from environment variables.
|
|
66
|
+
|
|
67
|
+
Reads :class:`.FeatureFlagSettings` (``CONFIGURATION_BACKEND_URL``,
|
|
68
|
+
``FEATURE_FLAG_SERVICE_ID``, ``FEATURE_FLAG_CACHE_TTL_MS``).
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ValueError: If ``CONFIGURATION_BACKEND_URL`` or
|
|
72
|
+
``FEATURE_FLAG_SERVICE_ID`` are not set.
|
|
73
|
+
"""
|
|
74
|
+
if cls._instance is not None:
|
|
75
|
+
return cls._instance
|
|
76
|
+
s = FeatureFlagSettings()
|
|
77
|
+
if not s.configuration_backend_url:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
"CONFIGURATION_BACKEND_URL is required to use FeatureFlagClient"
|
|
80
|
+
)
|
|
81
|
+
if not s.feature_flag_service_id:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"FEATURE_FLAG_SERVICE_ID is required to use FeatureFlagClient"
|
|
84
|
+
)
|
|
85
|
+
cls._instance = cls(
|
|
86
|
+
url=s.configuration_backend_url,
|
|
87
|
+
service_id=s.feature_flag_service_id,
|
|
88
|
+
ttl_ms=s.feature_flag_cache_ttl_ms,
|
|
89
|
+
)
|
|
90
|
+
return cls._instance
|
|
91
|
+
|
|
92
|
+
def bind_settings(self, settings: UniqueSettings) -> BoundFeatureFlagClient:
|
|
93
|
+
"""Return a :class:`BoundFeatureFlagClient` for the request-level auth context."""
|
|
94
|
+
return BoundFeatureFlagClient(self, settings.authcontext)
|
|
95
|
+
|
|
96
|
+
async def evaluate(
|
|
97
|
+
self,
|
|
98
|
+
flag: str,
|
|
99
|
+
*,
|
|
100
|
+
company_id: str,
|
|
101
|
+
user_id: str | None = None,
|
|
102
|
+
) -> FlagEvaluation:
|
|
103
|
+
"""Evaluate *flag* for the given context."""
|
|
104
|
+
company_id = company_id.strip()
|
|
105
|
+
if not company_id:
|
|
106
|
+
raise ValueError("company_id must be a non-empty string")
|
|
107
|
+
|
|
108
|
+
cache_key = (flag, company_id, user_id)
|
|
109
|
+
try:
|
|
110
|
+
value, from_cache = await self._cache.get_or_fetch(
|
|
111
|
+
cache_key,
|
|
112
|
+
lambda: self._fetch(flag=flag, company_id=company_id, user_id=user_id),
|
|
113
|
+
)
|
|
114
|
+
return FlagEvaluation(
|
|
115
|
+
value=value, reason="cached" if from_cache else "remote"
|
|
116
|
+
)
|
|
117
|
+
except Exception:
|
|
118
|
+
logger.warning(
|
|
119
|
+
"FeatureFlagClient: fetch failed for '%s' (company=%s) — trying stale cache",
|
|
120
|
+
flag,
|
|
121
|
+
company_id,
|
|
122
|
+
exc_info=True,
|
|
123
|
+
)
|
|
124
|
+
stale_value, stale_hit = self._cache.get_stale(cache_key)
|
|
125
|
+
if stale_hit:
|
|
126
|
+
logger.warning(
|
|
127
|
+
"FeatureFlagClient: stale cache hit for '%s' (company=%s, value=%s)",
|
|
128
|
+
flag,
|
|
129
|
+
company_id,
|
|
130
|
+
stale_value,
|
|
131
|
+
)
|
|
132
|
+
return FlagEvaluation(value=stale_value, reason="stale")
|
|
133
|
+
logger.warning(
|
|
134
|
+
"FeatureFlagClient: no stale value for '%s' (company=%s) — falling back to env var",
|
|
135
|
+
flag,
|
|
136
|
+
company_id,
|
|
137
|
+
)
|
|
138
|
+
return FlagEvaluation(
|
|
139
|
+
value=self._env_fallback(flag, company_id), reason="fallback"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def is_enabled(
|
|
143
|
+
self,
|
|
144
|
+
flag: str,
|
|
145
|
+
*,
|
|
146
|
+
company_id: str,
|
|
147
|
+
user_id: str | None = None,
|
|
148
|
+
) -> bool:
|
|
149
|
+
"""Return ``True`` if *flag* is enabled for the given context."""
|
|
150
|
+
return (await self.evaluate(flag, company_id=company_id, user_id=user_id)).value
|
|
151
|
+
|
|
152
|
+
@retry(
|
|
153
|
+
retry=retry_if_exception(
|
|
154
|
+
lambda exc: (
|
|
155
|
+
isinstance(exc, httpx.HTTPStatusError)
|
|
156
|
+
and exc.response.status_code >= 500
|
|
157
|
+
)
|
|
158
|
+
),
|
|
159
|
+
stop=stop_after_attempt(2),
|
|
160
|
+
reraise=True,
|
|
161
|
+
)
|
|
162
|
+
async def _fetch(
|
|
163
|
+
self,
|
|
164
|
+
*,
|
|
165
|
+
flag: str,
|
|
166
|
+
company_id: str,
|
|
167
|
+
user_id: str | None,
|
|
168
|
+
) -> bool:
|
|
169
|
+
return await evaluate_flag(
|
|
170
|
+
http=self._http,
|
|
171
|
+
url=self._url,
|
|
172
|
+
flag=flag,
|
|
173
|
+
service_id=self._service_id,
|
|
174
|
+
company_id=company_id,
|
|
175
|
+
user_id=user_id,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _env_fallback(flag: str, company_id: str | None = None) -> bool:
|
|
180
|
+
"""Read *flag* from env vars using the same semantics as ``FeatureFlag``.
|
|
181
|
+
|
|
182
|
+
Supports both plain booleans (``"true"`` / ``"false"``) and
|
|
183
|
+
comma-separated company-ID allowlists (``"company1,company2"``),
|
|
184
|
+
consistent with ``unique_toolkit.agentic.feature_flags.FeatureFlags``.
|
|
185
|
+
"""
|
|
186
|
+
raw = os.getenv(flag, "false").strip()
|
|
187
|
+
raw_lower = raw.lower()
|
|
188
|
+
if raw_lower in ("true", "1", "yes"):
|
|
189
|
+
return True
|
|
190
|
+
if raw_lower in ("false", "0", "no", ""):
|
|
191
|
+
return False
|
|
192
|
+
# Comma-separated company-ID allowlist
|
|
193
|
+
allowed = {part.strip() for part in raw.split(",") if part.strip()}
|
|
194
|
+
return company_id in allowed if company_id else False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class BoundFeatureFlagClient:
|
|
198
|
+
"""A :class:`FeatureFlagClient` bound to a request-level auth context.
|
|
199
|
+
|
|
200
|
+
Obtain via :meth:`FeatureFlagClient.bind_settings` — do not instantiate directly.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(self, client: FeatureFlagClient, auth: AuthContextProtocol) -> None:
|
|
204
|
+
self._client = client
|
|
205
|
+
self._auth = auth
|
|
206
|
+
|
|
207
|
+
async def evaluate(self, flag: str) -> FlagEvaluation:
|
|
208
|
+
return await self._client.evaluate(
|
|
209
|
+
flag,
|
|
210
|
+
company_id=self._auth.get_confidential_company_id(),
|
|
211
|
+
user_id=self._auth.get_confidential_user_id(),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
async def is_enabled(self, flag: str) -> bool:
|
|
215
|
+
return (await self.evaluate(flag)).value
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class FlagEvaluation:
|
|
7
|
+
"""Result of a feature flag evaluation.
|
|
8
|
+
|
|
9
|
+
``reason`` indicates how the value was determined:
|
|
10
|
+
- ``"remote"`` — freshly fetched from configuration-backend.
|
|
11
|
+
- ``"cached"`` — served from the in-process TTL cache.
|
|
12
|
+
- ``"stale"`` — transport error; last-known-good value for this flag+company used.
|
|
13
|
+
- ``"fallback"`` — transport error and no prior value; env-var default used.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
value: bool
|
|
17
|
+
reason: Literal["remote", "cached", "stale", "fallback"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from pydantic import field_validator
|
|
2
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
3
|
+
|
|
4
|
+
from unique_toolkit.app.find_env_file import find_env_file
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FeatureFlagSettings(BaseSettings):
|
|
8
|
+
"""Env-var settings for :class:`FeatureFlagClient`.
|
|
9
|
+
|
|
10
|
+
``CONFIGURATION_BACKEND_URL`` and ``FEATURE_FLAG_SERVICE_ID`` are required;
|
|
11
|
+
:meth:`FeatureFlagClient.from_settings` raises ``ValueError`` if either is absent.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
configuration_backend_url: str | None = None
|
|
15
|
+
feature_flag_service_id: str | None = None
|
|
16
|
+
feature_flag_cache_ttl_ms: int = 5_000
|
|
17
|
+
|
|
18
|
+
@field_validator(
|
|
19
|
+
"configuration_backend_url", "feature_flag_service_id", mode="before"
|
|
20
|
+
)
|
|
21
|
+
@classmethod
|
|
22
|
+
def _strip_strings(cls, v: object) -> object:
|
|
23
|
+
if isinstance(v, str):
|
|
24
|
+
stripped = v.strip()
|
|
25
|
+
return stripped if stripped else None
|
|
26
|
+
return v
|
|
27
|
+
|
|
28
|
+
model_config = SettingsConfigDict(
|
|
29
|
+
extra="ignore",
|
|
30
|
+
case_sensitive=False,
|
|
31
|
+
env_file=find_env_file(".env", required=False),
|
|
32
|
+
env_file_encoding="utf-8",
|
|
33
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def child_exit(server, worker) -> None:
|
|
7
|
+
"""Gunicorn child_exit hook: clean up dead worker's per-PID metric files.
|
|
8
|
+
|
|
9
|
+
Wire this up in gunicorn.conf.py:
|
|
10
|
+
|
|
11
|
+
from unique_toolkit.monitoring.gunicorn import child_exit # noqa: F401
|
|
12
|
+
|
|
13
|
+
Gunicorn calls any function named child_exit(server, worker) in the
|
|
14
|
+
config file whenever a worker process exits. Without this, dead workers'
|
|
15
|
+
.db files accumulate in PROMETHEUS_MULTIPROC_DIR and inflate aggregated
|
|
16
|
+
metric values on every future scrape.
|
|
17
|
+
|
|
18
|
+
No-ops when PROMETHEUS_MULTIPROC_DIR is not set (single-process mode).
|
|
19
|
+
"""
|
|
20
|
+
if not os.environ.get("PROMETHEUS_MULTIPROC_DIR"):
|
|
21
|
+
return
|
|
22
|
+
from prometheus_client import multiprocess # pyright: ignore[reportMissingImports]
|
|
23
|
+
|
|
24
|
+
multiprocess.mark_process_dead(worker.pid)
|
|
@@ -11,22 +11,23 @@ from prometheus_client import ( # pyright: ignore[reportMissingImports]
|
|
|
11
11
|
from .registry import REGISTRY
|
|
12
12
|
|
|
13
13
|
_http_requests_total = Counter(
|
|
14
|
-
"
|
|
14
|
+
"python_http_requests_total",
|
|
15
15
|
"Total HTTP requests",
|
|
16
16
|
["method", "path", "status_code"],
|
|
17
17
|
registry=REGISTRY,
|
|
18
18
|
)
|
|
19
19
|
_http_request_duration_seconds = Histogram(
|
|
20
|
-
"
|
|
20
|
+
"python_http_request_duration_seconds",
|
|
21
21
|
"HTTP request duration",
|
|
22
22
|
["method", "path"],
|
|
23
23
|
registry=REGISTRY,
|
|
24
24
|
)
|
|
25
25
|
_http_requests_in_progress = Gauge(
|
|
26
|
-
"
|
|
26
|
+
"python_http_requests_in_progress",
|
|
27
27
|
"In-flight HTTP requests",
|
|
28
28
|
["method"],
|
|
29
29
|
registry=REGISTRY,
|
|
30
|
+
multiprocess_mode="livesum",
|
|
30
31
|
)
|
|
31
32
|
|
|
32
33
|
_DEFAULT_EXCLUDED_PATHS = frozenset({"/health", "/metrics", "/"})
|
|
@@ -2,9 +2,11 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import functools
|
|
5
|
+
import os
|
|
5
6
|
import time
|
|
7
|
+
import warnings
|
|
6
8
|
from contextlib import contextmanager
|
|
7
|
-
from typing import Any, Callable
|
|
9
|
+
from typing import Any, Callable, Literal
|
|
8
10
|
|
|
9
11
|
from prometheus_client import ( # pyright: ignore[reportMissingImports]
|
|
10
12
|
CollectorRegistry,
|
|
@@ -18,7 +20,36 @@ REGISTRY = CollectorRegistry()
|
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
def get_metrics() -> bytes:
|
|
21
|
-
"""Returns all registered metrics in Prometheus text format.
|
|
23
|
+
"""Returns all registered metrics in Prometheus text format.
|
|
24
|
+
|
|
25
|
+
In multiprocess mode (PROMETHEUS_MULTIPROC_DIR is set), aggregates
|
|
26
|
+
per-PID .db files from all Gunicorn workers via MultiProcessCollector.
|
|
27
|
+
In single-process mode, returns metrics from the in-process REGISTRY.
|
|
28
|
+
|
|
29
|
+
NOTE: PROMETHEUS_MULTIPROC_DIR must be set in the shell/entrypoint
|
|
30
|
+
*before* Python starts. Setting it from Python after prometheus_client
|
|
31
|
+
has been imported has no effect — metrics will silently bypass per-PID
|
|
32
|
+
files and this function will emit a warning.
|
|
33
|
+
"""
|
|
34
|
+
if os.environ.get("PROMETHEUS_MULTIPROC_DIR"):
|
|
35
|
+
from prometheus_client import ( # pyright: ignore[reportMissingImports]
|
|
36
|
+
multiprocess,
|
|
37
|
+
values,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if not getattr(values.ValueClass, "_multiprocess", False):
|
|
41
|
+
warnings.warn(
|
|
42
|
+
"PROMETHEUS_MULTIPROC_DIR is set but prometheus_client was imported "
|
|
43
|
+
"before the env var — metrics will silently bypass per-PID files. "
|
|
44
|
+
"Set PROMETHEUS_MULTIPROC_DIR in the shell/entrypoint before launching Python.",
|
|
45
|
+
RuntimeWarning,
|
|
46
|
+
stacklevel=2,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
registry = CollectorRegistry()
|
|
50
|
+
multiprocess.MultiProcessCollector(registry)
|
|
51
|
+
return generate_latest(registry)
|
|
52
|
+
|
|
22
53
|
return generate_latest(REGISTRY)
|
|
23
54
|
|
|
24
55
|
|
|
@@ -43,8 +74,31 @@ class MetricNamespace:
|
|
|
43
74
|
def counter(self, name: str, description: str, labels: list[str]) -> Counter:
|
|
44
75
|
return Counter(f"{self._prefix}_{name}", description, labels, registry=REGISTRY)
|
|
45
76
|
|
|
46
|
-
def gauge(
|
|
47
|
-
|
|
77
|
+
def gauge(
|
|
78
|
+
self,
|
|
79
|
+
name: str,
|
|
80
|
+
description: str,
|
|
81
|
+
labels: list[str],
|
|
82
|
+
multiprocess_mode: Literal[
|
|
83
|
+
"all",
|
|
84
|
+
"liveall",
|
|
85
|
+
"min",
|
|
86
|
+
"livemin",
|
|
87
|
+
"max",
|
|
88
|
+
"livemax",
|
|
89
|
+
"sum",
|
|
90
|
+
"livesum",
|
|
91
|
+
"mostrecent",
|
|
92
|
+
"livemostrecent",
|
|
93
|
+
] = "all",
|
|
94
|
+
) -> Gauge:
|
|
95
|
+
return Gauge(
|
|
96
|
+
f"{self._prefix}_{name}",
|
|
97
|
+
description,
|
|
98
|
+
labels,
|
|
99
|
+
registry=REGISTRY,
|
|
100
|
+
multiprocess_mode=multiprocess_mode,
|
|
101
|
+
)
|
|
48
102
|
|
|
49
103
|
|
|
50
104
|
@contextmanager
|
{unique_toolkit-2026.24.0.dev6.dist-info → unique_toolkit-2026.24.0.dev8.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: unique-toolkit
|
|
3
|
-
Version: 2026.24.0.
|
|
3
|
+
Version: 2026.24.0.dev8
|
|
4
4
|
Summary:
|
|
5
5
|
Author: Cedric Klinkert, Martin Fadler, Sadique Sheik, Fabian Schläpfer, Pascal Hauri, Ejiroghene Oki-Peter, Rami Azouz, Ricardo Visini, Sanzio Monti
|
|
6
6
|
Author-email: Cedric Klinkert <cedric.klinkert@unique.ch>, Martin Fadler <martin.fadler@unique.ch>, Sadique Sheik <sadique@unique.ch>, Fabian Schläpfer <fabian@unique.ch>, Pascal Hauri <pascal@unique.ch>, Ejiroghene Oki-Peter <oki-peter.ejiroghene@unique.ai>, Rami Azouz <rami.ext@unique.ch>, Ricardo Visini <ricardo.visini@unique.ch>, Sanzio Monti <sanzio.monti@unique.ai>
|
|
@@ -16,7 +16,7 @@ Requires-Dist: sseclient>=0.0.27,<0.0.28
|
|
|
16
16
|
Requires-Dist: openai>=1.105.0,<3
|
|
17
17
|
Requires-Dist: platformdirs>=4.0.0,<5
|
|
18
18
|
Requires-Dist: pillow>=12.1.1
|
|
19
|
-
Requires-Dist: unique-sdk>=2026.24.0.
|
|
19
|
+
Requires-Dist: unique-sdk>=2026.24.0.dev3,<2026.24.0rc0
|
|
20
20
|
Requires-Dist: jambo>=0.1.2,<0.2
|
|
21
21
|
Requires-Dist: jinja2>=3.1.6,<4
|
|
22
22
|
Requires-Dist: docxtpl>=0.20.1,<0.21
|
|
@@ -31,6 +31,7 @@ Requires-Dist: deepdiff>=8.6.1,<9
|
|
|
31
31
|
Requires-Dist: pypandoc>=1.16.2,<2
|
|
32
32
|
Requires-Dist: tenacity>=9.1.4
|
|
33
33
|
Requires-Dist: pydantic-core>=2.41.5
|
|
34
|
+
Requires-Dist: cachetools>=5.0,<6
|
|
34
35
|
Requires-Dist: fastapi>=0.121.0,<0.122 ; extra == 'fastapi'
|
|
35
36
|
Requires-Dist: starlette>=0.49.1 ; extra == 'fastapi'
|
|
36
37
|
Requires-Dist: uvicorn>=0.37.0,<0.38 ; extra == 'fastapi'
|
|
@@ -276,7 +276,7 @@ unique_toolkit/embedding/functions.py,sha256=OWzY-iPNBjwXgSNNXDmu89Lzu-PAMG3Q7ig
|
|
|
276
276
|
unique_toolkit/embedding/schemas.py,sha256=1GvKCaSk4jixzVQ2PKq8yDqwGEVY_hWclYtoAr6CC2g,96
|
|
277
277
|
unique_toolkit/embedding/service.py,sha256=SSBIsn7ZyQ_5JtoCMqnP1bDoZzp0S3Lt-ao9YBUt1Bc,5232
|
|
278
278
|
unique_toolkit/embedding/utils.py,sha256=v86lo__bCJbxZBQ3OcLu5SuwT6NbFfWlcq8iyk6BuzQ,279
|
|
279
|
-
unique_toolkit/experimental/README.md,sha256=
|
|
279
|
+
unique_toolkit/experimental/README.md,sha256=52uUCehy0ZRoIrTrsvk8k6O_NxSUh9-3dATOzZ7wWjU,11350
|
|
280
280
|
unique_toolkit/experimental/__init__.py,sha256=fhSv-QhH0yxnnGqSwbFhX7Rp3TCG_zBphLHNC5GLBgA,393
|
|
281
281
|
unique_toolkit/experimental/_internal/__init__.py,sha256=wClblbvHTnOCjymmXVoAde1is3AF18iQ46lK6wKObr4,995
|
|
282
282
|
unique_toolkit/experimental/_internal/streaming/__init__.py,sha256=YExuxaOHB96xT1k0FsWiZEo6Ngiyv54GumhCAKW_3aA,942
|
|
@@ -331,7 +331,7 @@ unique_toolkit/experimental/integrations/openai/streaming/event_routing/response
|
|
|
331
331
|
unique_toolkit/experimental/integrations/openai/streaming/event_routing/subscribers/__init__.py,sha256=4wpUt9kzfPzncO9tlKcvOQUzQG5e6kVp5AKZI5grxOk,276
|
|
332
332
|
unique_toolkit/experimental/integrations/openai/streaming/event_routing/subscribers/message_persister.py,sha256=nHh2vQWF6YWurdkijof6UXhTMFzllj_4eSdr8nm9aDI,8016
|
|
333
333
|
unique_toolkit/experimental/integrations/openai/streaming/event_routing/subscribers/progress_log_persister.py,sha256=VCYGZY3WSpI7mvVs2gO89-M0JenakW01BGfBfFkPDrw,3499
|
|
334
|
-
unique_toolkit/experimental/resources/__init__.py,sha256=
|
|
334
|
+
unique_toolkit/experimental/resources/__init__.py,sha256=KdMPaaneY0twZ-JBfwImRjXZaWT68NVuOIeqdNXFh_Y,3377
|
|
335
335
|
unique_toolkit/experimental/resources/content_folder/__init__.py,sha256=c5MUj1xIP10L7tcM4-hG2m1_yUMVfKr_jHAncUXVNvo,2492
|
|
336
336
|
unique_toolkit/experimental/resources/content_folder/functions.py,sha256=nJle9Qnh-x1SLNetBBhTbQPu-zEBfQaHFnMq8N9RGEQ,16692
|
|
337
337
|
unique_toolkit/experimental/resources/content_folder/schemas.py,sha256=LYWRpMLFVg_iKT9dw2AyCNfTPVmKeB8erMSsgbIkZ48,5345
|
|
@@ -339,6 +339,13 @@ unique_toolkit/experimental/resources/content_folder/service.py,sha256=tSNVJ681c
|
|
|
339
339
|
unique_toolkit/experimental/resources/facades/__init__.py,sha256=imKXMz2nsQcg5zmG-Yq5aQJTMAgpJ3-cXW3L1WfP280,1421
|
|
340
340
|
unique_toolkit/experimental/resources/facades/identity/__init__.py,sha256=7r-1_gr81Idc6AJ1IRO6GCHx7psmC81BtD15BWxrlYs,1640
|
|
341
341
|
unique_toolkit/experimental/resources/facades/identity/service.py,sha256=A5I7QkNy-Su9FmOkrl8z1iwWhqzoo6KgxEgcdvnWN2M,4287
|
|
342
|
+
unique_toolkit/experimental/resources/feature_flags/README.md,sha256=ChPgYn4-SzY1UyF3OQgBxHzMk6JH_hVKfzHsTcKhcXo,6122
|
|
343
|
+
unique_toolkit/experimental/resources/feature_flags/__init__.py,sha256=eGU17swqiJdIqGC-HIGHjGk8rZFpc84IeTFFYkbZxpA,1510
|
|
344
|
+
unique_toolkit/experimental/resources/feature_flags/_graphql_client.py,sha256=a0KxF0EJk7baYChVKFy8bllIJShHKELyQReFd_B9bXs,1743
|
|
345
|
+
unique_toolkit/experimental/resources/feature_flags/_ttl_cache.py,sha256=lPEdWX1JkP2985M1AqQ0uYyWGlagXB0LvOtQPltbgJo,2520
|
|
346
|
+
unique_toolkit/experimental/resources/feature_flags/client.py,sha256=DicPHPpdckzQsHh2Xe5UMUWqdkqM0zZBKR1grB0_bHg,7600
|
|
347
|
+
unique_toolkit/experimental/resources/feature_flags/schemas.py,sha256=chLsBNkIL4BOvGrwrGeixy7BmNSi2BagXD6Jr2fZcqs,602
|
|
348
|
+
unique_toolkit/experimental/resources/feature_flags/settings.py,sha256=3L_c-0zxj4cpuHd3NL3mZ2kN35V6npgh6_hSwu6Gk00,1071
|
|
342
349
|
unique_toolkit/experimental/resources/group/__init__.py,sha256=qPi2DWgWLZw1ku0zi-UsPdornfl8pczW6QSYT5P8CzQ,2098
|
|
343
350
|
unique_toolkit/experimental/resources/group/functions.py,sha256=DrrafmR_6nfGZ1ThnqDxK7PP0zyAAo0uXKgp_mcIf-Q,7990
|
|
344
351
|
unique_toolkit/experimental/resources/group/schemas.py,sha256=t6lXLETGDBGzC9ROKDngQ6mg0KRf5XDTKj0j2uj2b60,3179
|
|
@@ -376,8 +383,9 @@ unique_toolkit/language_model/settings.py,sha256=xkoMv3ATb2ft5V3MA8JvqW-EO8AnZ5t
|
|
|
376
383
|
unique_toolkit/language_model/stream_transform.py,sha256=iNNGpLY_7G6Bpp9Y4KtbIkQm3WB4yZt75BXcqx0DVkI,2775
|
|
377
384
|
unique_toolkit/language_model/utils.py,sha256=bPQ4l6_YO71w-zaIPanUUmtbXC1_hCvLK0tAFc3VCRc,1902
|
|
378
385
|
unique_toolkit/monitoring/__init__.py,sha256=z1gkOCLxGcg936MFuLSeWFa0tREcBFSUbV09NxilBw4,1065
|
|
379
|
-
unique_toolkit/monitoring/
|
|
380
|
-
unique_toolkit/monitoring/
|
|
386
|
+
unique_toolkit/monitoring/gunicorn.py,sha256=urTFFS5zYHKoxb1TEQHcLqb2byi-aYgFRHGwgYRzYYI,844
|
|
387
|
+
unique_toolkit/monitoring/middleware.py,sha256=JNh5v_INAkexuLtXhCG9n5n2t6Pnf8ZdPTnimAVS0_s,2931
|
|
388
|
+
unique_toolkit/monitoring/registry.py,sha256=8r9jqaQoRHZveGRZe_GQvBzvQpmUV4cpw8Ej1ssDIdE,5056
|
|
381
389
|
unique_toolkit/protocols/support.py,sha256=x5edzbBmKAUuvm3oLFh52gkgQsqR5LU7M0t8RHK0DZk,5161
|
|
382
390
|
unique_toolkit/services/__init__.py,sha256=b9Wl7Wvz1JmZDbDexIZzBoXZ0wscXuczGAH8LoL-Qak,572
|
|
383
391
|
unique_toolkit/services/chat_service.py,sha256=FPY-GTFAkD0C9pYfPfkfAvyIKnL9DX9iDk75gDeghCc,95209
|
|
@@ -391,6 +399,6 @@ unique_toolkit/short_term_memory/service.py,sha256=Ncw0_0CNM6kmzJSLVJGG5GcKpBduI
|
|
|
391
399
|
unique_toolkit/smart_rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
392
400
|
unique_toolkit/smart_rules/compile.py,sha256=vRe0aMJWuVIK-VsqomgxQImzke7MDmwgSxxJfYCa7zw,1386
|
|
393
401
|
unique_toolkit/test_utilities/events.py,sha256=FLrhMh0A_mQutAUjoD0FdMcVQ4Gq31r35OVGzRjvLro,6760
|
|
394
|
-
unique_toolkit-2026.24.0.
|
|
395
|
-
unique_toolkit-2026.24.0.
|
|
396
|
-
unique_toolkit-2026.24.0.
|
|
402
|
+
unique_toolkit-2026.24.0.dev8.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
403
|
+
unique_toolkit-2026.24.0.dev8.dist-info/METADATA,sha256=JE05F3xARkbw1R2yqL_Yc-YtzJhIXXjifPc7LLCQz6Q,7426
|
|
404
|
+
unique_toolkit-2026.24.0.dev8.dist-info/RECORD,,
|
|
File without changes
|