mowa 0.1.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.
- mowa-0.1.0/.gitignore +5 -0
- mowa-0.1.0/PKG-INFO +88 -0
- mowa-0.1.0/README.md +74 -0
- mowa-0.1.0/pyproject.toml +30 -0
- mowa-0.1.0/src/mowa/__init__.py +3 -0
- mowa-0.1.0/src/mowa/client.py +318 -0
- mowa-0.1.0/src/mowa/py.typed +0 -0
- mowa-0.1.0/tests/test_client.py +395 -0
mowa-0.1.0/.gitignore
ADDED
mowa-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mowa
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Load your Mowa agents at runtime.
|
|
5
|
+
Project-URL: Homepage, https://mowa.dev
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Typing :: Typed
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Provides-Extra: httpx
|
|
12
|
+
Requires-Dist: httpx>=0.24; extra == 'httpx'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# mowa
|
|
16
|
+
|
|
17
|
+
Load your Mowa agents at runtime. Edit the prompt in Mowa, ship it without a deploy.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install mowa
|
|
23
|
+
pip install "mowa[httpx]"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
No required deps. Uses `httpx` if it's installed, else the standard library.
|
|
27
|
+
|
|
28
|
+
## Use
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from openai import OpenAI
|
|
32
|
+
from mowa import Mowa
|
|
33
|
+
|
|
34
|
+
mowa = Mowa()
|
|
35
|
+
openai = OpenAI()
|
|
36
|
+
|
|
37
|
+
agent = mowa.get_agent(
|
|
38
|
+
"support",
|
|
39
|
+
fallback={"system_prompt": "You are a helpful support agent.", "model": "gpt-4o-mini"},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
reply = openai.chat.completions.create(
|
|
43
|
+
model=agent.model,
|
|
44
|
+
messages=[
|
|
45
|
+
{"role": "system", "content": agent.system_prompt},
|
|
46
|
+
{"role": "user", "content": "Where is my order?"},
|
|
47
|
+
],
|
|
48
|
+
temperature=agent.settings.get("temperature"),
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`Mowa()` reads `MOWA_API_KEY` and `MOWA_BASE_URL` from the environment. You can also pass `api_key`, `base_url`, `cache_ttl` (default 60s) and `timeout` (default 2s).
|
|
53
|
+
|
|
54
|
+
## Versions
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
mowa.get_agent("support")
|
|
58
|
+
mowa.get_agent("support", version="draft")
|
|
59
|
+
mowa.get_agent("support", version=3)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Caching and fallback
|
|
63
|
+
|
|
64
|
+
- First call fetches the agent and keeps it in memory.
|
|
65
|
+
- For the next `cache_ttl` seconds you get the cached copy. No network.
|
|
66
|
+
- After that you still get the cached copy right away, and it refreshes in the background.
|
|
67
|
+
- If the first fetch fails and you passed a `fallback`, you get the fallback. It never raises.
|
|
68
|
+
- With no fallback, it raises `MowaError`.
|
|
69
|
+
|
|
70
|
+
`agent.source` tells you where it came from: `network`, `cache`, `stale` or `fallback`.
|
|
71
|
+
|
|
72
|
+
Warm the cache at startup with `mowa.prefetch(["support", "billing"])`. Reset it with `mowa.clear_cache()`.
|
|
73
|
+
|
|
74
|
+
## Traces
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
span.set_attributes(agent.trace_attributes())
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Gives `{"mowa.agent": "support", "mowa.version": "v3"}`, so traces link back to the version that ran.
|
|
81
|
+
|
|
82
|
+
## Raw HTTP
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
curl https://mowa.dev/api/v1/agents/support/live -H "x-api-key: $MOWA_API_KEY"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Add `?version=draft` or `?version=v3`. Send `If-None-Match` with the `ETag` to get a `304` when nothing changed.
|
mowa-0.1.0/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# mowa
|
|
2
|
+
|
|
3
|
+
Load your Mowa agents at runtime. Edit the prompt in Mowa, ship it without a deploy.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install mowa
|
|
9
|
+
pip install "mowa[httpx]"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
No required deps. Uses `httpx` if it's installed, else the standard library.
|
|
13
|
+
|
|
14
|
+
## Use
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from openai import OpenAI
|
|
18
|
+
from mowa import Mowa
|
|
19
|
+
|
|
20
|
+
mowa = Mowa()
|
|
21
|
+
openai = OpenAI()
|
|
22
|
+
|
|
23
|
+
agent = mowa.get_agent(
|
|
24
|
+
"support",
|
|
25
|
+
fallback={"system_prompt": "You are a helpful support agent.", "model": "gpt-4o-mini"},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
reply = openai.chat.completions.create(
|
|
29
|
+
model=agent.model,
|
|
30
|
+
messages=[
|
|
31
|
+
{"role": "system", "content": agent.system_prompt},
|
|
32
|
+
{"role": "user", "content": "Where is my order?"},
|
|
33
|
+
],
|
|
34
|
+
temperature=agent.settings.get("temperature"),
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`Mowa()` reads `MOWA_API_KEY` and `MOWA_BASE_URL` from the environment. You can also pass `api_key`, `base_url`, `cache_ttl` (default 60s) and `timeout` (default 2s).
|
|
39
|
+
|
|
40
|
+
## Versions
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
mowa.get_agent("support")
|
|
44
|
+
mowa.get_agent("support", version="draft")
|
|
45
|
+
mowa.get_agent("support", version=3)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Caching and fallback
|
|
49
|
+
|
|
50
|
+
- First call fetches the agent and keeps it in memory.
|
|
51
|
+
- For the next `cache_ttl` seconds you get the cached copy. No network.
|
|
52
|
+
- After that you still get the cached copy right away, and it refreshes in the background.
|
|
53
|
+
- If the first fetch fails and you passed a `fallback`, you get the fallback. It never raises.
|
|
54
|
+
- With no fallback, it raises `MowaError`.
|
|
55
|
+
|
|
56
|
+
`agent.source` tells you where it came from: `network`, `cache`, `stale` or `fallback`.
|
|
57
|
+
|
|
58
|
+
Warm the cache at startup with `mowa.prefetch(["support", "billing"])`. Reset it with `mowa.clear_cache()`.
|
|
59
|
+
|
|
60
|
+
## Traces
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
span.set_attributes(agent.trace_attributes())
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Gives `{"mowa.agent": "support", "mowa.version": "v3"}`, so traces link back to the version that ran.
|
|
67
|
+
|
|
68
|
+
## Raw HTTP
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
curl https://mowa.dev/api/v1/agents/support/live -H "x-api-key: $MOWA_API_KEY"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Add `?version=draft` or `?version=v3`. Send `If-None-Match` with the `ETag` to get a `304` when nothing changed.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mowa"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Load your Mowa agents at runtime."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = []
|
|
13
|
+
classifiers = [
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
httpx = ["httpx>=0.24"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://mowa.dev"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/mowa"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
testpaths = ["tests"]
|
|
30
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from dataclasses import dataclass, field, replace
|
|
11
|
+
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Set, Tuple, Union
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://mowa.dev"
|
|
17
|
+
|
|
18
|
+
Transport = Callable[[str, Mapping[str, str], float], Tuple[int, Mapping[str, str], bytes]]
|
|
19
|
+
VersionSelector = Union[str, int]
|
|
20
|
+
CacheKey = Tuple[str, str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MowaError(Exception):
|
|
24
|
+
def __init__(self, message: str, status: Optional[int] = None, code: Optional[str] = None) -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.message = message
|
|
27
|
+
self.status = status
|
|
28
|
+
self.code = code
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Agent:
|
|
33
|
+
slug: str
|
|
34
|
+
version: str
|
|
35
|
+
system_prompt: str
|
|
36
|
+
model: str
|
|
37
|
+
provider: Optional[str] = None
|
|
38
|
+
name: Optional[str] = None
|
|
39
|
+
settings: Dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
tools: List[Any] = field(default_factory=list)
|
|
41
|
+
inputs: List[Any] = field(default_factory=list)
|
|
42
|
+
outputs: List[Any] = field(default_factory=list)
|
|
43
|
+
source: str = "network"
|
|
44
|
+
|
|
45
|
+
def trace_attributes(self) -> Dict[str, str]:
|
|
46
|
+
return {"mowa.agent": self.slug, "mowa.version": self.version}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class _CacheEntry:
|
|
51
|
+
agent: Agent
|
|
52
|
+
etag: Optional[str]
|
|
53
|
+
fetched_at: float
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def urllib_transport(url: str, headers: Mapping[str, str], timeout: float) -> Tuple[int, Mapping[str, str], bytes]:
|
|
57
|
+
request = urllib.request.Request(url, headers=dict(headers), method="GET")
|
|
58
|
+
try:
|
|
59
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
60
|
+
return response.status, dict(response.headers.items()), response.read()
|
|
61
|
+
except urllib.error.HTTPError as error:
|
|
62
|
+
try:
|
|
63
|
+
body = error.read()
|
|
64
|
+
except Exception:
|
|
65
|
+
body = b""
|
|
66
|
+
response_headers = dict(error.headers.items()) if error.headers is not None else {}
|
|
67
|
+
return error.code, response_headers, body or b""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HttpxTransport:
|
|
71
|
+
def __init__(self) -> None:
|
|
72
|
+
import httpx
|
|
73
|
+
|
|
74
|
+
self._client = httpx.Client(follow_redirects=True)
|
|
75
|
+
|
|
76
|
+
def __call__(self, url: str, headers: Mapping[str, str], timeout: float) -> Tuple[int, Mapping[str, str], bytes]:
|
|
77
|
+
response = self._client.get(url, headers=dict(headers), timeout=timeout)
|
|
78
|
+
return response.status_code, dict(response.headers.items()), response.content
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def default_transport() -> Transport:
|
|
82
|
+
try:
|
|
83
|
+
return HttpxTransport()
|
|
84
|
+
except ImportError:
|
|
85
|
+
return urllib_transport
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def version_key(version: VersionSelector) -> str:
|
|
89
|
+
if isinstance(version, bool):
|
|
90
|
+
raise ValueError("version must be \"live\", \"draft\" or an int like 3.")
|
|
91
|
+
if isinstance(version, int):
|
|
92
|
+
if version < 1:
|
|
93
|
+
raise ValueError("version must be 1 or higher.")
|
|
94
|
+
return f"v{version}"
|
|
95
|
+
if isinstance(version, str):
|
|
96
|
+
cleaned = version.strip().lower()
|
|
97
|
+
if cleaned in ("live", "draft"):
|
|
98
|
+
return cleaned
|
|
99
|
+
if cleaned.startswith("v") and cleaned[1:].isdigit():
|
|
100
|
+
return cleaned
|
|
101
|
+
if cleaned.isdigit():
|
|
102
|
+
return f"v{cleaned}"
|
|
103
|
+
raise ValueError("version must be \"live\", \"draft\" or an int like 3.")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def provider_from_model(model: str) -> Optional[str]:
|
|
107
|
+
if "/" in model:
|
|
108
|
+
provider = model.split("/", 1)[0].strip()
|
|
109
|
+
return provider or None
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _lowercase_headers(headers: Optional[Mapping[str, str]]) -> Dict[str, str]:
|
|
114
|
+
return {str(name).lower(): str(value) for name, value in (headers or {}).items()}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _error_from_body(slug: str, status: int, body: bytes) -> MowaError:
|
|
118
|
+
message = f"HTTP {status}"
|
|
119
|
+
code: Optional[str] = None
|
|
120
|
+
try:
|
|
121
|
+
payload = json.loads(body.decode("utf-8")) if body else None
|
|
122
|
+
except (ValueError, UnicodeDecodeError):
|
|
123
|
+
payload = None
|
|
124
|
+
if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
|
|
125
|
+
error = payload["error"]
|
|
126
|
+
if error.get("message"):
|
|
127
|
+
message = f"{error['message']} (HTTP {status})"
|
|
128
|
+
if error.get("code"):
|
|
129
|
+
code = str(error["code"])
|
|
130
|
+
return MowaError(f"Could not load agent \"{slug}\": {message}", status=status, code=code)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _agent_from_payload(slug: str, requested_version: str, payload: Any) -> Agent:
|
|
134
|
+
if not isinstance(payload, dict):
|
|
135
|
+
raise MowaError(f"Could not load agent \"{slug}\": response was not a JSON object.")
|
|
136
|
+
model = str(payload.get("model") or "")
|
|
137
|
+
return Agent(
|
|
138
|
+
slug=str(payload.get("slug") or slug),
|
|
139
|
+
version=str(payload.get("version") or requested_version),
|
|
140
|
+
system_prompt=str(payload.get("systemPrompt") or ""),
|
|
141
|
+
model=model,
|
|
142
|
+
provider=payload.get("provider") or provider_from_model(model),
|
|
143
|
+
name=payload.get("name"),
|
|
144
|
+
settings=dict(payload.get("settings") or {}),
|
|
145
|
+
tools=list(payload.get("tools") or []),
|
|
146
|
+
inputs=list(payload.get("inputs") or []),
|
|
147
|
+
outputs=list(payload.get("outputs") or []),
|
|
148
|
+
source="network",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def agent_from_fallback(slug: str, fallback: Mapping[str, Any]) -> Agent:
|
|
153
|
+
model = str(fallback.get("model") or "")
|
|
154
|
+
return Agent(
|
|
155
|
+
slug=slug,
|
|
156
|
+
version="fallback",
|
|
157
|
+
system_prompt=str(fallback.get("system_prompt") or ""),
|
|
158
|
+
model=model,
|
|
159
|
+
provider=fallback.get("provider") or provider_from_model(model),
|
|
160
|
+
settings=dict(fallback.get("settings") or {}),
|
|
161
|
+
source="fallback",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Mowa:
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
api_key: Optional[str] = None,
|
|
169
|
+
base_url: Optional[str] = None,
|
|
170
|
+
cache_ttl: float = 60.0,
|
|
171
|
+
timeout: float = 2.0,
|
|
172
|
+
transport: Optional[Transport] = None,
|
|
173
|
+
clock: Optional[Callable[[], float]] = None,
|
|
174
|
+
) -> None:
|
|
175
|
+
resolved_key = api_key if api_key is not None else os.environ.get("MOWA_API_KEY")
|
|
176
|
+
self.api_key = resolved_key.strip() if resolved_key else None
|
|
177
|
+
resolved_base = base_url or os.environ.get("MOWA_BASE_URL") or DEFAULT_BASE_URL
|
|
178
|
+
self.base_url = resolved_base.strip().rstrip("/")
|
|
179
|
+
self.cache_ttl = float(cache_ttl)
|
|
180
|
+
self.timeout = float(timeout)
|
|
181
|
+
self._transport = transport
|
|
182
|
+
self._clock = clock or time.monotonic
|
|
183
|
+
self._lock = threading.Lock()
|
|
184
|
+
self._cache: Dict[CacheKey, _CacheEntry] = {}
|
|
185
|
+
self._refreshing: Set[CacheKey] = set()
|
|
186
|
+
self._refresh_threads: List[threading.Thread] = []
|
|
187
|
+
|
|
188
|
+
def get_agent(
|
|
189
|
+
self,
|
|
190
|
+
slug: str,
|
|
191
|
+
version: VersionSelector = "live",
|
|
192
|
+
fallback: Optional[Mapping[str, Any]] = None,
|
|
193
|
+
) -> Agent:
|
|
194
|
+
key = (slug, version_key(version))
|
|
195
|
+
with self._lock:
|
|
196
|
+
entry = self._cache.get(key)
|
|
197
|
+
if entry is not None:
|
|
198
|
+
if self._clock() - entry.fetched_at < self.cache_ttl:
|
|
199
|
+
return replace(entry.agent, source="cache")
|
|
200
|
+
stale_agent = replace(entry.agent, source="stale")
|
|
201
|
+
else:
|
|
202
|
+
stale_agent = None
|
|
203
|
+
if stale_agent is not None:
|
|
204
|
+
self._refresh_in_background(key)
|
|
205
|
+
return stale_agent
|
|
206
|
+
try:
|
|
207
|
+
return self._fetch_and_store(key, etag=None)
|
|
208
|
+
except Exception as error:
|
|
209
|
+
if fallback is not None:
|
|
210
|
+
return agent_from_fallback(slug, fallback)
|
|
211
|
+
if isinstance(error, MowaError):
|
|
212
|
+
raise
|
|
213
|
+
reason = str(error) or type(error).__name__
|
|
214
|
+
raise MowaError(f"Could not load agent \"{slug}\": {reason}") from error
|
|
215
|
+
|
|
216
|
+
def prefetch(self, slugs: Iterable[str], version: VersionSelector = "live") -> None:
|
|
217
|
+
unique_slugs = list(dict.fromkeys(slugs))
|
|
218
|
+
if not unique_slugs:
|
|
219
|
+
return
|
|
220
|
+
with ThreadPoolExecutor(max_workers=min(8, len(unique_slugs))) as pool:
|
|
221
|
+
for future in [pool.submit(self._prefetch_one, slug, version) for slug in unique_slugs]:
|
|
222
|
+
future.result()
|
|
223
|
+
|
|
224
|
+
def clear_cache(self) -> None:
|
|
225
|
+
with self._lock:
|
|
226
|
+
self._cache.clear()
|
|
227
|
+
|
|
228
|
+
def wait_for_refresh(self, timeout: Optional[float] = 5.0) -> bool:
|
|
229
|
+
with self._lock:
|
|
230
|
+
threads = list(self._refresh_threads)
|
|
231
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
232
|
+
for thread in threads:
|
|
233
|
+
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
234
|
+
thread.join(remaining)
|
|
235
|
+
with self._lock:
|
|
236
|
+
self._refresh_threads = [thread for thread in self._refresh_threads if thread.is_alive()]
|
|
237
|
+
return not self._refresh_threads
|
|
238
|
+
|
|
239
|
+
def _prefetch_one(self, slug: str, version: VersionSelector) -> None:
|
|
240
|
+
try:
|
|
241
|
+
self.get_agent(slug, version=version)
|
|
242
|
+
except MowaError:
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
def _refresh_in_background(self, key: CacheKey) -> None:
|
|
246
|
+
with self._lock:
|
|
247
|
+
if key in self._refreshing:
|
|
248
|
+
return
|
|
249
|
+
self._refreshing.add(key)
|
|
250
|
+
thread = threading.Thread(
|
|
251
|
+
target=self._refresh,
|
|
252
|
+
args=(key,),
|
|
253
|
+
name=f"mowa-refresh-{key[0]}-{key[1]}",
|
|
254
|
+
daemon=True,
|
|
255
|
+
)
|
|
256
|
+
self._refresh_threads = [alive for alive in self._refresh_threads if alive.is_alive()]
|
|
257
|
+
self._refresh_threads.append(thread)
|
|
258
|
+
thread.start()
|
|
259
|
+
|
|
260
|
+
def _refresh(self, key: CacheKey) -> None:
|
|
261
|
+
try:
|
|
262
|
+
with self._lock:
|
|
263
|
+
entry = self._cache.get(key)
|
|
264
|
+
etag = entry.etag if entry is not None else None
|
|
265
|
+
self._fetch_and_store(key, etag=etag)
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
finally:
|
|
269
|
+
with self._lock:
|
|
270
|
+
self._refreshing.discard(key)
|
|
271
|
+
|
|
272
|
+
def _url_for(self, key: CacheKey) -> str:
|
|
273
|
+
slug, selected = key
|
|
274
|
+
url = f"{self.base_url}/api/v1/agents/{quote(slug, safe='')}/live"
|
|
275
|
+
if selected != "live":
|
|
276
|
+
url += f"?version={selected}"
|
|
277
|
+
return url
|
|
278
|
+
|
|
279
|
+
def _headers_for(self, etag: Optional[str]) -> Dict[str, str]:
|
|
280
|
+
headers = {
|
|
281
|
+
"accept": "application/json",
|
|
282
|
+
"x-api-key": self.api_key or "",
|
|
283
|
+
"x-mowa-sdk": f"py/{__version__}",
|
|
284
|
+
"user-agent": f"mowa-python/{__version__}",
|
|
285
|
+
}
|
|
286
|
+
if etag:
|
|
287
|
+
headers["if-none-match"] = etag
|
|
288
|
+
return headers
|
|
289
|
+
|
|
290
|
+
def _send(self, url: str, headers: Mapping[str, str]) -> Tuple[int, Mapping[str, str], bytes]:
|
|
291
|
+
if self._transport is None:
|
|
292
|
+
self._transport = default_transport()
|
|
293
|
+
return self._transport(url, headers, self.timeout)
|
|
294
|
+
|
|
295
|
+
def _fetch_and_store(self, key: CacheKey, etag: Optional[str]) -> Agent:
|
|
296
|
+
slug, selected = key
|
|
297
|
+
if not self.api_key:
|
|
298
|
+
raise MowaError("Set MOWA_API_KEY or pass api_key.")
|
|
299
|
+
status, raw_headers, body = self._send(self._url_for(key), self._headers_for(etag))
|
|
300
|
+
response_headers = _lowercase_headers(raw_headers)
|
|
301
|
+
if status == 304:
|
|
302
|
+
with self._lock:
|
|
303
|
+
entry = self._cache.get(key)
|
|
304
|
+
if entry is None:
|
|
305
|
+
raise MowaError(f"Could not load agent \"{slug}\": got 304 with no cached copy.", status=304)
|
|
306
|
+
entry.fetched_at = self._clock()
|
|
307
|
+
return entry.agent
|
|
308
|
+
if not 200 <= status < 300:
|
|
309
|
+
raise _error_from_body(slug, status, body)
|
|
310
|
+
try:
|
|
311
|
+
payload = json.loads(body.decode("utf-8"))
|
|
312
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
313
|
+
raise MowaError(f"Could not load agent \"{slug}\": response was not valid JSON.") from error
|
|
314
|
+
agent = _agent_from_payload(slug, selected, payload)
|
|
315
|
+
new_etag = response_headers.get("etag") or (payload.get("etag") if isinstance(payload, dict) else None)
|
|
316
|
+
with self._lock:
|
|
317
|
+
self._cache[key] = _CacheEntry(agent=agent, etag=new_etag, fetched_at=self._clock())
|
|
318
|
+
return agent
|
|
File without changes
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import socket
|
|
3
|
+
import threading
|
|
4
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from mowa import Agent, Mowa, MowaError, __version__
|
|
9
|
+
from mowa.client import urllib_transport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def agent_payload(version="v3", system_prompt="You are helpful.", etag='"etag-1"'):
|
|
13
|
+
return {
|
|
14
|
+
"slug": "support",
|
|
15
|
+
"name": "Support",
|
|
16
|
+
"version": version,
|
|
17
|
+
"systemPrompt": system_prompt,
|
|
18
|
+
"provider": "openai",
|
|
19
|
+
"model": "gpt-4o-mini",
|
|
20
|
+
"settings": {"temperature": 0.2},
|
|
21
|
+
"tools": [{"name": "lookup"}],
|
|
22
|
+
"inputs": [{"name": "question"}],
|
|
23
|
+
"outputs": [],
|
|
24
|
+
"etag": etag,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FakeClock:
|
|
29
|
+
def __init__(self):
|
|
30
|
+
self.now = 1000.0
|
|
31
|
+
|
|
32
|
+
def __call__(self):
|
|
33
|
+
return self.now
|
|
34
|
+
|
|
35
|
+
def advance(self, seconds):
|
|
36
|
+
self.now += seconds
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FakeTransport:
|
|
40
|
+
def __init__(self, *responses):
|
|
41
|
+
self.responses = list(responses)
|
|
42
|
+
self.calls = []
|
|
43
|
+
|
|
44
|
+
def __call__(self, url, headers, timeout):
|
|
45
|
+
self.calls.append({"url": url, "headers": dict(headers), "timeout": timeout})
|
|
46
|
+
response = self.responses.pop(0) if len(self.responses) > 1 else self.responses[0]
|
|
47
|
+
if isinstance(response, BaseException):
|
|
48
|
+
raise response
|
|
49
|
+
return response
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def ok(payload, etag='"etag-1"'):
|
|
53
|
+
return 200, {"ETag": etag, "Content-Type": "application/json"}, json.dumps(payload).encode()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def not_modified(etag='"etag-1"'):
|
|
57
|
+
return 304, {"ETag": etag}, b""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def make_client(transport, clock=None, **options):
|
|
61
|
+
return Mowa(
|
|
62
|
+
api_key="mk_test",
|
|
63
|
+
base_url="https://example.test/",
|
|
64
|
+
transport=transport,
|
|
65
|
+
clock=clock or FakeClock(),
|
|
66
|
+
**options,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
FALLBACK = {"system_prompt": "Be brief.", "model": "anthropic/claude-sonnet-4-5"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_first_call_loads_from_network():
|
|
74
|
+
transport = FakeTransport(ok(agent_payload()))
|
|
75
|
+
agent = make_client(transport).get_agent("support")
|
|
76
|
+
assert isinstance(agent, Agent)
|
|
77
|
+
assert agent.source == "network"
|
|
78
|
+
assert agent.slug == "support"
|
|
79
|
+
assert agent.version == "v3"
|
|
80
|
+
assert agent.system_prompt == "You are helpful."
|
|
81
|
+
assert agent.provider == "openai"
|
|
82
|
+
assert agent.model == "gpt-4o-mini"
|
|
83
|
+
assert agent.settings == {"temperature": 0.2}
|
|
84
|
+
assert agent.tools == [{"name": "lookup"}]
|
|
85
|
+
assert transport.calls[0]["url"] == "https://example.test/api/v1/agents/support/live"
|
|
86
|
+
assert transport.calls[0]["timeout"] == 2.0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_sends_api_key_and_sdk_headers():
|
|
90
|
+
transport = FakeTransport(ok(agent_payload()))
|
|
91
|
+
make_client(transport).get_agent("support")
|
|
92
|
+
headers = transport.calls[0]["headers"]
|
|
93
|
+
assert headers["x-api-key"] == "mk_test"
|
|
94
|
+
assert headers["x-mowa-sdk"] == f"py/{__version__}"
|
|
95
|
+
assert __version__ == "0.1.0"
|
|
96
|
+
assert "if-none-match" not in headers
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_fresh_hit_uses_cache_without_network():
|
|
100
|
+
clock = FakeClock()
|
|
101
|
+
transport = FakeTransport(ok(agent_payload()))
|
|
102
|
+
mowa = make_client(transport, clock)
|
|
103
|
+
mowa.get_agent("support")
|
|
104
|
+
clock.advance(30)
|
|
105
|
+
agent = mowa.get_agent("support")
|
|
106
|
+
assert agent.source == "cache"
|
|
107
|
+
assert agent.system_prompt == "You are helpful."
|
|
108
|
+
assert len(transport.calls) == 1
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_stale_returns_copy_and_revalidates_with_304():
|
|
112
|
+
clock = FakeClock()
|
|
113
|
+
transport = FakeTransport(ok(agent_payload()), not_modified())
|
|
114
|
+
mowa = make_client(transport, clock)
|
|
115
|
+
mowa.get_agent("support")
|
|
116
|
+
clock.advance(61)
|
|
117
|
+
stale = mowa.get_agent("support")
|
|
118
|
+
assert stale.source == "stale"
|
|
119
|
+
assert stale.system_prompt == "You are helpful."
|
|
120
|
+
assert mowa.wait_for_refresh()
|
|
121
|
+
assert len(transport.calls) == 2
|
|
122
|
+
assert transport.calls[1]["headers"]["if-none-match"] == '"etag-1"'
|
|
123
|
+
refreshed = mowa.get_agent("support")
|
|
124
|
+
assert refreshed.source == "cache"
|
|
125
|
+
assert refreshed.system_prompt == "You are helpful."
|
|
126
|
+
assert len(transport.calls) == 2
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_stale_revalidation_replaces_copy_on_200():
|
|
130
|
+
clock = FakeClock()
|
|
131
|
+
transport = FakeTransport(
|
|
132
|
+
ok(agent_payload()),
|
|
133
|
+
ok(agent_payload(version="v4", system_prompt="New prompt.", etag='"etag-2"'), etag='"etag-2"'),
|
|
134
|
+
not_modified('"etag-2"'),
|
|
135
|
+
)
|
|
136
|
+
mowa = make_client(transport, clock)
|
|
137
|
+
mowa.get_agent("support")
|
|
138
|
+
clock.advance(61)
|
|
139
|
+
assert mowa.get_agent("support").system_prompt == "You are helpful."
|
|
140
|
+
assert mowa.wait_for_refresh()
|
|
141
|
+
updated = mowa.get_agent("support")
|
|
142
|
+
assert updated.source == "cache"
|
|
143
|
+
assert updated.version == "v4"
|
|
144
|
+
assert updated.system_prompt == "New prompt."
|
|
145
|
+
clock.advance(61)
|
|
146
|
+
mowa.get_agent("support")
|
|
147
|
+
assert mowa.wait_for_refresh()
|
|
148
|
+
assert transport.calls[2]["headers"]["if-none-match"] == '"etag-2"'
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_failed_revalidation_keeps_stale_copy():
|
|
152
|
+
clock = FakeClock()
|
|
153
|
+
transport = FakeTransport(ok(agent_payload()), TimeoutError("timed out"))
|
|
154
|
+
mowa = make_client(transport, clock)
|
|
155
|
+
mowa.get_agent("support")
|
|
156
|
+
clock.advance(61)
|
|
157
|
+
mowa.get_agent("support")
|
|
158
|
+
assert mowa.wait_for_refresh()
|
|
159
|
+
again = mowa.get_agent("support")
|
|
160
|
+
assert again.source == "stale"
|
|
161
|
+
assert again.system_prompt == "You are helpful."
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_only_one_revalidation_in_flight_per_key():
|
|
165
|
+
clock = FakeClock()
|
|
166
|
+
release = threading.Event()
|
|
167
|
+
calls = []
|
|
168
|
+
|
|
169
|
+
def slow_transport(url, headers, timeout):
|
|
170
|
+
calls.append(url)
|
|
171
|
+
if len(calls) == 1:
|
|
172
|
+
return ok(agent_payload())
|
|
173
|
+
release.wait(5)
|
|
174
|
+
return not_modified()
|
|
175
|
+
|
|
176
|
+
mowa = make_client(slow_transport, clock)
|
|
177
|
+
mowa.get_agent("support")
|
|
178
|
+
clock.advance(61)
|
|
179
|
+
for _ in range(5):
|
|
180
|
+
assert mowa.get_agent("support").source == "stale"
|
|
181
|
+
release.set()
|
|
182
|
+
assert mowa.wait_for_refresh()
|
|
183
|
+
assert len(calls) == 2
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_timeout_returns_fallback():
|
|
187
|
+
transport = FakeTransport(TimeoutError("timed out"))
|
|
188
|
+
agent = make_client(transport).get_agent("support", fallback=FALLBACK)
|
|
189
|
+
assert agent.source == "fallback"
|
|
190
|
+
assert agent.version == "fallback"
|
|
191
|
+
assert agent.system_prompt == "Be brief."
|
|
192
|
+
assert agent.model == "anthropic/claude-sonnet-4-5"
|
|
193
|
+
assert agent.provider == "anthropic"
|
|
194
|
+
assert agent.settings == {}
|
|
195
|
+
assert agent.tools == [] and agent.inputs == [] and agent.outputs == []
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_fallback_provider_and_settings_are_used_when_given():
|
|
199
|
+
transport = FakeTransport(OSError("connection refused"))
|
|
200
|
+
fallback = {"system_prompt": "Hi.", "model": "gpt-4o", "provider": "openai", "settings": {"temperature": 0}}
|
|
201
|
+
agent = make_client(transport).get_agent("support", fallback=fallback)
|
|
202
|
+
assert agent.provider == "openai"
|
|
203
|
+
assert agent.settings == {"temperature": 0}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_fallback_without_slash_has_no_provider():
|
|
207
|
+
transport = FakeTransport(OSError("down"))
|
|
208
|
+
agent = make_client(transport).get_agent("support", fallback={"system_prompt": "Hi.", "model": "gpt-4o"})
|
|
209
|
+
assert agent.provider is None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_bad_json_and_server_errors_return_fallback():
|
|
213
|
+
for response in [(200, {}, b"not json"), (500, {}, b""), (200, {}, b"[1, 2]")]:
|
|
214
|
+
agent = make_client(FakeTransport(response)).get_agent("support", fallback=FALLBACK)
|
|
215
|
+
assert agent.source == "fallback"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_fallback_is_not_cached():
|
|
219
|
+
transport = FakeTransport(OSError("down"), ok(agent_payload()))
|
|
220
|
+
mowa = make_client(transport)
|
|
221
|
+
assert mowa.get_agent("support", fallback=FALLBACK).source == "fallback"
|
|
222
|
+
assert mowa.get_agent("support", fallback=FALLBACK).source == "network"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_no_fallback_raises_mowa_error_on_network_failure():
|
|
226
|
+
transport = FakeTransport(TimeoutError("timed out"))
|
|
227
|
+
with pytest.raises(MowaError) as raised:
|
|
228
|
+
make_client(transport).get_agent("support")
|
|
229
|
+
assert raised.value.message == 'Could not load agent "support": timed out'
|
|
230
|
+
assert raised.value.status is None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_no_fallback_raises_mowa_error_with_server_code():
|
|
234
|
+
body = json.dumps({"error": {"code": "not_found", "message": 'No agent "support" in production.'}}).encode()
|
|
235
|
+
transport = FakeTransport((404, {}, body))
|
|
236
|
+
with pytest.raises(MowaError) as raised:
|
|
237
|
+
make_client(transport).get_agent("support")
|
|
238
|
+
assert raised.value.status == 404
|
|
239
|
+
assert raised.value.code == "not_found"
|
|
240
|
+
assert 'No agent "support" in production.' in raised.value.message
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_missing_api_key(monkeypatch):
|
|
244
|
+
monkeypatch.delenv("MOWA_API_KEY", raising=False)
|
|
245
|
+
transport = FakeTransport(ok(agent_payload()))
|
|
246
|
+
mowa = Mowa(transport=transport)
|
|
247
|
+
with pytest.raises(MowaError) as raised:
|
|
248
|
+
mowa.get_agent("support")
|
|
249
|
+
assert raised.value.message == "Set MOWA_API_KEY or pass api_key."
|
|
250
|
+
assert mowa.get_agent("support", fallback=FALLBACK).source == "fallback"
|
|
251
|
+
assert transport.calls == []
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_reads_key_and_base_url_from_environment(monkeypatch):
|
|
255
|
+
monkeypatch.setenv("MOWA_API_KEY", "mk_env")
|
|
256
|
+
monkeypatch.setenv("MOWA_BASE_URL", "http://localhost:3001///")
|
|
257
|
+
transport = FakeTransport(ok(agent_payload()))
|
|
258
|
+
mowa = Mowa(transport=transport)
|
|
259
|
+
mowa.get_agent("support")
|
|
260
|
+
assert mowa.base_url == "http://localhost:3001"
|
|
261
|
+
assert transport.calls[0]["url"] == "http://localhost:3001/api/v1/agents/support/live"
|
|
262
|
+
assert transport.calls[0]["headers"]["x-api-key"] == "mk_env"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_pinned_version_sends_version_query():
|
|
266
|
+
transport = FakeTransport(ok(agent_payload()))
|
|
267
|
+
agent = make_client(transport).get_agent("support", version=3)
|
|
268
|
+
assert transport.calls[0]["url"] == "https://example.test/api/v1/agents/support/live?version=v3"
|
|
269
|
+
assert agent.version == "v3"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_draft_sends_version_query():
|
|
273
|
+
transport = FakeTransport(ok(agent_payload(version="draft")))
|
|
274
|
+
agent = make_client(transport).get_agent("support", version="draft")
|
|
275
|
+
assert transport.calls[0]["url"] == "https://example.test/api/v1/agents/support/live?version=draft"
|
|
276
|
+
assert agent.version == "draft"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_versions_are_cached_separately():
|
|
280
|
+
transport = FakeTransport(ok(agent_payload()))
|
|
281
|
+
mowa = make_client(transport)
|
|
282
|
+
mowa.get_agent("support")
|
|
283
|
+
mowa.get_agent("support", version="draft")
|
|
284
|
+
mowa.get_agent("support", version=3)
|
|
285
|
+
assert mowa.get_agent("support", version=3).source == "cache"
|
|
286
|
+
assert len(transport.calls) == 3
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_invalid_version_raises_value_error():
|
|
290
|
+
mowa = make_client(FakeTransport(ok(agent_payload())))
|
|
291
|
+
with pytest.raises(ValueError):
|
|
292
|
+
mowa.get_agent("support", version="latest")
|
|
293
|
+
with pytest.raises(ValueError):
|
|
294
|
+
mowa.get_agent("support", version=True)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_trace_attributes():
|
|
298
|
+
agent = make_client(FakeTransport(ok(agent_payload()))).get_agent("support")
|
|
299
|
+
assert agent.trace_attributes() == {"mowa.agent": "support", "mowa.version": "v3"}
|
|
300
|
+
fallback = make_client(FakeTransport(OSError("down"))).get_agent("support", fallback=FALLBACK)
|
|
301
|
+
assert fallback.trace_attributes() == {"mowa.agent": "support", "mowa.version": "fallback"}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_agent_is_frozen():
|
|
305
|
+
agent = make_client(FakeTransport(ok(agent_payload()))).get_agent("support")
|
|
306
|
+
with pytest.raises(Exception):
|
|
307
|
+
agent.model = "other"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_prefetch_warms_cache_and_ignores_failures():
|
|
311
|
+
def transport(url, headers, timeout):
|
|
312
|
+
if "/broken/" in url:
|
|
313
|
+
raise OSError("down")
|
|
314
|
+
return ok(agent_payload())
|
|
315
|
+
|
|
316
|
+
mowa = make_client(transport)
|
|
317
|
+
mowa.prefetch(["support", "billing", "broken"])
|
|
318
|
+
assert mowa.get_agent("support").source == "cache"
|
|
319
|
+
assert mowa.get_agent("billing").source == "cache"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def test_clear_cache():
|
|
323
|
+
transport = FakeTransport(ok(agent_payload()))
|
|
324
|
+
mowa = make_client(transport)
|
|
325
|
+
mowa.get_agent("support")
|
|
326
|
+
mowa.clear_cache()
|
|
327
|
+
assert mowa.get_agent("support").source == "network"
|
|
328
|
+
assert len(transport.calls) == 2
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@pytest.fixture
|
|
332
|
+
def local_server():
|
|
333
|
+
seen = []
|
|
334
|
+
|
|
335
|
+
class Handler(BaseHTTPRequestHandler):
|
|
336
|
+
def do_GET(self):
|
|
337
|
+
seen.append({"path": self.path, "headers": dict(self.headers.items())})
|
|
338
|
+
if self.headers.get("If-None-Match") == '"etag-1"':
|
|
339
|
+
self.send_response(304)
|
|
340
|
+
self.send_header("ETag", '"etag-1"')
|
|
341
|
+
self.end_headers()
|
|
342
|
+
return
|
|
343
|
+
if self.path.startswith("/api/v1/agents/missing/"):
|
|
344
|
+
body = json.dumps({"error": {"code": "not_found", "message": "No agent."}}).encode()
|
|
345
|
+
self.send_response(404)
|
|
346
|
+
else:
|
|
347
|
+
body = json.dumps(agent_payload()).encode()
|
|
348
|
+
self.send_response(200)
|
|
349
|
+
self.send_header("ETag", '"etag-1"')
|
|
350
|
+
self.send_header("Content-Type", "application/json")
|
|
351
|
+
self.send_header("Content-Length", str(len(body)))
|
|
352
|
+
self.end_headers()
|
|
353
|
+
self.wfile.write(body)
|
|
354
|
+
|
|
355
|
+
def log_message(self, *args):
|
|
356
|
+
pass
|
|
357
|
+
|
|
358
|
+
with socket.socket() as probe:
|
|
359
|
+
probe.bind(("127.0.0.1", 0))
|
|
360
|
+
port = probe.getsockname()[1]
|
|
361
|
+
server = HTTPServer(("127.0.0.1", port), Handler)
|
|
362
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
363
|
+
thread.start()
|
|
364
|
+
yield f"http://127.0.0.1:{port}", seen
|
|
365
|
+
server.shutdown()
|
|
366
|
+
server.server_close()
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_urllib_transport_against_local_server(local_server):
|
|
370
|
+
base_url, seen = local_server
|
|
371
|
+
clock = FakeClock()
|
|
372
|
+
mowa = Mowa(api_key="mk_test", base_url=base_url, transport=urllib_transport, clock=clock)
|
|
373
|
+
assert mowa.get_agent("support").source == "network"
|
|
374
|
+
clock.advance(61)
|
|
375
|
+
assert mowa.get_agent("support").source == "stale"
|
|
376
|
+
assert mowa.wait_for_refresh()
|
|
377
|
+
assert mowa.get_agent("support").source == "cache"
|
|
378
|
+
assert seen[1]["headers"].get("If-None-Match") == '"etag-1"'
|
|
379
|
+
assert seen[0]["headers"].get("X-Mowa-Sdk") == "py/0.1.0"
|
|
380
|
+
with pytest.raises(MowaError) as raised:
|
|
381
|
+
mowa.get_agent("missing")
|
|
382
|
+
assert raised.value.status == 404
|
|
383
|
+
assert raised.value.code == "not_found"
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def test_default_transport_against_local_server(local_server):
|
|
387
|
+
base_url, seen = local_server
|
|
388
|
+
clock = FakeClock()
|
|
389
|
+
mowa = Mowa(api_key="mk_test", base_url=base_url, clock=clock)
|
|
390
|
+
assert mowa.get_agent("support").source == "network"
|
|
391
|
+
clock.advance(61)
|
|
392
|
+
mowa.get_agent("support")
|
|
393
|
+
assert mowa.wait_for_refresh()
|
|
394
|
+
assert mowa.get_agent("support").source == "cache"
|
|
395
|
+
assert len(seen) == 2
|