Skip to content

Commit 5c04b64

Browse files
cosmicBboyclaude
andcommitted
feat(jira): give JiraClient both a sync and an async call form
Wraps the 12 client methods with flyte's @Syncify, matching how the SDK itself exposes Run.listall, flyte.run and flyte.serve. Each method now has two forms: `client.foo(...)` blocks, `await client.foo.aio(...)` does not. - Adds __enter__/__exit__ so the blocking form is actually usable. They run __aenter__/__aexit__ on syncify's background loop -- the same loop the syncified methods run on -- so the httpx.AsyncClient is created and used on a single loop. - Internal self-calls use .aio(). The blocking form would deadlock when called from syncify's own loop thread. - The MCP tool bridge uses .aio(). `await getattr(client, name)(...)` would otherwise raise TypeError on the returned value, and would stall the MCP server's event loop for the duration of every tool call. - Tests, examples and READMEs use .aio() on async paths, and document both forms plus when not to reach for the blocking one. The syncified client type-checks clean: mypy resolves methods to SyncFunction[...] and the error count on the package is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKZrTNjjWVzTZUxDFbn4Nk
1 parent 8f3b0d2 commit 5c04b64

7 files changed

Lines changed: 113 additions & 30 deletions

File tree

plugins/jira/README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ from flyteplugins.jira import JiraClient
4747
@env.task
4848
async def open_ticket(project_key: str, summary: str) -> str:
4949
async with JiraClient() as client:
50-
issue = await client.create_issue(project_key, summary)
50+
issue = await client.create_issue.aio(project_key, summary)
5151
return issue["url"]
5252
```
5353

@@ -57,6 +57,24 @@ comments are converted to Jira's Atlassian Document Format automatically, and
5757
issue descriptions are converted back to plain text on read. Errors are raised
5858
as `JiraAPIError`; 429 rate limits are retried.
5959

60+
### Both call forms
61+
62+
Every client method is available two ways. `await client.get_issue.aio(...)` is the
63+
async form — use it in `async def` tasks and anywhere on an app's event loop.
64+
`client.get_issue(...)` is the blocking form, for plain `def` tasks and scripts:
65+
66+
```python
67+
@env.task
68+
def summarize(...) -> str:
69+
with JiraClient() as client: # note: `with`, not `async with`
70+
issue = client.get_issue(issue_key)
71+
...
72+
```
73+
74+
The blocking form parks the calling thread until the call returns, so never
75+
reach for it inside an `async def` task or a webhook handler — it would stall
76+
the event loop and everything else waiting on it.
77+
6078
## React to Jira events
6179

6280
`JiraAppEnvironment` serves a **setup dashboard** (`/`) and a **webhook

plugins/jira/examples/manage_ticket.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,24 +36,24 @@
3636
async def open_ticket(project_key: str, summary: str, description: str) -> str:
3737
"""Create a ticket and return its URL."""
3838
async with JiraClient() as client:
39-
issue = await client.create_issue(project_key, summary, description=description)
39+
issue = await client.create_issue.aio(project_key, summary, description=description)
4040
return issue["url"]
4141

4242

4343
@env.task
4444
async def start_work(issue_key: str) -> str:
4545
"""Transition a ticket to In Progress and comment on it."""
4646
async with JiraClient() as client:
47-
await client.transition_issue(issue_key, "In Progress")
48-
await client.add_comment(issue_key, "Flyte picked this ticket up.")
47+
await client.transition_issue.aio(issue_key, "In Progress")
48+
await client.add_comment.aio(issue_key, "Flyte picked this ticket up.")
4949
return issue_key
5050

5151

5252
@env.task
5353
async def summarize_open_bugs(project_key: str) -> str:
5454
"""Search open bugs in a project and summarize them."""
5555
async with JiraClient() as client:
56-
issues = await client.search_issues(
56+
issues = await client.search_issues.aio(
5757
f"project = {project_key} AND issuetype = Bug AND statusCategory != Done ORDER BY priority DESC"
5858
)
5959
if not issues:

plugins/jira/examples/react_to_jira_events.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ async def note_done_transitions(event):
6868
if event.status != "Done":
6969
return None
7070
async with JiraClient() as client:
71-
await client.add_comment(event.issue_key, "Flyte noticed this issue is now Done.")
71+
await client.add_comment.aio(event.issue_key, "Flyte noticed this issue is now Done.")
7272
return {"noted": event.issue_key}
7373

7474

plugins/jira/src/flyteplugins/jira/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,15 @@
3636
@env.task
3737
async def open_ticket(project_key: str, summary: str, description: str) -> str:
3838
async with JiraClient() as client:
39-
issue = await client.create_issue(project_key, summary, description=description)
39+
issue = await client.create_issue.aio(project_key, summary, description=description)
4040
return issue["url"]
4141
```
4242
43+
Every client method has two call forms: `await client.get_issue.aio(...)` for
44+
async tasks and app handlers, and `client.get_issue(...)` (under a plain `with`)
45+
for sync tasks and scripts. The blocking form stalls the calling thread, so never
46+
use it on an event loop.
47+
4348
## React to Jira events
4449
4550
```python

plugins/jira/src/flyteplugins/jira/_client.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from typing import Any
1515

1616
import httpx
17+
from flyte.syncify import syncify
1718

1819
from ._config import Config, default_config
1920
from ._errors import JiraAPIError, MissingCredentialsError
@@ -162,6 +163,27 @@ async def __aexit__(self, *exc_info: object) -> None:
162163
await self._client.aclose()
163164
self._client = None
164165

166+
def __enter__(self) -> JiraClient:
167+
"""Enter synchronously, for use with the blocking call form.
168+
169+
`__aenter__` runs on syncify's background loop — the same loop the
170+
syncified methods run on — so the underlying `httpx.AsyncClient` is
171+
created and used on a single loop.
172+
"""
173+
return self._enter_sync()
174+
175+
def __exit__(self, *exc_info: object) -> None:
176+
self._exit_sync()
177+
178+
@syncify
179+
async def _enter_sync(self) -> JiraClient:
180+
return await self.__aenter__()
181+
182+
@syncify
183+
async def _exit_sync(self) -> None:
184+
await self.__aexit__()
185+
186+
@syncify
165187
async def request(
166188
self,
167189
method: str,
@@ -215,33 +237,38 @@ async def request(
215237
# reads
216238
# ------------------------------------------------------------------
217239

240+
@syncify
218241
async def get_myself(self) -> dict[str, Any]:
219242
"""Return the authenticated user (`GET /myself`)."""
220-
data = await self.request("GET", "/myself")
243+
data = await self.request.aio("GET", "/myself")
221244
return {
222245
"account_id": data.get("accountId"),
223246
"display_name": data.get("displayName"),
224247
"email": data.get("emailAddress"),
225248
}
226249

250+
@syncify
227251
async def list_projects(self) -> list[dict[str, Any]]:
228252
"""List projects visible to the authenticated user."""
229-
data = await self.request("GET", "/project/search", params={"maxResults": 50})
253+
data = await self.request.aio("GET", "/project/search", params={"maxResults": 50})
230254
return [{"key": p.get("key"), "name": p.get("name"), "id": p.get("id")} for p in data.get("values", [])]
231255

256+
@syncify
232257
async def get_issue(self, issue_key: str) -> dict[str, Any]:
233258
"""Return a single issue by key (e.g. `PROJ-123`)."""
234-
data = await self.request("GET", f"/issue/{issue_key}")
259+
data = await self.request.aio("GET", f"/issue/{issue_key}")
235260
return _simplify_issue(data, self.base_url)
236261

262+
@syncify
237263
async def search_issues(self, jql: str, max_results: int = 50) -> list[dict[str, Any]]:
238264
"""Search issues with JQL."""
239-
data = await self.request("GET", "/search", params={"jql": jql, "maxResults": max_results})
265+
data = await self.request.aio("GET", "/search", params={"jql": jql, "maxResults": max_results})
240266
return [_simplify_issue(issue, self.base_url) for issue in data.get("issues", [])]
241267

268+
@syncify
242269
async def list_comments(self, issue_key: str) -> list[dict[str, Any]]:
243270
"""List comments on an issue."""
244-
data = await self.request("GET", f"/issue/{issue_key}/comment")
271+
data = await self.request.aio("GET", f"/issue/{issue_key}/comment")
245272
return [
246273
{
247274
"id": c.get("id"),
@@ -252,9 +279,10 @@ async def list_comments(self, issue_key: str) -> list[dict[str, Any]]:
252279
for c in data.get("comments", [])
253280
]
254281

282+
@syncify
255283
async def list_transitions(self, issue_key: str) -> list[dict[str, Any]]:
256284
"""List the transitions available for an issue."""
257-
data = await self.request("GET", f"/issue/{issue_key}/transitions")
285+
data = await self.request.aio("GET", f"/issue/{issue_key}/transitions")
258286
return [
259287
{"id": t.get("id"), "name": t.get("name"), "to_status": (t.get("to") or {}).get("name")}
260288
for t in data.get("transitions", [])
@@ -264,6 +292,7 @@ async def list_transitions(self, issue_key: str) -> list[dict[str, Any]]:
264292
# writes
265293
# ------------------------------------------------------------------
266294

295+
@syncify
267296
async def create_issue(
268297
self,
269298
project_key: str,
@@ -299,9 +328,10 @@ async def create_issue(
299328
fields["labels"] = labels
300329
if extra_fields:
301330
fields.update(extra_fields)
302-
data = await self.request("POST", "/issue", json={"fields": fields})
331+
data = await self.request.aio("POST", "/issue", json={"fields": fields})
303332
return {"key": data.get("key"), "id": data.get("id"), "url": f"{self.base_url}/browse/{data.get('key')}"}
304333

334+
@syncify
305335
async def update_issue(
306336
self,
307337
issue_key: str,
@@ -320,22 +350,24 @@ async def update_issue(
320350
fields["labels"] = labels
321351
if extra_fields:
322352
fields.update(extra_fields)
323-
await self.request("PUT", f"/issue/{issue_key}", json={"fields": fields})
353+
await self.request.aio("PUT", f"/issue/{issue_key}", json={"fields": fields})
324354
return {"key": issue_key}
325355

356+
@syncify
326357
async def add_comment(self, issue_key: str, body: str) -> dict[str, Any]:
327358
"""Add a comment to an issue."""
328-
data = await self.request("POST", f"/issue/{issue_key}/comment", json={"body": _text_to_adf(body)})
359+
data = await self.request.aio("POST", f"/issue/{issue_key}/comment", json={"body": _text_to_adf(body)})
329360
return {"id": data.get("id"), "created": data.get("created")}
330361

362+
@syncify
331363
async def transition_issue(self, issue_key: str, transition: str) -> dict[str, Any]:
332364
"""Transition an issue by transition name or id.
333365
334366
Looks up available transitions when a name is given; raises
335367
`JiraAPIError` when the name does not match.
336368
"""
337369
if not transition.isdigit():
338-
transitions = await self.list_transitions(issue_key)
370+
transitions = await self.list_transitions.aio(issue_key)
339371
match = next((t for t in transitions if t["name"].lower() == transition.lower()), None)
340372
if match is None:
341373
available = ", ".join(t["name"] for t in transitions) or "<none>"
@@ -345,12 +377,13 @@ async def transition_issue(self, issue_key: str, transition: str) -> dict[str, A
345377
transition_id = match["id"]
346378
else:
347379
transition_id = transition
348-
await self.request("POST", f"/issue/{issue_key}/transitions", json={"transition": {"id": transition_id}})
380+
await self.request.aio("POST", f"/issue/{issue_key}/transitions", json={"transition": {"id": transition_id}})
349381
return {"key": issue_key, "transition": transition_id}
350382

383+
@syncify
351384
async def delete_issue(self, issue_key: str) -> None:
352385
"""Delete an issue permanently. Destructive and irreversible."""
353-
await self.request("DELETE", f"/issue/{issue_key}")
386+
await self.request.aio("DELETE", f"/issue/{issue_key}")
354387

355388

356389
def _safe_json(response: httpx.Response) -> dict[str, Any] | None:

plugins/jira/src/flyteplugins/jira/_tools.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def _make_tool(name: str, config: Config, base_url: str | None, email: str | Non
103103

104104
async def tool(*args: Any, **kwargs: Any) -> Any:
105105
async with JiraClient(config, base_url=base_url, email=email, api_token=api_token) as client:
106-
return await getattr(client, name)(*args, **kwargs)
106+
return await getattr(client, name).aio(*args, **kwargs)
107107

108108
tool.__signature__ = sig.replace(parameters=params) # type: ignore[attr-defined]
109109
tool.__name__ = name

plugins/jira/tests/test_client.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
async def test_basic_auth_header(jira_api, creds):
1515
route = jira_api.get("/myself").respond(json={"accountId": "a", "displayName": "Bot"})
1616
async with JiraClient() as client:
17-
await client.get_myself()
17+
await client.get_myself.aio()
1818
header = route.calls[0].request.headers["Authorization"]
1919
assert header == "Basic " + base64.b64encode(b"bot@acme.com:jira-token").decode()
2020

@@ -30,7 +30,7 @@ async def test_missing_credentials(monkeypatch):
3030
async def test_get_issue_simplifies_and_extracts_description(jira_api, creds):
3131
jira_api.get("/issue/PROJ-1").respond(json=issue_json())
3232
async with JiraClient() as client:
33-
issue = await client.get_issue("PROJ-1")
33+
issue = await client.get_issue.aio("PROJ-1")
3434
assert issue["key"] == "PROJ-1"
3535
assert issue["status"] == "To Do"
3636
assert issue["description"] == "It broke."
@@ -41,7 +41,7 @@ async def test_get_issue_simplifies_and_extracts_description(jira_api, creds):
4141
async def test_search_issues(jira_api, creds):
4242
jira_api.get("/search").respond(json={"issues": [issue_json(), issue_json(key="PROJ-2")]})
4343
async with JiraClient() as client:
44-
issues = await client.search_issues("project = PROJ")
44+
issues = await client.search_issues.aio("project = PROJ")
4545
assert [i["key"] for i in issues] == ["PROJ-1", "PROJ-2"]
4646

4747

@@ -56,7 +56,7 @@ def capture(request: httpx.Request) -> httpx.Response:
5656

5757
jira_api.post("/issue").mock(side_effect=capture)
5858
async with JiraClient() as client:
59-
issue = await client.create_issue("PROJ", "New thing", description="details", priority="High")
59+
issue = await client.create_issue.aio("PROJ", "New thing", description="details", priority="High")
6060
assert issue["key"] == "PROJ-3"
6161
fields = captured["body"]["fields"]
6262
assert fields["project"] == {"key": "PROJ"}
@@ -79,7 +79,7 @@ def capture(request: httpx.Request) -> httpx.Response:
7979

8080
jira_api.post("/issue/PROJ-1/transitions").mock(side_effect=capture)
8181
async with JiraClient() as client:
82-
result = await client.transition_issue("PROJ-1", "in progress")
82+
result = await client.transition_issue.aio("PROJ-1", "in progress")
8383
assert result["transition"] == "21"
8484
assert captured["body"] == {"transition": {"id": "21"}}
8585

@@ -88,21 +88,21 @@ async def test_transition_unknown_name_raises(jira_api, creds):
8888
jira_api.get("/issue/PROJ-1/transitions").respond(json={"transitions": [{"id": "21", "name": "In Progress"}]})
8989
async with JiraClient() as client:
9090
with pytest.raises(JiraAPIError) as excinfo:
91-
await client.transition_issue("PROJ-1", "Done")
91+
await client.transition_issue.aio("PROJ-1", "Done")
9292
assert "In Progress" in str(excinfo.value)
9393

9494

9595
async def test_add_comment(jira_api, creds):
9696
jira_api.post("/issue/PROJ-1/comment").respond(json={"id": "c1", "created": "t"})
9797
async with JiraClient() as client:
98-
comment = await client.add_comment("PROJ-1", "on it")
98+
comment = await client.add_comment.aio("PROJ-1", "on it")
9999
assert comment == {"id": "c1", "created": "t"}
100100

101101

102102
async def test_delete_issue(jira_api, creds):
103103
route = jira_api.delete("/issue/PROJ-1").respond(status_code=204, content=b"")
104104
async with JiraClient() as client:
105-
assert await client.delete_issue("PROJ-1") is None
105+
assert await client.delete_issue.aio("PROJ-1") is None
106106
assert route.called
107107

108108

@@ -112,7 +112,7 @@ async def test_api_error_messages(jira_api, creds):
112112
)
113113
async with JiraClient() as client:
114114
with pytest.raises(JiraAPIError) as excinfo:
115-
await client.get_issue("NOPE-1")
115+
await client.get_issue.aio("NOPE-1")
116116
assert excinfo.value.status_code == 404
117117
assert "does not exist" in str(excinfo.value)
118118

@@ -126,5 +126,32 @@ async def test_retries_on_429(jira_api, creds):
126126
from flyteplugins.jira import Config
127127

128128
async with JiraClient(Config(retry_backoff=0.0)) as client:
129-
await client.get_myself()
129+
await client.get_myself.aio()
130130
assert route.call_count == 2
131+
132+
133+
def test_the_blocking_call_form_works_outside_an_event_loop(jira_api):
134+
"""`with Client() as c: c.method(...)` -- the point of syncifying the client.
135+
136+
`__enter__` runs `__aenter__` on syncify's background loop, the same loop the
137+
syncified methods run on, so the httpx client is created and used on one loop.
138+
"""
139+
jira_api.get("/issue/PROJ-1").respond(json=issue_json())
140+
with JiraClient(base_url="https://acme.atlassian.net", email="a@b.c", api_token="t") as client:
141+
issue = client.get_issue("PROJ-1")
142+
assert issue["key"] == "PROJ-1"
143+
144+
145+
async def test_the_async_form_is_the_same_method_via_aio(jira_api):
146+
"""Both call forms are the same method: `m(...)` blocks, `await m.aio(...)` does not."""
147+
jira_api.get("/issue/PROJ-1").respond(json=issue_json())
148+
async with JiraClient(base_url="https://acme.atlassian.net", email="a@b.c", api_token="t") as client:
149+
issue = await client.get_issue.aio("PROJ-1")
150+
assert issue["key"] == "PROJ-1"
151+
152+
153+
def test_methods_expose_both_call_forms():
154+
from flyte.syncify import syncify # noqa: F401
155+
156+
method = JiraClient.get_issue
157+
assert hasattr(method, "aio"), "syncified methods must offer an async form"

0 commit comments

Comments
 (0)