Merge branch 'dev' into 'main'

0.1.0.13: Following

See merge request trustcafe/trustcafe-api-wrapper!11
This commit is contained in:
Simon Little 2026-04-16 21:36:06 +00:00
commit 14346ed02e
24 changed files with 323 additions and 34 deletions

View file

@ -19,21 +19,19 @@ backend, relfollow for follow, but don't have relblock, instead it's
userblock. It's a bit inconsistent and not very obvious at all. userblock. It's a bit inconsistent and not very obvious at all.
Feels helpful to make it more obvious inside the wrapper but is also Feels helpful to make it more obvious inside the wrapper but is also
potentially misleading. potentially misleading.
7. It makes more sense to organise like: `user.follow` rather than
`follow.follow`, and `post.vote` instead of `vote.vote`, but what about
`post.comment.vote` vs `comment.vote`, what about
`post.comment.comment`, they seem weird.
## ToDo: ## ToDo:
1. Make more jobs 1. Make more jobs
2. Make more wrappers 2. Make more wrappers
3. Write more documentation 3. Write more documentation
4. Wrappers should also accept actual keys for when you know them
5. Add utility to manage the token easily to encourage reuse over
new tokens every time
Trust
Follow
Mute Mute
Block Block
Branch Mute
Remvoved posts

View file

@ -1,6 +1,6 @@
[project] [project]
name = "trustcafeapiwrapper" name = "trustcafeapiwrapper"
version = "0.1.0.11" version = "0.1.0.13"
description = "Wraps the Trust Cafe API" description = "Wraps the Trust Cafe API"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

View file

@ -82,8 +82,11 @@ class APIClient(BaseModel):
# Make the API request and handle potential exceptions # Make the API request and handle potential exceptions
try: try:
response = requests.request(method.upper(), url, json=data, headers=headers, timeout=20) with requests.Session() as session:
return_json = response.json() session.headers.update(headers)
response = session.request(method.upper(), url, json=data, headers=headers, timeout=20)
response.raise_for_status() # Raise an exception for HTTP errors
return_json = response.json()
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
raise ConnectionError(f"An error occurred while making the request: {e}") raise ConnectionError(f"An error occurred while making the request: {e}")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
@ -170,9 +173,11 @@ class APIClient(BaseModel):
# Get a new one if we don't have one or if the existing one is expired # Get a new one if we don't have one or if the existing one is expired
if not self.is_token_valid(): if not self.is_token_valid():
tokendata = self.sign_in() token_data = self.sign_in()
with open(token_data_path, "w") as f: with open(token_data_path, "w") as f:
json.dump(tokendata, f, indent=2) json.dump(token_data, f, indent=2)
return token_data
def run_job(self, job_function, *args, **kwargs): def run_job(self, job_function, *args, **kwargs):
""" """

View file

@ -0,0 +1 @@
from .follow import follow

View file

@ -0,0 +1,12 @@
def follow(API, payload: dict) -> dict:
"""
Creates or updates a follow relationship in the API.
Args:
payload (dict): The data for the follow relationship.
Returns:
dict: The follow relationship data.
"""
follow_data = API.make_request("POST", "content", "relfollow", data=payload, authenticate=True)
return follow_data

View file

@ -4,4 +4,5 @@ from .make_comment_sk import make_comment_sk
from .make_post_sk import make_post_sk from .make_post_sk import make_post_sk
from .get_child_spksk_from_paths import get_child_spksk_from_paths from .get_child_spksk_from_paths import get_child_spksk_from_paths
from .get_user_slug_from_path import get_user_slug_from_path from .get_user_slug_from_path import get_user_slug_from_path
from .get_userprofile_pksk_from_slug import get_userprofile_pksk_from_slug from .get_userprofile_pksk_from_slug import get_userprofile_pksk_from_slug
from .get_entity_from_str import get_entity_from_str

View file

@ -0,0 +1,34 @@
def get_entity_from_str(entity):
# Translate the more obvious names to our
# obscure internal ones - sorry about that!
if entity == 'branch':
entity = 'subwiki'
elif entity == 'user':
entity = 'userprofile'
elif entity == 'follow':
entity = 'relfollow'
elif entity == 'trust':
entity = 'reltrust'
elif entity == 'block':
entity = 'userblock'
elif entity == 'mute':
entity = 'usermute'
valid = [
'comment',
'post',
'reaction',
'relfollow',
'reltrust',
'subwiki',
'userblock',
'usermute',
'userprofile',
'vote',
]
if entity not in valid:
raise ValueError(f"Invalid entity: {entity}. Must be one of {', '.join(valid)}.")
return entity

