variably-sdk 2.12.0__tar.gz → 2.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/PKG-INFO +7 -2
  2. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/README.md +1 -1
  3. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/pyproject.toml +10 -1
  4. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably/__init__.py +2 -0
  5. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably/client.py +92 -19
  6. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably/config.py +10 -3
  7. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably/errors.py +19 -1
  8. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably/http_client.py +50 -3
  9. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably/integrations/__init__.py +8 -1
  10. variably_sdk-2.13.0/src/variably/integrations/adk.py +563 -0
  11. variably_sdk-2.13.0/src/variably/integrations/otel.py +112 -0
  12. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably/observe.py +8 -3
  13. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably/version.py +1 -1
  14. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably_sdk.egg-info/PKG-INFO +7 -2
  15. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably_sdk.egg-info/SOURCES.txt +4 -0
  16. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably_sdk.egg-info/requires.txt +9 -0
  17. variably_sdk-2.13.0/tests/test_integrations_adk.py +252 -0
  18. variably_sdk-2.13.0/tests/test_integrations_otel.py +67 -0
  19. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/setup.cfg +0 -0
  20. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably/assignment.py +0 -0
  21. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably/cache.py +0 -0
  22. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably/integrations/_common.py +0 -0
  23. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably/integrations/langgraph.py +0 -0
  24. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably/logger.py +0 -0
  25. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably/prompt_config.py +0 -0
  26. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably/types.py +0 -0
  27. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably_sdk.egg-info/dependency_links.txt +0 -0
  28. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/src/variably_sdk.egg-info/top_level.txt +0 -0
  29. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/tests/test_integrations_langgraph.py +0 -0
  30. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/tests/test_prompt_assignment.py +0 -0
  31. {variably_sdk-2.12.0 → variably_sdk-2.13.0}/tests/test_streaming.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: variably-sdk
3
- Version: 2.12.0
3
+ Version: 2.13.0
4
4
  Summary: Official Python SDK for Variably feature flags, LLM experimentation, and prompt optimization platform
5
5
  Author-email: Variably <support@variably.com>
6
6
  License: MIT
@@ -28,6 +28,11 @@ Requires-Dist: requests>=2.25.0
28
28
  Requires-Dist: typing-extensions>=3.7.4; python_version < "3.8"
29
29
  Provides-Extra: langgraph
30
30
  Requires-Dist: langgraph>=0.1; extra == "langgraph"
31
+ Provides-Extra: adk
32
+ Requires-Dist: google-adk<3,>=2.0; python_version >= "3.10" and extra == "adk"
33
+ Provides-Extra: otel
34
+ Requires-Dist: opentelemetry-sdk<2,>=1.20; extra == "otel"
35
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.20; extra == "otel"
31
36
  Provides-Extra: dev
32
37
  Requires-Dist: pytest>=6.0; extra == "dev"
33
38
  Requires-Dist: pytest-asyncio>=0.18.0; extra == "dev"
@@ -67,7 +72,7 @@ No experiment setup. No prompt migration. Just log and see scores.
67
72
 
68
73
  ```bash
69
74
  VARIABLY_API_KEY=vbl_your_key_here
70
- VARIABLY_BASE_URL=https://api.variably.tech
75
+ VARIABLY_BASE_URL=https://api.variably.dev
71
76
  ```
72
77
 
73
78
  ### 2. Add one line after your LLM call
@@ -23,7 +23,7 @@ No experiment setup. No prompt migration. Just log and see scores.
23
23
 
24
24
  ```bash
25
25
  VARIABLY_API_KEY=vbl_your_key_here
26
- VARIABLY_BASE_URL=https://api.variably.tech
26
+ VARIABLY_BASE_URL=https://api.variably.dev
27
27
  ```
28
28
 
29
29
  ### 2. Add one line after your LLM call
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "variably-sdk"
7
- version = "2.12.0"
7
+ version = "2.13.0"
8
8
  description = "Official Python SDK for Variably feature flags, LLM experimentation, and prompt optimization platform"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.7"
