diff --git a/development.md b/development.md index e50b761..6686365 100644 --- a/development.md +++ b/development.md @@ -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. Feels helpful to make it more obvious inside the wrapper but is also 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: 1. Make more jobs 2. Make more wrappers 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 Block - -Remvoved posts +Branch Mute diff --git a/pyproject.toml b/pyproject.toml index c2eafcc..9c414e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "trustcafeapiwrapper" -version = "0.1.0.11" +version = "0.1.0.13" description = "Wraps the Trust Cafe API" readme = "README.md" requires-python = ">=3.11" diff --git a/src/trustcafeapiwrapper/apiclient.py b/src/trustcafeapiwrapper/apiclient.py index 658c862..b622055 100644 --- a/src/trustcafeapiwrapper/apiclient.py +++ b/src/trustcafeapiwrapper/apiclient.py @@ -82,8 +82,11 @@ class APIClient(BaseModel): # Make the API request and handle potential exceptions try: - response = requests.request(method.upper(), url, json=data, headers=headers, timeout=20) - return_json = response.json() + with requests.Session() as session: + 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: raise ConnectionError(f"An error occurred while making the request: {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 if not self.is_token_valid(): - tokendata = self.sign_in() + token_data = self.sign_in() 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): """ diff --git a/src/trustcafeapiwrapper/jobs/follow/__init__.py b/src/trustcafeapiwrapper/jobs/follow/__init__.py new file mode 100644 index 0000000..994e9c0 --- /dev/null +++ b/src/trustcafeapiwrapper/jobs/follow/__init__.py @@ -0,0 +1 @@ +from .follow import follow \ No newline at end of file diff --git a/src/trustcafeapiwrapper/jobs/follow/follow.py b/src/trustcafeapiwrapper/jobs/follow/follow.py new file mode 100644 index 0000000..91d8ee4 --- /dev/null +++ b/src/trustcafeapiwrapper/jobs/follow/follow.py @@ -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 \ No newline at end of file diff --git a/src/trustcafeapiwrapper/utils/__init__.py b/src/trustcafeapiwrapper/utils/__init__.py index 08e5cba..1b28d26 100644 --- a/src/trustcafeapiwrapper/utils/__init__.py +++ b/src/trustcafeapiwrapper/utils/__init__.py @@ -4,4 +4,5 @@ from .make_comment_sk import make_comment_sk from .make_post_sk import make_post_sk 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_userprofile_pksk_from_slug import get_userprofile_pksk_from_slug \ No newline at end of file +from .get_userprofile_pksk_from_slug import get_userprofile_pksk_from_slug +from .get_entity_from_str import get_entity_from_str \ No newline at end of file diff --git a/src/trustcafeapiwrapper/utils/get_entity_from_str.py b/src/trustcafeapiwrapper/utils/get_entity_from_str.py new file mode 100644 index 0000000..529d69b --- /dev/null +++ b/src/trustcafeapiwrapper/utils/get_entity_from_str.py @@ -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 diff --git a/src/trustcafeapiwrapper/utils/get_parent_pksk_from_path.py b/src/trustcafeapiwrapper/utils/get_parent_pksk_from_path.py index 4cd061a..b328f21 100644 --- a/src/trustcafeapiwrapper/utils/get_parent_pksk_from_path.py +++ b/src/trustcafeapiwrapper/utils/get_parent_pksk_from_path.py @@ -1,13 +1,11 @@ +from .get_entity_from_str import get_entity_from_str def get_parent_pksk_from_path(parent_path): if parent_path == '/': return 'maintrunk#maintrunk' entity, slug = parent_path.strip('/').split('/') - if entity == 'branch': - entity = 'subwiki' - elif entity == 'user': - entity = 'userprofile' + entity = get_entity_from_str(entity) if entity not in ['userprofile', 'subwiki']: raise ValueError(f"Invalid parent entity: {entity}. Must be 'userprofile' or 'subwiki'.") diff --git a/src/trustcafeapiwrapper/wrappers/comment/create_comment.py b/src/trustcafeapiwrapper/wrappers/comment/create_comment.py index 32f4f1d..a01d0b3 100644 --- a/src/trustcafeapiwrapper/wrappers/comment/create_comment.py +++ b/src/trustcafeapiwrapper/wrappers/comment/create_comment.py @@ -1,7 +1,14 @@ from trustcafeapiwrapper.utils.get_parent_pksk_from_path import get_parent_pksk_from_path 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. diff --git a/src/trustcafeapiwrapper/wrappers/follow/follow.py b/src/trustcafeapiwrapper/wrappers/follow/follow.py new file mode 100644 index 0000000..65c2235 --- /dev/null +++ b/src/trustcafeapiwrapper/wrappers/follow/follow.py @@ -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 + } + } + } diff --git a/src/trustcafeapiwrapper/wrappers/post/create_post.py b/src/trustcafeapiwrapper/wrappers/post/create_post.py index af25048..4c52e0a 100644 --- a/src/trustcafeapiwrapper/wrappers/post/create_post.py +++ b/src/trustcafeapiwrapper/wrappers/post/create_post.py @@ -1,6 +1,12 @@ 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. diff --git a/src/trustcafeapiwrapper/wrappers/post/update_post.py b/src/trustcafeapiwrapper/wrappers/post/update_post.py index c4b3cfe..7fc64b2 100644 --- a/src/trustcafeapiwrapper/wrappers/post/update_post.py +++ b/src/trustcafeapiwrapper/wrappers/post/update_post.py @@ -1,6 +1,14 @@ 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. diff --git a/src/trustcafeapiwrapper/wrappers/reaction/react.py b/src/trustcafeapiwrapper/wrappers/reaction/react.py index 26bc2e0..b46ae3f 100644 --- a/src/trustcafeapiwrapper/wrappers/reaction/react.py +++ b/src/trustcafeapiwrapper/wrappers/reaction/react.py @@ -1,6 +1,11 @@ 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. 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 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 { "job_function": "reaction.reacttosomething", "payload": { diff --git a/src/trustcafeapiwrapper/wrappers/trust/trust.py b/src/trustcafeapiwrapper/wrappers/trust/trust.py index 3bb5c56..ef3f98f 100644 --- a/src/trustcafeapiwrapper/wrappers/trust/trust.py +++ b/src/trustcafeapiwrapper/wrappers/trust/trust.py @@ -1,6 +1,9 @@ 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. diff --git a/src/trustcafeapiwrapper/wrappers/vote/votecast.py b/src/trustcafeapiwrapper/wrappers/vote/votecast.py index 742d24b..efb03b6 100644 --- a/src/trustcafeapiwrapper/wrappers/vote/votecast.py +++ b/src/trustcafeapiwrapper/wrappers/vote/votecast.py @@ -1,6 +1,11 @@ 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. @@ -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 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 { "job_function": "vote.votecast", diff --git a/testing.py b/testing.py index 72c8cc2..0a0faf9 100644 --- a/testing.py +++ b/testing.py @@ -26,7 +26,9 @@ API = APIClient( # Keep a token cache to avoid unnecessary sign-ins during development. # (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 @@ -41,8 +43,8 @@ def save_response(response): # print("-----------Get a user profile----------------") -profile = API.run_job('userprofile.get', "simon-little") -print(profile) +# profile = API.run_job('userprofile.get', "simon-little") +# print(profile) # print("-------------- Get a branch -----------------") # branch = API.run_job('branch.get', "music") # print(branch) @@ -197,3 +199,11 @@ print(profile) # post_path="/post/1775143460-ef45186a", # parent_path="/", # ))) + + +from trustcafeapiwrapper.wrappers.follow.follow import follow +save_response(API.wrapped(follow( + entity='userprofile', + is_following=True, + parent_slug='alphaemail-test2' +))) \ No newline at end of file diff --git a/tests/apiclient.py b/tests/apiclient.py index fa7ec3a..a7e0b0e 100644 --- a/tests/apiclient.py +++ b/tests/apiclient.py @@ -79,7 +79,7 @@ class TestAPIClient(unittest.TestCase): 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): # This test should be expanded # Or the the functions should be broken up more to be more easily testable diff --git a/tests/utils/get_entity_from_str.py b/tests/utils/get_entity_from_str.py new file mode 100644 index 0000000..540db96 --- /dev/null +++ b/tests/utils/get_entity_from_str.py @@ -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') \ No newline at end of file diff --git a/tests/wrappers/follow.py b/tests/wrappers/follow.py new file mode 100644 index 0000000..af1f280 --- /dev/null +++ b/tests/wrappers/follow.py @@ -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) \ No newline at end of file diff --git a/tests/wrappers/react.py b/tests/wrappers/react.py index 641a535..529f3cc 100644 --- a/tests/wrappers/react.py +++ b/tests/wrappers/react.py @@ -32,4 +32,20 @@ class TestReact(unittest.TestCase): self.assertEqual(result["payload"]["parent"]["pk"], "post#12345-abcv") self.assertEqual(result["payload"]["parent"]["sk"], "comment#67890") self.assertEqual(result["payload"]["parent"]["entity"], "comment") - self.assertEqual(result["payload"]["parent"]["slug"], "67890") \ No newline at end of file + 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") \ No newline at end of file diff --git a/tests/wrappers/trust.py b/tests/wrappers/trust.py index fe723f8..84a8cf9 100644 --- a/tests/wrappers/trust.py +++ b/tests/wrappers/trust.py @@ -12,4 +12,4 @@ class TestTrustCreateOrUpdate(unittest.TestCase): self.assertIn("payload", result) self.assertEqual(result["job_function"], "trust.createorupdate") self.assertEqual(result["payload"]["trustLevel"], trustLevel) - self.assertEqual(result["payload"]["parentSlug"], "johndoe") \ No newline at end of file + self.assertEqual(result["payload"]["parentSlug"], "johndoe") diff --git a/tests/wrappers/update_post.py b/tests/wrappers/update_post.py index f8cd43d..b9ccafe 100644 --- a/tests/wrappers/update_post.py +++ b/tests/wrappers/update_post.py @@ -90,4 +90,48 @@ class TestUpdatePost(unittest.TestCase): self.assertEqual(result["payload"]["key"]["sk"], "post#1235-abcv") self.assertNotIn("slug", result["payload"]["key"]) self.assertEqual(result["payload"]["blurLabel"], blur_label) - self.assertEqual(result["payload"]["cardUrl"], card_url) \ No newline at end of file + 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.") \ No newline at end of file diff --git a/tests/wrappers/vote.py b/tests/wrappers/vote.py index 5f672f5..7906380 100644 --- a/tests/wrappers/vote.py +++ b/tests/wrappers/vote.py @@ -31,4 +31,23 @@ class TestVoteCast(unittest.TestCase): 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") \ No newline at end of file + 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") diff --git a/unittests.py b/unittests.py index d0dc1b9..c79f4d8 100644 --- a/unittests.py +++ b/unittests.py @@ -2,6 +2,7 @@ import sys sys.path.insert(0, './src/') import unittest +from tests.utils.get_entity_from_str import TestGetEntityFromStr from tests.utils.get_post_pksk import TestGetPostPksk from tests.utils.get_parent_pksk_from_path import TestGetParentPkskFromPath 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.vote import TestVoteCast from tests.wrappers.trust import TestTrustCreateOrUpdate - +from tests.wrappers.follow import TestFollow from tests.apiclient import TestAPIClient