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 ADDED
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ .pytest_cache/
3
+ dist/
4
+ *.egg-info/
5
+ .venv/
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,3 @@
1
+ from .client import Agent, Mowa, MowaError, Transport, __version__
2
+
3
+ __all__ = ["Agent", "Mowa", "MowaError", "Transport", "__version__"]
@@ -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