@@ -39,6 +39,15 @@ dependencies = [
39
39
  langgraph = [
40
40
  "langgraph>=0.1",
41
41
  ]
42
+ # Google ADK / Vertex AI Agent Engine plugin (#102). ADK itself needs Python >= 3.10.
43
+ adk = [
44
+ "google-adk>=2.0,<3; python_version>='3.10'",
45
+ ]
46
+ # OpenTelemetry exporter: send existing OTel traces to be evaluated.
47
+ otel = [
48
+ "opentelemetry-sdk>=1.20,<2",
49
+ "opentelemetry-exporter-otlp-proto-http>=1.20,<2",
50
+ ]
42
51
  dev = [
43
52
  "pytest>=6.0",
44
53
  "pytest-asyncio>=0.18.0",
@@ -8,6 +8,7 @@ from .client import VariablyClient
8
8
  from .config import VariablyConfig
9
9
  from .types import UserContext, Event, FlagResult, PromptVariant, EvaluateResponse, StreamingEvaluateResponse, SubmitResponseResult
10
10
  from .errors import (
11
+ FlagNotFoundError,
11
12
  VariablyError,
12
13
  NetworkError,
13
14
  AuthenticationError,
@@ -20,6 +21,7 @@ from .observe import observe
20
21
  from .version import __version__
21
22
 
22
23
  __all__ = [
24
+ "FlagNotFoundError",
23
25
  "VariablyClient",
24
26
  "VariablyConfig",
25
27
  "UserContext",
@@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional, Union
7
7
 
8
8
  from .cache import CacheManager, NoOpCache
9
9
  from .config import VariablyConfig
10
- from .errors import ConfigurationError, VariablyError
10
+ from .errors import ConfigurationError, FlagNotFoundError, VariablyError
11
11
  from .http_client import HTTPClient
12
12
  from .logger import VariablyLogger, create_logger
13
13
  from .prompt_config import PromptConfigStore, http_config_fetcher
@@ -24,6 +24,10 @@ from .types import (
24
24
  )
25
25
 
26
26
 
27
+ #: Reason the API returns when a project has no such flag.
28
+ FLAG_NOT_FOUND_REASON = "gate_not_found"
29
+
30
+
27
31
  class VariablyClient:
28
32
  """Main Variably SDK client for feature flags and experimentation."""
29
33
 
@@ -155,10 +159,10 @@ class VariablyClient:
155
159
  raise ConfigurationError(
156
160
  "Flag key must be a non-empty string", "flag_key"
157
161
  )
158
- if not user_context.get("user_id"):
159
- raise ConfigurationError(
160
- "User context must include user_id", "user_context.user_id"
161
- )
162
+ # A user id is optional. Server-rendered callers evaluate before a user
163
+ # is known and expect the flag's default rather than an exception; the API
164
+ # accepts an anonymous context, so rejecting it here would make the SDK
165
+ # stricter than the service it wraps.
162
166
 
163
167
  # Check cache first
164
168
  cache_key = self._generate_cache_key(flag_key, user_context)
@@ -167,7 +171,7 @@ class VariablyClient:
167
171
  self.metrics.cache_hits += 1
168
172
  self.logger.debug(
169
173
  "Flag evaluation cache hit",
170
- {"flag_key": flag_key, "user_id": user_context["user_id"]},
174
+ {"flag_key": flag_key, "user_id": user_context.get("user_id", "")},
171
175
  )
172
176
  cached.cache_hit = True
173
177
  return cached
@@ -176,10 +180,15 @@ class VariablyClient:
176
180
 
177
181
  # Evaluate via API
178
182
  response = self.http_client.evaluate_flag(flag_key, user_context)
179
-
183
+
184
+ if response.get("reason") == FLAG_NOT_FOUND_REASON:
185
+ raise FlagNotFoundError(flag_key)
186
+
187
+ value = response["value"]
188
+
180
189
  result = FlagResult(
181
190
  key=flag_key,
182
- value=response["value"],
191
+ value=value,
183
192
  reason=response.get("reason", "api_evaluation"),
184
193
  rule_id=response.get("rule_id"),
185
194
  cache_hit=False,
@@ -193,13 +202,17 @@ class VariablyClient:
193
202
  "Flag evaluation successful",
194
203
  {
195
204
  "flag_key": flag_key,
196
- "value": response["value"],
197
- "user_id": user_context["user_id"],
205
+ "value": value,
206
+ "user_id": user_context.get("user_id", ""),
198
207
  },
199
208
  )
200
209
 
201
210
  return result
202
211
 
212
+ except FlagNotFoundError:
213
+ # A missing flag is a configuration error and must reach the caller.
214
+ # Only transient failures below degrade to the default.
215
+ raise
203
216
  except Exception as error:
204
217
  self.logger.error(
205
218
  "Flag evaluation failed",
@@ -306,10 +319,10 @@ class VariablyClient:
306
319
  raise ConfigurationError(
307
320
  "Gate key must be a non-empty string", "gate_key"
308
321
  )
309
- if not user_context.get("user_id"):
310
- raise ConfigurationError(
311
- "User context must include user_id", "user_context.user_id"
312
- )
322
+ # A user id is optional. Server-rendered callers evaluate before a user
323
+ # is known and expect the flag's default rather than an exception; the API
324
+ # accepts an anonymous context, so rejecting it here would make the SDK
325
+ # stricter than the service it wraps.
313
326
 
314
327
  # Check cache first
315
328
  cache_key = self._generate_gate_cache_key(gate_key, user_context)
@@ -318,7 +331,7 @@ class VariablyClient:
318
331
  self.metrics.cache_hits += 1
319
332
  self.logger.debug(
320
333
  "Gate evaluation cache hit",
321
- {"gate_key": gate_key, "user_id": user_context["user_id"]},
334
+ {"gate_key": gate_key, "user_id": user_context.get("user_id", "")},
322
335
  )
323
336
  return cached.value
324
337
 
@@ -342,8 +355,8 @@ class VariablyClient:
342
355
  "Gate evaluation successful",
343
356
  {
344
357
  "gate_key": gate_key,
345
- "value": response["value"],
346
- "user_id": user_context["user_id"],
358
+ "value": value,
359
+ "user_id": user_context.get("user_id", ""),
347
360
  },
348
361
  )
349
362
 
@@ -359,7 +372,67 @@ class VariablyClient:
359
372
  },
360
373
  )
361
374
  return False # Default to false for gates
362
-
375
+
376
+ def evaluate_gates(
377
+ self, gate_keys: List[str], user_context: UserContext
378
+ ) -> Dict[str, bool]:
379
+ """
380
+ Evaluate multiple feature gates.
381
+
382
+ Mirrors the Go SDK's ``EvaluateGates`` so both SDKs expose the same
383
+ feature-flag surface.
384
+
385
+ Args:
386
+ gate_keys: Feature gate keys
387
+ user_context: User context for access control
388
+
389
+ Returns:
390
+ Mapping of gate key to whether access is granted. A gate that fails to
391
+ evaluate is False, matching ``evaluate_gate``.
392
+ """
393
+ results: Dict[str, bool] = {}
394
+ for gate_key in gate_keys:
395
+ results[gate_key] = self.evaluate_gate(gate_key, user_context)
396
+ return results
397
+
398
+ def all_flags(self, user_context: UserContext) -> Dict[str, FlagResult]:
399
+ """
400
+ Evaluate every flag in the project for one user context.
401
+
402
+ The caller does not supply flag keys: the server discovers them, so a newly
403
+ created flag reaches the SDK without a release. This is what a process should
404
+ call on start-up, and what a server-side renderer should use to build the
405
+ snapshot it hands to a browser client.
406
+
407
+ A flag missing from the result has no definition in this project. Callers
408
+ should treat that as "unset" rather than False, so a not-yet-created flag is
409
+ distinguishable from one deliberately turned off.
410
+ """
411
+ response = self.http_client.evaluate_all_flags(user_context)
412
+
413
+ results: Dict[str, FlagResult] = {}
414
+ for flag_key, result in (response.get("flags") or {}).items():
415
+ results[flag_key] = FlagResult(
416
+ key=flag_key,
417
+ value=result.get("value"),
418
+ reason=result.get("reason", "api_evaluation"),
419
+ rule_id=result.get("rule_id"),
420
+ variation=result.get("variant_id"),
421
+ cache_hit=False,
422
+ )
423
+ return results
424
+
425
+ def validate_flags(
426
+ self, expected_keys: List[str], user_context: Optional[UserContext] = None
427
+ ) -> List[str]:
428
+ """Report which of ``expected_keys`` the project does not define.
429
+
430
+ Call this at start-up: it surfaces every missing flag at once, before a
431
+ request path can hit whichever one happens to be read first.
432
+ """
433
+ defined = self.all_flags(user_context or {"user_id": ""})
434
+ return [key for key in expected_keys if key not in defined]
435
+
363
436
  def track(self, event: Event) -> None:
364
437
  """
365
438
  Track an analytics event.
@@ -921,7 +994,7 @@ class VariablyClient:
921
994
  # Create a stable hash of user context
922
995
  context_str = json.dumps(
923
996
  {
924
- "user_id": user_context["user_id"],
997
+ "user_id": user_context.get("user_id", ""),
925
998
  "attributes": user_context.get("attributes", {}),
926
999
  },
927
1000
  sort_keys=True,
@@ -25,7 +25,9 @@ class VariablyConfig:
25
25
 
26
26
  Args:
27
27
  api_key: API key for authentication
28
- base_url: Base URL for the API (default: http://localhost:8080)
28
+ base_url: Base URL for the API. REQUIRED — no default (e.g. your
29
+ self-hosted https://api.your-domain.com). Fail-closed by design so
30
+ evaluation data is never sent to an unintended host.
29
31
  environment: Environment name (development, staging, production)
30
32
  timeout: Request timeout in milliseconds (default: 5000)
31
33
  retry_attempts: Number of retry attempts (default: 3)
@@ -35,9 +37,14 @@ class VariablyConfig:
35
37
  """
36
38
  if not api_key:
37
39
  raise ValueError("API key is required")
38
-
40
+ if not base_url:
41
+ raise ValueError(
42
+ "base_url is required — set VARIABLY_BASE_URL to your API endpoint. "
43
+ "No default, so evaluation data is never sent to an unintended host."
44
+ )
45
+
39
46
  self.api_key = api_key
40
- self.base_url = base_url or "http://localhost:8080"
47
+ self.base_url = base_url
41
48
  self.environment = environment or "development"
42
49
  self.timeout = timeout / 1000.0 # Convert to seconds
43
50
  self.retry_attempts = retry_attempts
@@ -58,4 +58,22 @@ class ConfigurationError(VariablyError):
58
58
 
59
59
  def __init__(self, message: str, parameter: str, cause: Exception = None):
60
60
  super().__init__(message, cause)
61
- self.parameter = parameter
61
+ self.parameter = parameter
62
+
63
+ class FlagNotFoundError(VariablyError):
64
+ """Raised when a flag is not defined in the project.
65
+
66
+ This is a configuration error, not a value. Returning the caller's default
67
+ here would make a typo, an unseeded flag, or a half-finished migration look
68
+ exactly like a flag that is deliberately off — the application would run on
69
+ defaults and appear healthy. Network failures are different and still fall
70
+ back to the default: those are transient, this one is not.
71
+ """
72
+
73
+ def __init__(self, flag_key: str):
74
+ self.flag_key = flag_key
75
+ super().__init__(
76
+ f'Variably: flag "{flag_key}" is not defined in this project. '
77
+ "Create it, or check the key for a typo — a missing flag is not "
78
+ 'treated as "off".'
79
+ )
@@ -31,6 +31,40 @@ from .types import (
31
31
  )
32
32
 
33
33
 
34
+
35
+ def to_sdk_context(context: UserContext) -> Dict[str, Any]:
36
+ """Map a UserContext onto the wire shape the API expects.
37
+
38
+ UserContext is snake_case (``user_id``) but the API binds camelCase
39
+ (``userId``). Passing the dict through unmapped drops the user id silently:
40
+ every evaluation then looks anonymous, so overrides and targeting rules are
41
+ ignored and the caller gets a plausible-but-wrong answer rather than an error.
42
+ """
43
+ mapping = {
44
+ "user_id": "userId",
45
+ "email": "email",
46
+ "country": "country",
47
+ "language": "language",
48
+ "platform": "platform",
49
+ "version": "version",
50
+ "ip_address": "ipAddress",
51
+ "user_agent": "userAgent",
52
+ "session_id": "sessionId",
53
+ }
54
+
55
+ payload: Dict[str, Any] = {}
56
+ for snake, camel in mapping.items():
57
+ value = context.get(snake)
58
+ if value:
59
+ payload[camel] = value
60
+
61
+ attributes = context.get("attributes")
62
+ if attributes:
63
+ payload["attributes"] = attributes
64
+
65
+ return payload
66
+
67
+
34
68
  class HTTPClient:
35
69
  """HTTP client for Variably API with retry logic and error handling."""
36
70
 
@@ -69,7 +103,7 @@ class HTTPClient:
69
103
  """Evaluate a single feature flag."""
70
104
  request_data: FlagEvaluationRequest = {
71
105
  "flag_key": flag_key,
72
- "context": context,
106
+ "context": to_sdk_context(context),
73
107
  }
74
108
 
75
109
  response = self._make_request(
@@ -87,7 +121,7 @@ class HTTPClient:
87
121
  """Evaluate multiple feature flags in batch."""
88
122
  request_data: BatchFlagEvaluationRequest = {
89
123
  "flag_keys": flag_keys,
90
- "context": context,
124
+ "context": to_sdk_context(context),
91
125
  }
92
126
 
93
127
  response = self._make_request(
@@ -99,11 +133,24 @@ class HTTPClient:
99
133
 
100
134
  return response
101
135
 
136
+ def evaluate_all_flags(self, context: UserContext) -> Dict[str, Any]:
137
+ """Evaluate every flag in the project for one context.
138
+
139
+ No flag keys are sent: the server discovers them, so a newly created flag
140
+ reaches the SDK without a client release.
141
+ """
142
+ return self._make_request(
143
+ "POST",
144
+ "/api/v1/sdk/evaluate/all",
145
+ json={"context": to_sdk_context(context)},
146
+ retryable=True,
147
+ )
148
+
102
149
  def evaluate_gate(self, gate_key: str, context: UserContext) -> GateEvaluationResponse:
103
150
  """Evaluate a feature gate."""
104
151
  request_data: GateEvaluationRequest = {
105
152
  "gate_key": gate_key,
106
- "context": context,
153
+ "context": to_sdk_context(context),
107
154
  }
108
155
 
109
156
  response = self._make_request(
@@ -6,10 +6,17 @@ deterministic cross-step evaluation with little or no per-node code:
6
6
  from variably.integrations.langgraph import instrument
7
7
  instrument(graph, session_id_key="session_id")
8
8
 
9
+ from variably.integrations.adk import VariablyPlugin # Google ADK / Agent Engine
10
+ Runner(agent=root_agent, ..., plugins=[VariablyPlugin()])
11
+
12
+ from variably.integrations.otel import instrument # any OpenTelemetry tracing
13
+ instrument()
14
+
9
15
  Each integration maps the framework's node/agent identity to ``agent_name``,
10
16
  detects the terminal node(s) as ``is_aggregator``, pulls session id + context
11
17
  from the framework's state, and logs via ``variably.observe`` — always
12
18
  best-effort, never raising into the customer's workflow.
13
19
 
14
- Install extras per framework, e.g. ``pip install variably-sdk[langgraph]``.
20
+ Install extras per framework, e.g. ``pip install variably-sdk[langgraph]`` or
21
+ ``pip install "variably-sdk[adk]"`` or ``pip install "variably-sdk[otel]"``.
15
22
  """