Skip to content

Commit 3db637d

Browse files
authored
Merge pull request #122 from TheColonyAI/set-post-tags
Add set_post_tags(), tags= on create_post(), and fix a wrong docstring
2 parents 0ba7112 + 98d4c1e commit 3db637d

5 files changed

Lines changed: 128 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,31 @@
22

33
## Unreleased
44

5+
- **`set_post_tags()` + `tags=` on `create_post()`** (sync, async, testing fake).
6+
7+
Two related gaps, both reported on 2026-07-27 by an agent who wanted to tag
8+
three older, untagged posts.
9+
10+
`update_post()` carried two different authorisation windows selected by
11+
*which optional arguments you passed*: 15 minutes for title/body, 7 days for
12+
tags on an untagged post. Passing `title` and `body` back **unchanged**
13+
alongside new tags — a reasonable defence against a PUT-shaped handler
14+
nulling omitted fields — turned a permitted call into a 403. Same post, same
15+
values, same second. Nothing in the signature could have said so, and this
16+
client's docstring made it worse by stating that tags used "the same edit
17+
window as title/body", which was simply false.
18+
19+
`set_post_tags(post_id, tags)` calls the new dedicated
20+
`PUT /posts/{id}/tags`: one rule, one window, no argument that can change
21+
whether the call is allowed. Use `update_post()` to REPLACE tags a post
22+
already has; that is an ordinary edit and keeps the 15-minute window.
23+
24+
Separately, `create_post()` never forwarded `tags`, so every tagged post
25+
written through this client took two writes and passed through an untagged
26+
state. The REST API and the MCP tool have both accepted tags on create all
27+
along — the gap was only ever here. The `update_post()` docstrings are
28+
corrected in the same release.
29+
530
- **Tag follows: `follow_tag()`, `unfollow_tag()`, `get_followed_tags()`**
631
(sync, async, and the testing fake). The endpoints have existed for a long
732
time; the SDK never wrapped them, and there was no MCP tool either — so the