View file

@ -1,13 +1,11 @@
from .get_entity_from_str import get_entity_from_str
def get_parent_pksk_from_path(parent_path): def get_parent_pksk_from_path(parent_path):
if parent_path == '/': if parent_path == '/':
return 'maintrunk#maintrunk' return 'maintrunk#maintrunk'
entity, slug = parent_path.strip('/').split('/') entity, slug = parent_path.strip('/').split('/')
if entity == 'branch': entity = get_entity_from_str(entity)
entity = 'subwiki'
elif entity == 'user':
entity = 'userprofile'
if entity not in ['userprofile', 'subwiki']: if entity not in ['userprofile', 'subwiki']:
raise ValueError(f"Invalid parent entity: {entity}. Must be 'userprofile' or 'subwiki'.") raise ValueError(f"Invalid parent entity: {entity}. Must be 'userprofile' or 'subwiki'.")

View file

@ -1,7 +1,14 @@
from trustcafeapiwrapper.utils.get_parent_pksk_from_path import get_parent_pksk_from_path from trustcafeapiwrapper.utils.get_parent_pksk_from_path import get_parent_pksk_from_path
from trustcafeapiwrapper.utils.get_post_pksk import get_post_pksk from trustcafeapiwrapper.utils.get_post_pksk import get_post_pksk
def create_comment(comment_text, post_slug=None, parent_path=None, post_key=None, blur_label=None, version=3): def create_comment(
comment_text:str,
post_slug:str|None=None,
parent_path:str|None=None,
post_key:dict|None=None,
blur_label:str|None=None,
version:int=3
):
""" """
Creates a new comment. Creates a new comment.

View file

@ -0,0 +1,40 @@
from trustcafeapiwrapper.utils.get_entity_from_str import get_entity_from_str
def follow(
entity: str,
is_following: bool,
parent_slug: str,
):
"""
Creates new or update existing follow entry in the API.
Args:
entity (str): The type of entity to follow (userprofile | subwiki).
is_following (bool): Indicates whether the entity is being followed.
parent_slug (str): The slug of the parent entity.
Returns:
dict: A dictionary containing the job name and payload for creating the post
that will be processed by the API client wrapper function.
"""
entity = get_entity_from_str(entity)
itemPKSK = f"{entity}#{parent_slug}"
return {
"job_function": "follow.follow",
"payload": {
"isFollowing": is_following,
"parent": {
"pk": itemPKSK,
"sk": itemPKSK
},
"followType": entity,
"parentSlug": parent_slug,
"preferences": {
"notification": True,
"emailNew": False,
"emailDigest": True
}
}
}

View file

@ -1,6 +1,12 @@
from trustcafeapiwrapper.utils.get_parent_pksk_from_path import get_parent_pksk_from_path from trustcafeapiwrapper.utils.get_parent_pksk_from_path import get_parent_pksk_from_path
def create_post(post_text, parent_path='/', blur_label=None, card_url=None, collaborative=False): def create_post(
post_text:str,
parent_path:str|None='/',
blur_label:str|None=None,
card_url:str|None=None,
collaborative:bool=False
):
""" """
Creates a new post. Creates a new post.

View file

@ -1,6 +1,14 @@
from trustcafeapiwrapper.utils import get_post_pksk, get_parent_pksk_from_path from trustcafeapiwrapper.utils import get_post_pksk, get_parent_pksk_from_path
def update_post( post_text, post_path=None, parent_path='/', post_key=None, blur_label=None, card_url=None, collaborative=False): def update_post(
post_text:str,
post_path:str|None=None,
parent_path:str|None='/',
post_key:dict|None=None,
blur_label:str|None=None,
card_url:str|None=None,
collaborative:bool=False
):
""" """
Updates an existing post. Updates an existing post.

View file

