munim 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.
munim/__init__.py ADDED
File without changes
@@ -0,0 +1,3 @@
1
+ from munim.adapters.cloudflare import Cloudflare # noqa: F401
2
+ from munim.adapters.resend import Resend # noqa: F401
3
+ from munim.adapters.vercel import Vercel # noqa: F401
@@ -0,0 +1,173 @@
1
+ """Cloudflare DNS: read, and write.
2
+
3
+ Every mutation here is **read-before-write, upserted on (type, name)**. That is
4
+ not tidiness. Re-running a launch that failed halfway would otherwise add a
5
+ second SPF record beside the first, and two SPF records means receivers ignore
6
+ both - the exact fault this product exists to catch. A tool that causes the bug
7
+ it reports is worse than no tool.
8
+
9
+ Writes are also idempotent for a second reason: a launch polls DNS and can
10
+ outlive one tool call, so it must be safe to resume.
11
+ """
12
+
13
+ from dataclasses import dataclass
14
+
15
+ from munim.container import Container
16
+ from munim.runlog import RunLog
17
+
18
+
19
+ class CloudflareError(RuntimeError):
20
+ pass
21
+
22
+
23
+ @dataclass
24
+ class Record:
25
+ id: str
26
+ type: str
27
+ name: str
28
+ content: str
29
+ ttl: int = 1
30
+ proxied: bool = False
31
+
32
+ @classmethod
33
+ def from_api(cls, payload: dict) -> "Record":
34
+ return cls(id=payload["id"], type=payload["type"], name=payload["name"],
35
+ content=payload["content"], ttl=payload.get("ttl", 1),
36
+ proxied=payload.get("proxied", False))
37
+
38
+
39
+ def _ok(payload: dict) -> dict:
40
+ if not payload.get("success", False):
41
+ errors = "; ".join(e.get("message", str(e)) for e in payload.get("errors", []))
42
+ raise CloudflareError(errors or "Cloudflare rejected the request")
43
+ return payload
44
+
45
+
46
+ class Cloudflare:
47
+ """One client's Cloudflare account, reached through their container.
48
+
49
+ The container vends an authenticated HTTP client, so no token ever becomes
50
+ a value in this file (D6).
51
+ """
52
+
53
+ name = "cloudflare"
54
+
55
+ def __init__(self, container: Container, log: RunLog | None = None) -> None:
56
+ self._container = container
57
+ self._log = log
58
+
59
+ def _note(self, kind: str, text: str, **detail) -> None:
60
+ if self._log:
61
+ self._log.append(client=self._container.client, stage="dns",
62
+ kind=kind, human_text=text, detail=detail)
63
+
64
+ async def zone_id(self, domain: str) -> str:
65
+ async with self._container.http("cloudflare") as http:
66
+ payload = _ok((await http.get("/zones", params={"name": domain})).json())
67
+ zones = payload.get("result") or []
68
+ if not zones:
69
+ raise CloudflareError(
70
+ f"{domain} is not a zone in this client's Cloudflare account. "
71
+ "Either the nameservers are not delegated here, or the wrong "
72
+ "client was named."
73
+ )
74
+ return zones[0]["id"]
75
+
76
+ async def records(self, zone: str, *, type: str = "", name: str = "") -> list[Record]:
77
+ params = {"per_page": 100}
78
+ if type:
79
+ params["type"] = type
80
+ if name:
81
+ params["name"] = name
82
+ async with self._container.http("cloudflare") as http:
83
+ payload = _ok((await http.get(f"/zones/{zone}/dns_records", params=params)).json())
84
+ return [Record.from_api(r) for r in payload.get("result", [])]
85
+
86
+ async def upsert(self, zone: str, *, type: str, name: str, content: str,
87
+ ttl: int = 1, proxied: bool = False) -> tuple[Record, str]:
88
+ """Create or update. Returns the record and what happened.
89
+
90
+ Read-before-write on (type, name): identical content is left alone, a
91
+ differing single record is updated in place, and only a genuinely new
92
+ (type, name) is created. Nothing is ever blindly appended.
93
+ """
94
+ existing = [r for r in await self.records(zone, type=type, name=name)]
95
+
96
+ for record in existing:
97
+ if record.content == content:
98
+ self._note("observation", f"{type} record for {name} is already correct",
99
+ check="dns_write", action="unchanged", record=name)
100
+ return record, "unchanged"
101
+
102
+ body = {"type": type, "name": name, "content": content,
103
+ "ttl": ttl, "proxied": proxied}
104
+
105
+ if len(existing) == 1:
106
+ async with self._container.http("cloudflare") as http:
107
+ payload = _ok((await http.put(
108
+ f"/zones/{zone}/dns_records/{existing[0].id}", json=body)).json())
109
+ self._note("mutation", f"Updated the {type} record for {name}",
110
+ check="dns_write", action="updated", record=name)
111
+ return Record.from_api(payload["result"]), "updated"
112
+
113
+ if len(existing) > 1:
114
+ # Appending here is what creates the duplicate-SPF fault. Refuse and
115
+ # let the caller decide, because merging is a judgement call.
116
+ raise CloudflareError(
117
+ f"{len(existing)} {type} records already exist for {name}. "
118
+ "Adding another would leave several in place; decide how to "
119
+ "combine them before writing."
120
+ )
121
+
122
+ async with self._container.http("cloudflare") as http:
123
+ payload = _ok((await http.post(f"/zones/{zone}/dns_records", json=body)).json())
124
+ self._note("mutation", f"Created the {type} record for {name}",
125
+ check="dns_write", action="created", record=name)
126
+ return Record.from_api(payload["result"]), "created"
127
+
128
+ async def merge_spf(self, zone: str, domain: str, merged: str) -> tuple[Record, str]:
129
+ """Replace every SPF record with one. The only safe way to end up with
130
+ a single policy when a domain already carries more than one.
131
+
132
+ Order matters, though not where it first appears to. A failed *delete*
133
+ is equally bad either way: two policies remain and receivers ignore
134
+ both. The orders differ when the *write* fails. Write first and nothing
135
+ has been removed yet, so the domain keeps every policy it had and mail
136
+ stays broken. Delete first and the leftovers are already gone, so what
137
+ remains is one intact policy: not the merge that was wanted, but a
138
+ working one. That is the most a partial write can be.
139
+
140
+ The read-back at the end is separate, and covers the case neither order
141
+ helps with: an API that answers 200 without changing anything. Two
142
+ successful responses are not evidence that one policy is left, and one
143
+ policy is the only thing this function promises.
144
+ """
145
+ spf = [r for r in await self.records(zone, type="TXT", name=domain)
146
+ if r.content.lower().startswith("v=spf1")]
147
+ if not spf:
148
+ return await self.upsert(zone, type="TXT", name=domain, content=merged)
149
+
150
+ survivor, leftovers = spf[0], spf[1:]
151
+ async with self._container.http("cloudflare") as http:
152
+ for extra in leftovers:
153
+ _ok((await http.delete(f"/zones/{zone}/dns_records/{extra.id}")).json())
154
+ payload = _ok((await http.put(
155
+ f"/zones/{zone}/dns_records/{survivor.id}",
156
+ json={"type": "TXT", "name": domain, "content": merged, "ttl": 1})).json())
157
+
158
+ # Read back rather than trust the writes. A merge that silently left two
159
+ # policies is indistinguishable, from the caller's side, from one that
160
+ # worked, and the whole point of this function is that there is one.
161
+ remaining = [r for r in await self.records(zone, type="TXT", name=domain)
162
+ if r.content.lower().startswith("v=spf1")]
163
+ if len(remaining) != 1:
164
+ raise CloudflareError(
165
+ f"{domain} has {len(remaining)} sender policies after the merge, "
166
+ f"not one. Nothing further was written. The records now are: "
167
+ + "; ".join(r.content for r in remaining)
168
+ )
169
+
170
+ self._note("mutation",
171
+ f"Merged {len(spf)} sender policies into one",
172
+ check="spf_single", action="merged", removed=len(leftovers))
173
+ return Record.from_api(payload["result"]), "merged"
@@ -0,0 +1,165 @@
1
+ """Resend: create a sending domain and hand back the records it needs.
2
+
3
+ This is one end of the handoff the whole project is about. Resend emits DKIM,
4
+ SPF and return-path records; they have to be written into Cloudflare, which is a
5
+ different company and a different login. Get the A record wrong and the site does
6
+ not load. Get these wrong and nothing breaks - the client's mail simply stops
7
+ arriving, and nobody notices for weeks.
8
+
9
+ Resend publishes no OAuth authorization endpoint, so it authenticates with an API
10
+ key. That is Resend offering nothing else, not a preference (see connect/oauth.py,
11
+ where it is deliberately absent from the provider table).
12
+ """
13
+
14
+ from dataclasses import dataclass
15
+
16
+ from munim.container import Container
17
+ from munim.runlog import RunLog
18
+
19
+
20
+ class ResendError(RuntimeError):
21
+ pass
22
+
23
+
24
+ @dataclass
25
+ class DnsRecord:
26
+ """A record Resend needs published, in the shape Cloudflare wants it.
27
+
28
+ Translating between the two vocabularies here is the point: Resend says
29
+ `record: "SPF", type: "TXT", name: "send"`, Cloudflare wants a type, a fully
30
+ qualified name and a content string. Doing it by hand is the copy-paste that
31
+ goes wrong.
32
+ """
33
+ purpose: str # SPF, DKIM, or the tracking/return-path record
34
+ type: str # TXT, CNAME, MX
35
+ name: str # as Resend gives it, often relative
36
+ value: str
37
+ priority: int | None = None
38
+ status: str = "not_started"
39
+
40
+ def fqdn(self, domain: str) -> str:
41
+ """Resend returns names relative to the domain; Cloudflare wants them
42
+ absolute. Getting this wrong publishes a record nobody can find."""
43
+ if not self.name or self.name in ("@", domain):
44
+ return domain
45
+ if self.name.endswith(domain):
46
+ return self.name
47
+ return f"{self.name}.{domain}"
48
+
49
+ @classmethod
50
+ def from_api(cls, payload: dict) -> "DnsRecord":
51
+ return cls(
52
+ purpose=payload.get("record", "").upper(),
53
+ type=payload.get("type", "TXT").upper(),
54
+ name=payload.get("name", ""),
55
+ value=payload.get("value", ""),
56
+ priority=payload.get("priority"),
57
+ status=payload.get("status", "not_started"),
58
+ )
59
+
60
+
61
+ @dataclass
62
+ class Domain:
63
+ id: str
64
+ name: str
65
+ status: str
66
+ records: list[DnsRecord]
67
+
68
+ @property
69
+ def verified(self) -> bool:
70
+ return self.status == "verified"
71
+
72
+
73
+ class Resend:
74
+ name = "resend"
75
+
76
+ def __init__(self, container: Container, log: RunLog | None = None) -> None:
77
+ self._container = container
78
+ self._log = log
79
+
80
+ def _note(self, kind: str, text: str, **detail) -> None:
81
+ if self._log:
82
+ self._log.append(client=self._container.client, stage="mail",
83
+ kind=kind, human_text=text, detail=detail)
84
+
85
+ @staticmethod
86
+ def _ok(response) -> dict:
87
+ if response.status_code >= 400:
88
+ body = {}
89
+ try:
90
+ body = response.json()
91
+ except Exception:
92
+ pass
93
+ raise ResendError(body.get("message") or response.text[:160]
94
+ or f"Resend returned {response.status_code}")
95
+ return response.json()
96
+
97
+ async def domains(self) -> list[Domain]:
98
+ async with self._container.http("resend") as http:
99
+ payload = self._ok(await http.get("/domains"))
100
+ return [Domain(id=d["id"], name=d["name"], status=d.get("status", ""),
101
+ records=[DnsRecord.from_api(r) for r in d.get("records", [])])
102
+ for d in payload.get("data", [])]
103
+
104
+ async def find(self, domain: str) -> Domain | None:
105
+ for existing in await self.domains():
106
+ if existing.name.lower() == domain.lower():
107
+ return existing
108
+ return None
109
+
110
+ async def ensure_domain(self, domain: str, region: str = "us-east-1") -> tuple[Domain, str]:
111
+ """Create the sending domain, or return the one already there.
112
+
113
+ Idempotent for the same reason every mutation here is: a launch that
114
+ failed halfway and is re-run must not create a second sending domain and
115
+ a second set of DKIM keys, which would leave the client with records that
116
+ do not match the keys their mail is signed with.
117
+ """
118
+ existing = await self.find(domain)
119
+ if existing is not None:
120
+ self._note("observation", f"{domain} is already set up for sending",
121
+ check="resend_domain", action="unchanged")
122
+ return existing, "unchanged"
123
+
124
+ async with self._container.http("resend") as http:
125
+ payload = self._ok(await http.post(
126
+ "/domains", json={"name": domain, "region": region}))
127
+
128
+ created = Domain(
129
+ id=payload["id"], name=payload["name"],
130
+ status=payload.get("status", "not_started"),
131
+ records=[DnsRecord.from_api(r) for r in payload.get("records", [])],
132
+ )
133
+ self._note("mutation",
134
+ f"Created the sending domain and got {len(created.records)} "
135
+ "records to publish",
136
+ check="resend_domain", action="created",
137
+ records=[r.purpose for r in created.records])
138
+ return created, "created"
139
+
140
+ async def verify(self, domain_id: str) -> str:
141
+ async with self._container.http("resend") as http:
142
+ payload = self._ok(await http.post(f"/domains/{domain_id}/verify"))
143
+ return payload.get("status", "unknown")
144
+
145
+ @staticmethod
146
+ def cloudflare_records(domain: Domain) -> list[dict]:
147
+ """Translate Resend's records into what Cloudflare's API wants.
148
+
149
+ This function is the handoff. Everything it does by rule is what an
150
+ operator otherwise does by eye, between two browser tabs, once per
151
+ client - and it is where the mistake that breaks nothing visible gets
152
+ made.
153
+ """
154
+ out = []
155
+ for record in domain.records:
156
+ entry = {
157
+ "type": record.type,
158
+ "name": record.fqdn(domain.name),
159
+ "content": record.value,
160
+ "purpose": record.purpose,
161
+ }
162
+ if record.priority is not None:
163
+ entry["priority"] = record.priority
164
+ out.append(entry)
165
+ return out
@@ -0,0 +1,169 @@
1
+ """Vercel: what a project is doing, and what it is configured with.
2
+
3
+ Read-first on purpose. The two failures worth catching here are quiet ones:
4
+
5
+ - An environment variable set but never applied, because Vercel bakes
6
+ build-time values in and setting one changes nothing until a redeploy. The
7
+ dashboard shows the new value; the running site uses the old one.
8
+ - A variable set on Preview instead of Production. It works when you test it
9
+ and is missing when a customer arrives.
10
+
11
+ Both look correct in the dashboard, which is why nobody finds them by looking.
12
+ """
13
+
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timezone
16
+
17
+ from munim.checks.dns import CheckResult
18
+ from munim.container import Container
19
+
20
+
21
+ class VercelError(RuntimeError):
22
+ pass
23
+
24
+
25
+ @dataclass
26
+ class Deployment:
27
+ uid: str
28
+ state: str
29
+ url: str
30
+ created_at: datetime
31
+ target: str | None = None
32
+
33
+ @classmethod
34
+ def from_api(cls, payload: dict) -> "Deployment":
35
+ return cls(
36
+ uid=payload.get("uid") or payload.get("id", ""),
37
+ state=payload.get("readyState") or payload.get("state", "UNKNOWN"),
38
+ url=payload.get("url", ""),
39
+ created_at=datetime.fromtimestamp(
40
+ payload.get("createdAt", 0) / 1000, tz=timezone.utc),
41
+ target=payload.get("target"),
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class EnvVar:
47
+ key: str
48
+ targets: list[str]
49
+ created_at: datetime
50
+ # The value is deliberately absent. Reading configuration must not mean
51
+ # pulling every client's secrets into a coding agent's context (D6).
52
+
53
+
54
+ class Vercel:
55
+ """One client's Vercel account, reached through their container."""
56
+
57
+ name = "vercel"
58
+
59
+ def __init__(self, container: Container, team_id: str = "") -> None:
60
+ self._container = container
61
+ self._team = team_id
62
+
63
+ def _params(self, **extra) -> dict:
64
+ params = {k: v for k, v in extra.items() if v not in (None, "")}
65
+ if self._team:
66
+ params["teamId"] = self._team
67
+ return params
68
+
69
+ async def _get(self, path: str, **params):
70
+ async with self._container.http("vercel") as http:
71
+ response = await http.get(path, params=self._params(**params))
72
+ if response.status_code >= 400:
73
+ raise VercelError(
74
+ f"Vercel returned {response.status_code} for {path}: "
75
+ f"{response.json().get('error', {}).get('message', response.text[:120])}"
76
+ )
77
+ return response.json()
78
+
79
+ async def projects(self) -> list[dict]:
80
+ payload = await self._get("/v9/projects", limit=100)
81
+ return [{"id": p["id"], "name": p["name"],
82
+ "framework": p.get("framework"),
83
+ "updated": p.get("updatedAt")}
84
+ for p in payload.get("projects", [])]
85
+
86
+ async def deployments(self, project: str, limit: int = 10) -> list[Deployment]:
87
+ payload = await self._get("/v6/deployments", projectId=project, limit=limit)
88
+ return [Deployment.from_api(d) for d in payload.get("deployments", [])]
89
+
90
+ async def env_vars(self, project: str) -> list[EnvVar]:
91
+ """Names and scopes only; never values."""
92
+ payload = await self._get(f"/v9/projects/{project}/env")
93
+ return [
94
+ EnvVar(key=e["key"], targets=e.get("target", []),
95
+ created_at=datetime.fromtimestamp(
96
+ e.get("createdAt", 0) / 1000, tz=timezone.utc))
97
+ for e in payload.get("envs", [])
98
+ ]
99
+
100
+ async def check_deploy_current(self, project: str) -> CheckResult:
101
+ """Is the site people see the site you last built?"""
102
+ deployments = await self.deployments(project, limit=10)
103
+ production = [d for d in deployments if d.target == "production"]
104
+ if not production:
105
+ return CheckResult("deploy_current", "skip",
106
+ "No production deployment yet.", "")
107
+ latest = production[0]
108
+ if latest.state == "READY":
109
+ return CheckResult("deploy_current", "pass",
110
+ f"Production is {latest.state}, deployed "
111
+ f"{latest.created_at:%d %b %Y}.",
112
+ "The site your customers see is the latest version.",
113
+ evidence=latest.url)
114
+ failed = [d for d in production if d.state in ("ERROR", "CANCELED")]
115
+ return CheckResult(
116
+ "deploy_current", "fail",
117
+ f"Latest production deployment is {latest.state}; "
118
+ f"{len(failed)} of the last {len(production)} failed.",
119
+ "Your site is still showing an older version, because recent updates "
120
+ "did not go live.",
121
+ evidence=f"{latest.uid} {latest.state} {latest.created_at:%d %b %Y}",
122
+ detail={"state": latest.state, "failed": len(failed)})
123
+
124
+ async def check_env_applied(self, project: str) -> CheckResult:
125
+ """A variable set after the last deploy has not taken effect.
126
+
127
+ Vercel bakes build-time values in, so changing one in the dashboard
128
+ changes nothing on the running site until a rebuild.
129
+ """
130
+ env = await self.env_vars(project)
131
+ production = [e for e in env if "production" in e.targets]
132
+ if not production:
133
+ return CheckResult("env_applied", "skip", "No production variables.", "")
134
+ deployments = [d for d in await self.deployments(project, limit=10)
135
+ if d.target == "production" and d.state == "READY"]
136
+ if not deployments:
137
+ return CheckResult("env_applied", "skip", "No successful production build.", "")
138
+
139
+ last_build = deployments[0].created_at
140
+ stale = [e for e in production if e.created_at > last_build]
141
+ if not stale:
142
+ return CheckResult("env_applied", "pass",
143
+ "Every production variable predates the last build.",
144
+ "Your settings are live on the site.")
145
+ return CheckResult(
146
+ "env_applied", "fail",
147
+ f"{len(stale)} production variable(s) changed after the last build "
148
+ f"({', '.join(e.key for e in stale)}); Vercel bakes build-time values "
149
+ "in, so the running site still uses the old ones.",
150
+ "A setting was changed but never applied - your live site is still "
151
+ "using the previous value.",
152
+ detail={"stale": [e.key for e in stale]})
153
+
154
+ async def check_env_scoped(self, project: str) -> CheckResult:
155
+ """A variable only on Preview works when you test and is missing live."""
156
+ env = await self.env_vars(project)
157
+ preview_only = [e for e in env
158
+ if "preview" in e.targets and "production" not in e.targets]
159
+ if not preview_only:
160
+ return CheckResult("env_scoped", "pass",
161
+ "No variables are preview-only.",
162
+ "Your settings apply to the live site, not just to tests.")
163
+ return CheckResult(
164
+ "env_scoped", "fail",
165
+ f"{len(preview_only)} variable(s) exist only on Preview: "
166
+ f"{', '.join(e.key for e in preview_only)}.",
167
+ "Something is configured for your test site but not the real one, so "
168
+ "it works when we check it and not when a customer arrives.",
169
+ detail={"preview_only": [e.key for e in preview_only]})
File without changes
munim/agent/across.py ADDED
@@ -0,0 +1,81 @@
1
+ """One question, answered across every client at once.
2
+
3
+ This is the capability D15 says nothing else does, and until now it was a
4
+ deterministic DNS sweep: useful, but it could only answer questions the check
5
+ catalogue already asked. With a session per client against the providers' own
6
+ MCP servers, the same shape answers questions nobody wrote a check for, because
7
+ the provider's own tools are there.
8
+
9
+ The safety property is structural, not instructed. Every toolset here is built
10
+ read-only, so a tool that changes anything is not present to be called. "Read
11
+ across, write within" (D5) stops depending on the model doing as it is told.
12
+ """
13
+
14
+ from strands import Agent
15
+
16
+ from munim.agent.model import build_model
17
+ from munim.remote.servers import SERVERS
18
+ from munim.remote.storage import KeychainTokenStorage
19
+ from munim.remote.toolsets import toolsets_for
20
+
21
+ SYSTEM = """You answer one question about several clients at once.
22
+
23
+ You have each client's own tools, named with that client's prefix. A tool named
24
+ acme_ltd_* acts on Acme Ltd's account and no other. Never assume two clients
25
+ share anything.
26
+
27
+ Every tool you have is read-only. If answering would require changing something,
28
+ say what would have to change and which client it belongs to. Do not claim you
29
+ changed it.
30
+
31
+ Name the client beside every fact. A finding without a client attached is
32
+ useless to someone who looks after a dozen of them.
33
+
34
+ Be brief. No preamble."""
35
+
36
+
37
+ def connected_clients(clients, provider: str, backend=None) -> list:
38
+ """Those with a session for this provider. Asking about the rest would open
39
+ a browser, which is not a thing a question gets to do.
40
+
41
+ Takes client records, not names: the session is filed under the identity,
42
+ and looking it up by label found nothing at all once the two were split.
43
+ """
44
+ def has_session(client) -> bool:
45
+ key = getattr(client, "id", client)
46
+ store = (KeychainTokenStorage(key, provider, backend) if backend
47
+ else KeychainTokenStorage(key, provider))
48
+ return store._read("tokens") is not None
49
+
50
+ return [c for c in clients if has_session(c)]
51
+
52
+
53
+ async def ask(question: str, clients: list[str], *, backend=None) -> str:
54
+ """Answer `question` using every connected client's read-only tools."""
55
+ toolsets = []
56
+ reached: dict[str, list[str]] = {}
57
+ for provider in sorted(SERVERS):
58
+ present = connected_clients(clients, provider, backend)
59
+ if not present:
60
+ continue
61
+ reached[provider] = present
62
+ toolsets += toolsets_for(present, provider, backend=backend,
63
+ read_only=True)
64
+
65
+ if not toolsets:
66
+ return ("No client has a session with a provider yet, so there is "
67
+ "nothing to read across. Connect one with "
68
+ "`munim connect \"<client>\" cloudflare`.")
69
+
70
+ model, _ = build_model()
71
+ agent = Agent(model=model, tools=toolsets, system_prompt=SYSTEM,
72
+ callback_handler=None)
73
+
74
+ roster = "\n".join(
75
+ f"- {p}: {', '.join(getattr(c, 'name', str(c)) for c in cs)}"
76
+ for p, cs in sorted(reached.items()))
77
+ reply = await agent.invoke_async(
78
+ f"Clients and the providers each is connected to:\n{roster}\n\n"
79
+ f"Question: {question}"
80
+ )
81
+ return str(reply)