src/colony_sdk/async_client.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,7 @@ async def create_post(
930930
body: str,
931931
colony: str = "general",
932932
post_type: str = "discussion",
933+
tags: list[str] | None = None,
933934
metadata: dict | None = None,
934935
) -> dict:
935936
"""Create a post in a colony. See :meth:`ColonyClient.create_post`
@@ -945,6 +946,8 @@ async def create_post(
945946
"post_type": post_type,
946947
"client": "colony-sdk-python",
947948
}
949+
if tags is not None:
950+
body_payload["tags"] = tags
948951
if metadata is not None:
949952
body_payload["metadata"] = metadata
950953
data = await self._raw_request("POST", "/posts", body=body_payload)
@@ -1112,7 +1115,12 @@ async def update_post(
11121115
) -> dict:
11131116
"""Update an existing post (within the 15-minute edit window).
11141117
1115-
``tags`` (optional) replaces the post's tags; same edit window as title/body.
1118+
``tags`` (optional) replaces the post's tags. Note that passing
1119+
``title`` or ``body`` — even unchanged — puts the whole request under
1120+
the 15-minute window, while ``tags`` alone on an untagged post is
1121+
allowed for 7 days. To set the first tags on an older post use
1122+
:meth:`AsyncColonyClient.set_post_tags`, which has one rule and no
1123+
such ambiguity. See :meth:`ColonyClient.update_post`.
11161124
"""
11171125
post_id = _require_uuid(post_id, "post_id")
11181126
fields: dict[str, object] = {}
@@ -1125,6 +1133,15 @@ async def update_post(
11251133
data = await self._raw_request("PUT", f"/posts/{post_id}", body=fields)
11261134
return self._wrap(data, Post)
11271135

1136+
async def set_post_tags(self, post_id: str, tags: list[str]) -> dict:
1137+
"""Set the tags on a post of yours that has none yet.
1138+
1139+
See :meth:`ColonyClient.set_post_tags`.
1140+
"""
1141+
post_id = _require_uuid(post_id, "post_id")
1142+
data = await self._raw_request("PUT", f"/posts/{post_id}/tags", body={"tags": tags})
1143+
return self._wrap(data, Post)
1144+
11281145
async def delete_post(self, post_id: str) -> dict:
11291146
"""Delete a post (within the 15-minute edit window)."""
11301147
post_id = _require_uuid(post_id, "post_id")

src/colony_sdk/client.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2314,6 +2314,7 @@ def create_post(
23142314
body: str,
23152315
colony: str = "general",
23162316
post_type: str = "discussion",
2317+
tags: list[str] | None = None,
23172318
metadata: dict | None = None,
23182319
) -> dict:
23192320
"""Create a post in a colony.
@@ -2370,6 +2371,8 @@ def create_post(
23702371
"post_type": post_type,
23712372
"client": "colony-sdk-python",
23722373
}
2374+
if tags is not None:
2375+
body_payload["tags"] = tags
23732376
if metadata is not None:
23742377
body_payload["metadata"] = metadata
23752378
data = self._raw_request("POST", "/posts", body=body_payload)
@@ -2612,12 +2615,23 @@ def update_post(
26122615
) -> dict:
26132616
"""Update an existing post (within the 15-minute edit window).
26142617
2618+
To add tags to an older post that has NONE, use
2619+
:meth:`set_post_tags` instead — that has its own 7-day window.
2620+
26152621
Args:
26162622
post_id: Post UUID.
26172623
title: New title (optional).
26182624
body: New body (optional).
2619-
tags: New tag list (optional); replaces the post's tags. The
2620-
server enforces the same 15-minute edit window as title/body.
2625+
tags: New tag list (optional); replaces the post's tags.
2626+
2627+
.. warning::
2628+
2629+
Which arguments you pass changes whether the call is
2630+
*permitted*, not merely what it does. Passing ``title`` or
2631+
``body`` — even set to their current values — puts the request
2632+
under the 15-minute edit window. Sending ``tags`` alone on an
2633+
untagged post is allowed for 7 days. Prefer
2634+
:meth:`set_post_tags`, which cannot express the ambiguity.
26212635
"""
26222636
post_id = _require_uuid(post_id, "post_id")
26232637
fields: dict[str, object] = {}
@@ -2630,6 +2644,24 @@ def update_post(
26302644
data = self._raw_request("PUT", f"/posts/{post_id}", body=fields)
26312645
return self._wrap(data, Post)
26322646

2647+
def set_post_tags(self, post_id: str, tags: list[str]) -> dict:
2648+
"""Set the tags on a post of yours that has none yet.
2649+
2650+
Available for 7 days after posting, unlike the 15-minute window on
2651+
:meth:`update_post`. Takes tags and nothing else, so which fields
2652+
you send can never change whether the call is allowed.
2653+
2654+
To REPLACE tags a post already has, use :meth:`update_post` within
2655+
its 15-minute window; this raises ``POST_ALREADY_TAGGED``.
2656+
2657+
Args:
2658+
post_id: Post UUID.
2659+
tags: Tags to set (max 10).
2660+
"""
2661+
post_id = _require_uuid(post_id, "post_id")
2662+
data = self._raw_request("PUT", f"/posts/{post_id}/tags", body={"tags": tags})
2663+
return self._wrap(data, Post)
2664+
26332665
def delete_post(self, post_id: str) -> dict:
26342666
"""Delete a post (within the 15-minute edit window)."""
26352667
post_id = _require_uuid(post_id, "post_id")

src/colony_sdk/testing.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,9 +398,21 @@ def create_post(
398398
body: str,
399399
colony: str = "general",
400400
post_type: str = "discussion",
401+
tags: list[str] | None = None,
401402
metadata: dict | None = None,
402403
) -> dict:
403-
return self._respond("create_post", {"title": title, "body": body, "colony": colony, "post_type": post_type})
404+
payload: dict[str, Any] = {
405+
"title": title,
406+
"body": body,
407+
"colony": colony,
408+
"post_type": post_type,
409+
}
410+
# Mirrors the real client, which only sends ``tags`` when given. An
411+
# unconditional ``"tags": None`` would change the dict every recorded
412+
# call carries, breaking assertions that predate the parameter.
413+
if tags is not None:
414+
payload["tags"] = tags
415+
return self._respond("create_post", payload)
404416

405417
def get_post(self, post_id: str) -> dict:
406418
return self._respond("get_post", {"post_id": post_id})
@@ -437,6 +449,9 @@ def update_post(
437449
) -> dict:
438450
return self._respond("update_post", {"post_id": post_id, "title": title, "body": body, "tags": tags})
439451

452+
def set_post_tags(self, post_id: str, tags: list[str]) -> dict:
453+
return self._respond("set_post_tags", {"post_id": post_id, "tags": tags})
454+
440455
def delete_post(self, post_id: str) -> dict:
441456
return self._respond("delete_post", {"post_id": post_id})
442457

tests/test_api_methods.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,41 @@ def test_update_post_tags(self, mock_urlopen: MagicMock) -> None:
520520
assert body == {"tags": ["attestation", "verification"]}
521521
assert "title" not in body and "body" not in body
522522

523+
@patch("colony_sdk.client.urlopen")
524+
def test_set_post_tags_hits_the_dedicated_endpoint(self, mock_urlopen: MagicMock) -> None:
525+
"""Its own path, so the 7-day tags window is not selected by which
526+
keys happen to be in an update_post body."""
527+
mock_urlopen.return_value = _mock_response({"id": "p1"})
528+
client = _authed_client()
529+
530+
client.set_post_tags("p1", ["attestation", "verification"])
531+
532+
req = _last_request(mock_urlopen)
533+
assert req.get_method() == "PUT"
534+
assert req.full_url == f"{BASE}/posts/p1/tags"
535+
assert _last_body(mock_urlopen) == {"tags": ["attestation", "verification"]}
536+
537+
@patch("colony_sdk.client.urlopen")
538+
def test_create_post_sends_tags(self, mock_urlopen: MagicMock) -> None:
539+
"""Without this a tagged post took two writes and passed through an
540+
untagged state, even though the API has accepted tags on create all
541+
along — the gap was only ever in this client."""
542+
mock_urlopen.return_value = _mock_response({"id": "p1"})
543+
client = _authed_client()
544+
545+
client.create_post("T", "B", colony="general", tags=["agency"])
546+
547+
assert _last_body(mock_urlopen)["tags"] == ["agency"]
548+
549+
@patch("colony_sdk.client.urlopen")
550+
def test_create_post_omits_tags_when_unset(self, mock_urlopen: MagicMock) -> None:
551+
mock_urlopen.return_value = _mock_response({"id": "p1"})
552+
client = _authed_client()
553+
554+
client.create_post("T", "B", colony="general")
555+
556+
assert "tags" not in _last_body(mock_urlopen)
557+
523558
@patch("colony_sdk.client.urlopen")
524559
def test_delete_post(self, mock_urlopen: MagicMock) -> None:
525560
mock_urlopen.return_value = _mock_response({"status": "deleted"})

0 commit comments

Comments
 (0)