@ -1,6 +1,11 @@
from trustcafeapiwrapper.utils import get_child_spksk_from_paths from trustcafeapiwrapper.utils import get_child_spksk_from_paths
def react(reaction_type: str, parent_path: str, item_path: str): def react(
reaction_type: str,
parent_path: str|None=None,
item_path: str|None=None,
item_key: dict|None=None
):
""" """
React to something. ie a post or a comment. React to something. ie a post or a comment.
This is one endpoint for creating, updating and deleting reactions. This is one endpoint for creating, updating and deleting reactions.
@ -12,7 +17,13 @@ def react(reaction_type: str, parent_path: str, item_path: str):
dict: A dictionary containing the job name and payload for creating the post dict: A dictionary containing the job name and payload for creating the post
that will be processed by the API client wrapper function. that will be processed by the API client wrapper function.
""" """
parent = get_child_spksk_from_paths(parent_path, item_path) if item_key is not None:
parent = {
'pk': item_key.get('pk', None),
'sk': item_key.get('sk', None)
}
else:
parent = get_child_spksk_from_paths(parent_path, item_path)
return { return {
"job_function": "reaction.reacttosomething", "job_function": "reaction.reacttosomething",
"payload": { "payload": {

View file

@ -1,6 +1,9 @@
from trustcafeapiwrapper.utils import get_user_slug_from_path, get_userprofile_pksk_from_slug from trustcafeapiwrapper.utils import get_user_slug_from_path, get_userprofile_pksk_from_slug
def trust(trustLevel: str, userprofile_path: str): def trust(
trustLevel: str,
userprofile_path: str
):
""" """
Creates new or update existing trust entry in the API. Creates new or update existing trust entry in the API.

View file

@ -1,6 +1,11 @@
from trustcafeapiwrapper.utils import get_child_spksk_from_paths from trustcafeapiwrapper.utils import get_child_spksk_from_paths
def votecast(vote: str, parent_path: str, item_path: str): def votecast(
vote: str,
parent_path: str|None=None,
item_path: str|None=None,
item_key: dict|None=None
):
""" """
Creates a new vote in the API. Creates a new vote in the API.
@ -10,7 +15,15 @@ def votecast(vote: str, parent_path: str, item_path: str):
dict: A dictionary containing the job name and payload for creating the post dict: A dictionary containing the job name and payload for creating the post
that will be processed by the API client wrapper function. that will be processed by the API client wrapper function.
""" """
parent = get_child_spksk_from_paths(parent_path, item_path) if item_key is not None:
parent = {
'pk': item_key.get('pk', None),
'sk': item_key.get('sk', None),
'slug': item_key.get('sk', '').split('#')[-1],
'entity': item_key.get('sk', '').split('#')[0]
}
else:
parent = get_child_spksk_from_paths(parent_path, item_path)
return { return {
"job_function": "vote.votecast", "job_function": "vote.votecast",

View file

@ -26,7 +26,9 @@ API = APIClient(
# Keep a token cache to avoid unnecessary sign-ins during development. # Keep a token cache to avoid unnecessary sign-ins during development.
# (In production, you'd handle this more robustly and securely) # (In production, you'd handle this more robustly and securely)
API.handle_token() # This will load the token from file if it exists and is valid, or sign in to get a new one if not. token_data = API.handle_token() # This will load the token from file if it exists and is valid, or sign in to get a new one if not.
if token_data is None or 'access_token' not in token_data:
raise Exception("Failed to obtain a valid access token. Please check your credentials and token handling.")
''' '''
END IMPORTANT BIT END IMPORTANT BIT
@ -41,8 +43,8 @@ def save_response(response):
# print("-----------Get a user profile----------------") # print("-----------Get a user profile----------------")
profile = API.run_job('userprofile.get', "simon-little") # profile = API.run_job('userprofile.get', "simon-little")
print(profile) # print(profile)
# print("-------------- Get a branch -----------------") # print("-------------- Get a branch -----------------")
# branch = API.run_job('branch.get', "music") # branch = API.run_job('branch.get', "music")
# print(branch) # print(branch)
@ -197,3 +199,11 @@ print(profile)
# post_path="/post/1775143460-ef45186a", # post_path="/post/1775143460-ef45186a",
# parent_path="/", # parent_path="/",
# ))) # )))
from trustcafeapiwrapper.wrappers.follow.follow import follow
save_response(API.wrapped(follow(
entity='userprofile',
is_following=True,
parent_slug='alphaemail-test2'
)))

View file

@ -79,7 +79,7 @@ class TestAPIClient(unittest.TestCase):
self.api_client.make_request("GET", "invalid_endpoint", "test") self.api_client.make_request("GET", "invalid_endpoint", "test")
@patch('trustcafeapiwrapper.apiclient.requests.request') @patch('trustcafeapiwrapper.apiclient.requests.Session.request')
def test_make_request(self, mock_request): def test_make_request(self, mock_request):
# This test should be expanded # This test should be expanded
# Or the the functions should be broken up more to be more easily testable # Or the the functions should be broken up more to be more easily testable

View file

@ -0,0 +1,19 @@
import unittest
from trustcafeapiwrapper.utils.get_entity_from_str import get_entity_from_str
class TestGetEntityFromStr(unittest.TestCase):
def test_branch(self):
self.assertEqual(get_entity_from_str('branch'), 'subwiki')
def test_user(self):
self.assertEqual(get_entity_from_str('user'), 'userprofile')
def test_subwiki(self):
self.assertEqual(get_entity_from_str('subwiki'), 'subwiki')
def test_userprofile(self):
self.assertEqual(get_entity_from_str('userprofile'), 'userprofile')
def test_invalid_entity(self):
with self.assertRaises(ValueError):
get_entity_from_str('invalidentity')

43
tests/wrappers/follow.py Normal file
View file

@ -0,0 +1,43 @@
import unittest
from trustcafeapiwrapper.wrappers.follow.follow import follow
class TestFollow(unittest.TestCase):
def test_follow(self):
result = follow(
entity='user',
is_following=True,
parent_slug='janedoe'
)
self.assertIsInstance(result, dict)
self.assertIn("job_function", result)
self.assertIn("payload", result)
self.assertEqual(result["job_function"], "follow.follow")
self.assertIsInstance(result["payload"], dict)
self.assertIn("isFollowing", result["payload"])
self.assertIn("parent", result["payload"])
self.assertIn("followType", result["payload"])
self.assertIn("preferences", result["payload"])
self.assertEqual(result["payload"]["isFollowing"], True)
self.assertEqual(result["payload"]["parentSlug"], "janedoe")
self.assertEqual(result["payload"]["parent"]["pk"], "userprofile#janedoe")
def test_unfollow(self):
result = follow(
entity='user',
is_following=False,
parent_slug='janedoe'
)
self.assertIsInstance(result, dict)
self.assertIn("job_function", result)
self.assertIn("payload", result)
self.assertEqual(result["job_function"], "follow.follow")
self.assertIsInstance(result["payload"], dict)
self.assertIn("isFollowing", result["payload"])
self.assertIn("parent", result["payload"])
self.assertIn("followType", result["payload"])
self.assertIn("preferences", result["payload"])
self.assertEqual(result["payload"]["isFollowing"], False)

View file

@ -32,4 +32,20 @@ class TestReact(unittest.TestCase):
self.assertEqual(result["payload"]["parent"]["pk"], "post#12345-abcv") self.assertEqual(result["payload"]["parent"]["pk"], "post#12345-abcv")
self.assertEqual(result["payload"]["parent"]["sk"], "comment#67890") self.assertEqual(result["payload"]["parent"]["sk"], "comment#67890")
self.assertEqual(result["payload"]["parent"]["entity"], "comment") self.assertEqual(result["payload"]["parent"]["entity"], "comment")
self.assertEqual(result["payload"]["parent"]["slug"], "67890") self.assertEqual(result["payload"]["parent"]["slug"], "67890")
def react_with_parent_key(self):
reaction_type = 'like'
parent_key = {
'pk': 'post#12345-abcv',
'sk': 'comment#67890'
}
result = react(reaction_type, parent_key=parent_key)
self.assertIsInstance(result, dict)
self.assertIn("job_function", result)
self.assertIn("payload", result)
self.assertEqual(result["job_function"], "reaction.reacttosomething")
self.assertEqual(result["payload"]["reaction"], reaction_type)
self.assertEqual(result["payload"]["parent"]["pk"], "post#12345-abcv")
self.assertEqual(result["payload"]["parent"]["sk"], "comment#67890")

View file

@ -12,4 +12,4 @@ class TestTrustCreateOrUpdate(unittest.TestCase):
self.assertIn("payload", result) self.assertIn("payload", result)
self.assertEqual(result["job_function"], "trust.createorupdate") self.assertEqual(result["job_function"], "trust.createorupdate")
self.assertEqual(result["payload"]["trustLevel"], trustLevel) self.assertEqual(result["payload"]["trustLevel"], trustLevel)
self.assertEqual(result["payload"]["parentSlug"], "johndoe") self.assertEqual(result["payload"]["parentSlug"], "johndoe")

View file

@ -90,4 +90,48 @@ class TestUpdatePost(unittest.TestCase):
self.assertEqual(result["payload"]["key"]["sk"], "post#1235-abcv") self.assertEqual(result["payload"]["key"]["sk"], "post#1235-abcv")
self.assertNotIn("slug", result["payload"]["key"]) self.assertNotIn("slug", result["payload"]["key"])
self.assertEqual(result["payload"]["blurLabel"], blur_label) self.assertEqual(result["payload"]["blurLabel"], blur_label)
self.assertEqual(result["payload"]["cardUrl"], card_url) self.assertEqual(result["payload"]["cardUrl"], card_url)
def test_update_post_with_post_key(self):
post_key = {
"pk": "maintrunk#maintrunk",
"sk": "post#1235-abcv"
}
result = update_post(
post_key=post_key,
post_text=self.post_text,
blur_label=self.blur_label,
card_url=self.card_url,
collaborative=self.collaborative,
)
self.assertIsInstance(result, dict)
self.assertIn("job_function", result)
self.assertIn("payload", result)
self.assertEqual(result["job_function"], "post.update")
self.assertEqual(result["payload"]["postText"], self.post_text)
self.assertEqual(result["payload"]["postSlug"], "1235-abcv")
self.assertEqual(result["payload"]["key"]["pk"], post_key["pk"])
self.assertEqual(result["payload"]["key"]["sk"], post_key["sk"])
self.assertNotIn("slug", result["payload"]["key"])
def test_update_post_missing_post_text(self):
with self.assertRaises(ValueError) as context:
update_post(
parent_path='/',
post_path='/post/1235-abcv',
post_text=None,
blur_label=self.blur_label,
card_url=self.card_url,
collaborative=self.collaborative
)
self.assertEqual(str(context.exception), "post_text is required.")
def test_update_post_missing_identification(self):
with self.assertRaises(ValueError) as context:
update_post(
post_text=self.post_text,
blur_label=self.blur_label,
card_url=self.card_url,
collaborative=self.collaborative
)
self.assertEqual(str(context.exception), "Either post_path and parent_path or post_key must be provided.")

View file

@ -31,4 +31,23 @@ class TestVoteCast(unittest.TestCase):
self.assertEqual(result["payload"]["parent"]["pk"], "post#12345") self.assertEqual(result["payload"]["parent"]["pk"], "post#12345")
self.assertEqual(result["payload"]["parent"]["sk"], "comment#67890") self.assertEqual(result["payload"]["parent"]["sk"], "comment#67890")
self.assertEqual(result["payload"]["parent"]["entity"], "comment") self.assertEqual(result["payload"]["parent"]["entity"], "comment")
self.assertEqual(result["payload"]["parent"]["slug"], "67890") self.assertEqual(result["payload"]["parent"]["slug"], "67890")
def test_vote_cast_with_parent_key(self):
vote = 'up'
parent_key = {
'pk': 'post#12345',
'sk': 'comment#67890'
}
result = votecast(vote, item_key=parent_key)
self.assertIsInstance(result, dict)
self.assertIn("job_function", result)
self.assertIn("payload", result)
self.assertEqual(result["job_function"], "vote.votecast")
self.assertEqual(result["payload"]["vote"], vote)
self.assertEqual(result["payload"]["parent"]["pk"], "post#12345")
self.assertEqual(result["payload"]["parent"]["sk"], "comment#67890")
self.assertEqual(result["payload"]["parent"]["entity"], "comment")
self.assertEqual(result["payload"]["parent"]["slug"], "67890")

View file

@ -2,6 +2,7 @@ import sys
sys.path.insert(0, './src/') sys.path.insert(0, './src/')
import unittest import unittest
from tests.utils.get_entity_from_str import TestGetEntityFromStr
from tests.utils.get_post_pksk import TestGetPostPksk from tests.utils.get_post_pksk import TestGetPostPksk
from tests.utils.get_parent_pksk_from_path import TestGetParentPkskFromPath from tests.utils.get_parent_pksk_from_path import TestGetParentPkskFromPath
from tests.utils.make_comment_sk import TestMakeCommentSk from tests.utils.make_comment_sk import TestMakeCommentSk
@ -16,7 +17,7 @@ from tests.wrappers.create_comment import TestCreateComment
from tests.wrappers.react import TestReact from tests.wrappers.react import TestReact
from tests.wrappers.vote import TestVoteCast from tests.wrappers.vote import TestVoteCast
from tests.wrappers.trust import TestTrustCreateOrUpdate from tests.wrappers.trust import TestTrustCreateOrUpdate
from tests.wrappers.follow import TestFollow
from tests.apiclient import TestAPIClient from tests.apiclient import TestAPIClient