mowa 0.1.0__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.
mowa/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .client import Agent, Mowa, MowaError, Transport, __version__
2
+
3
+ __all__ = ["Agent", "Mowa", "MowaError", "Transport", "__version__"]
mowa/client.py ADDED
@@ -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
mowa/py.typed ADDED
File without changes
@@ -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.
@@ -0,0 +1,6 @@
1
+ mowa/__init__.py,sha256=zMh_QIRsScpg5manL4LEOGAufPNxPHgDfhQ92pnWNlc,137
2
+ mowa/client.py,sha256=kxMXat_HsiwcZ3V4jPGqzU7iIMEYnWjsZNyNLNONVrM,12007
3
+ mowa/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ mowa-0.1.0.dist-info/METADATA,sha256=qiii4VXDyJfgl_AGu89hvtt2-XP9P6TkpsnOXJpWbZs,2413
5
+ mowa-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
6
+ mowa-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any