diff --git a/communi_api/communi_api.py b/communi_api/communi_api.py index 6fd91f1..58d36d9 100644 --- a/communi_api/communi_api.py +++ b/communi_api/communi_api.py @@ -1,3 +1,5 @@ +"""This module implements functionality to use CommuniAPI with python.""" + import json import logging import logging.config @@ -18,13 +20,20 @@ class CommuniApi: - """CommuniAPI class which can be used for all actions with Communi""" - - def __init__(self, communi_server, communi_token, communi_appid): - """Args: - communi_token (str): security token used for access - see /page/integration/tab/rest within communi as admin - communi_server (str): REST endpoint of the server https://api.communiapp.de/rest by default - communi_appid (int): app ID of the communi instance to be used - see /page/integration/tab/rest within communi as admin + """CommuniAPI class which can be used for all actions with Communi.""" + + def __init__( + self, communi_server: str, communi_token: str, communi_appid: int + ) -> None: + """Setup initial connection. + + Arguments: + communi_token: security token used for access + see /page/integration/tab/rest within communi as admin + communi_server: REST endpoint of the server + https://api.communiapp.de/rest by default + communi_appid: app ID of the communi instance to be used + see /page/integration/tab/rest within communi as admin. """ super().__init__() self.communi_server = communi_server @@ -36,16 +45,22 @@ def __init__(self, communi_server, communi_token, communi_appid): logger.debug("Instance initialized") - def __str__(self): - """Default print option for the class - :return: + def __str__(self) -> str: + """Default print option for the class. + + Returns: + String with basic information about the instance. """ - text = f"This is a Communi API instance connected to {self.communi_server} with CommuniApp {self.communi_appid}" - return text + return ( + "This is a Communi API instance connected to" + f" {self.communi_server} with CommuniApp {self.communi_appid}" + ) + + def login(self) -> dict | bool: + """Method used for login (with token, server stored in instance). - def login(self): - """Method used for login (with token, server stored in instance) - :return: either response content or False if unsucessful + Returns: + either response content or False if unsucessful. """ url = self.communi_server + "/login" self.session.headers["X-Authorization"] = "Bearer " + self.communi_token @@ -56,11 +71,12 @@ def login(self): self.user_id = response_content["id"] logger.debug("Login with user ID:%s - success", self.user_id) - groups = self.getGroups() + groups = self.get_groups() if groups: return response_content logger.warning( - "Login with App-ID:%s did not return groups - either APP-ID wrong or empty app", + "Login with App-ID:%s did not return groups -" + " either APP-ID wrong or empty app", self.communi_appid, ) return False @@ -69,21 +85,32 @@ def login(self): logger.debug("Login failed with %s", response.content) return False - def who_am_i(self): - """Method to request user information associated with the logged in user (id stored upon successful login) - This can be used to test if the user is authorized + def who_am_i(self) -> dict | bool: + """Method to request user information associated with the logged in user. + + (id stored upon successful login) + This can be used to test if the user is authorized. - :return: dict of user OR bool False if not successful + Returns. + dict of user OR bool False if not successful """ if not hasattr(self, "user_id"): return False - return self.getUserList(userId=self.user_id) + return self.get_user_list(userId=self.user_id) + + def get_user_list(self, **kwargs: int) -> list | bool: + """Method that requests the list of all users. + + from Communi and optionally aplies filter by ID. + + Arguments: + kwargs: keyword arguments - def getUserList(self, **kwargs): - """Method that requests the list of all users from Communi and optionally aplies filter by ID - :param kwargs: keyword arguments - :keyword userId: user Id to filter by - :return: list of users or False if unsuccesful + Keyword: + userId: user Id to filter by + + Returns: + list of users or False if unsuccesful. """ url = self.communi_server + "/user" # +'?communiApp=2406&loadStatus=1' params = {"communiApp": self.communi_appid, "loadStatus": 1} @@ -102,12 +129,101 @@ def getUserList(self, **kwargs): ) return False - def getUserGroupList(self, **kwargs): - """Get a list of UserGroup allocations matching respecting optional id and group id filter - :param kwargs: - :keyword group: group ID for filter - :keyword user: user ID for filter - :return: list of UserGroup allocations + def get_user(self, user_id: int) -> dict | bool: + """Request a single user by ID. + + Arguments: user_id: ID of the user to be requested + Returns: dict of user information or False if not successful. + """ + url = self.communi_server + f"/user/{user_id}" + params = {"communiApp": self.communi_appid, "loadStatus": 1} + + response = self.session.get(url=url, params=params) + if response.status_code == requests.codes.ok: + response_content = json.loads(response.content) + if isinstance(response_content, dict) and response_content: + logger.debug("Fetched user %s successful", user_id) + return response_content + logger.warning("User %s response empty or invalid", user_id) + return False + logger.debug( + "get_user failed with code %s and message %s", + response.status_code, + response.content, + ) + return False + + def update_user(self, user_id: int, data: dict) -> dict | bool: + """Update a user via PUT using the Communi User API. + + Following API documentation only own user can be updated! + + Arguments: + user_id: ID of the user to be updated + data: dict with user information to be updated. + Must include all required fields. + + Returns: + dict of updated user information or False if not successful. + """ + url = self.communi_server + f"/user/{user_id}" + + payload = dict(data) + payload["id"] = user_id + payload["communiApp"] = self.communi_appid + + response = self.session.put(url=url, json=payload) + if response.status_code == requests.codes.ok: + response_content = json.loads(response.content) + if isinstance(response_content, dict) and response_content: + logger.debug("Updated user %s successful", user_id) + return response_content + logger.warning( + "Update of user %s returned empty or invalid response", + user_id, + ) + return False + logger.debug( + "update_user failed with code %s and message %s", + response.status_code, + response.content, + ) + return False + + def delete_user(self, user_id: int) -> bool: + """Delete a user by ID. + + No test implemented because no create method exists in API + + Arguments: + user_id: ID of the user to be deleted + Returns: + True if deletion successful, False otherwise. + """ + url = self.communi_server + f"/user/{user_id}" + + response = self.session.delete(url) + if response.status_code == requests.codes.ok: + response_content = json.loads(response.content) + if len(response_content) == 0: + logger.debug("Deleted user %s successful", user_id) + return True + logger.debug("Deleting user %s failed with %s", user_id, response.content) + return False + + def get_user_group_list(self, **kwargs: int) -> list | bool: + """Get a list of UserGroup allocations matching. + + respecting optional id and group id filter. + + Arguments: + kwargs: keyword arguments + + Keyword: + group: group ID for filter + user: user ID for filter + Returns: + list of UserGroup allocations. """ url = self.communi_server + "/UserGroup" params = {"loadStatus": True, "communiApp": self.communi_appid} @@ -133,15 +249,22 @@ def getUserGroupList(self, **kwargs): ) return False - def createGroup( - self, title="", description="", access_type_open=False, hasGroupChat=True - ): - """Method which creates a new group in Communi - :param title: Name of the group - :param description: Description for the group - :param access_type_open: boolean set to true if open to everyone - :param hasGroupChat: boolean set to true if chat should exist - :return: response for group creation from communi or false if not successful + def create_group( + self, + title: str = "", + description: str = "", + access_type_open: bool = False, # noqa: FBT001, FBT002 + has_goup_chat: bool = True, # noqa: FBT001, FBT002 + ) -> dict | bool: + """Method which creates a new group in Communi. + + Arguments: + title: Name of the group + description: Description for the group + access_type_open: boolean set to true if open to everyone + has_goup_chat: boolean set to true if chat should exist + Returns: + response for group creation from communi or false if not successful. """ url = self.communi_server + "/group" data = { @@ -149,7 +272,7 @@ def createGroup( "description": description, "type": "2", "accessType": "2" if access_type_open else "3", - "hasGroupChat": hasGroupChat, + "hasGroupChat": has_goup_chat, "communiApp": self.communi_appid, } @@ -165,12 +288,17 @@ def createGroup( logger.debug("Creating group failed with %s", response.content) return False - def getGroups(self, **kwargs): - """Get a list of groups matching either any or keyword specified criteria - :param kwargs: - :keyword id: get only group with matching id - :keyword name: get only group with matching name - :return: list of groups or single group if filtered + def get_groups(self, **kwargs: int | str) -> list | dict | bool: + """Get a list of groups matching either any or keyword specified criteria. + + Arguments: + kwargs: keyword arguments + + Keywords: + id: get only group with matching id + name: get only group with matching name + Returns: + list of groups or single group if filtered. """ url = self.communi_server + "/group" params = {"loadStatus": True, "communiApp": self.communi_appid} @@ -198,53 +326,71 @@ def getGroups(self, **kwargs): logger.debug("Requesting group failed with %s", response.content) return False - def deleteGroup(self, **kwargs): - """Delete a groups matching keyword specified criteria - :param kwargs: id = groupID (primary filter) OR name = groupName (without - :return: True if group does not exist at end of function + def delete_group(self, **kwargs: int | str) -> bool: + """Delete a groups matching keyword specified criteria. + + Arguments: + kwargs: keyword arguments + + Keywords: + id: groupID (primary filter) OR name = groupName (without + + Returns: + True if group does not exist at end of function. """ by_name = "name" in kwargs if not (by_name or "id" in kwargs) and len(kwargs.keys() == 1): logger.warning("Problem with keywords %s in deleteGroupd", kwargs) else: - id = ( + group_id = ( kwargs["id"] if "id" in kwargs - else self.getGroups(name=kwargs["name"])["id"] + else self.get_groups(name=kwargs["name"])["id"] ) - url = self.communi_server + "/group/" + str(id) + url = self.communi_server + "/group/" + str(group_id) response = self.session.delete(url) if response.status_code == requests.codes.ok: response_content = json.loads(response.content) if len(response_content) == 0: - logger.debug("Deleted group%s?", id) + logger.debug("Deleted group%s?", group_id) return True - else: - logger.debug("Deleting group failed with %s", response.content) - return False - def changeUserGroup(self, userId, groupId, add_user=True): - """Function to add or remove a user from a group - Be aware that there might be a few seconds delay before changes are reflected in the app + logger.debug("Deleting group failed with %s", response.content) + return False + + def change_user_group( + self, + user_id: int, + group_id: int, + add_user: bool = True, # noqa: FBT001, FBT002 + ) -> bool: + """Function to add or remove a user from a group. - :param userId: user specific id - :param groupId: group specific id- either from get groups or e.g. from groups detail page - :param add_user: boolean if user should be added (or removed if false) - :return: ??? + Be aware that there might be a few seconds delay + before changes are reflected in the app. + + Arguments: + user_id: user specific id + group_id: group specific id- either from + get groups or e.g. from groups detail page + add_user: boolean if user should be added (or removed if false) + + Returns: + ??? """ - url = self.communi_server + f"/UserGroup/{userId}-{groupId}" + url = self.communi_server + f"/UserGroup/{user_id}-{group_id}" data = { "roleId": 40, - "createdOn": str(datetime.now()), + "createdOn": str(datetime.now()), # noqa: DTZ005 "status": 2 if add_user else 4, - "user": userId, - "group": groupId, - "id": f"{userId}-{groupId}", + "user": user_id, + "group": group_id, + "id": f"{user_id}-{group_id}", "_rls": 1, "_loadStatus": 10, "valid": True, @@ -254,38 +400,41 @@ def changeUserGroup(self, userId, groupId, add_user=True): if response.status_code == requests.codes.ok: response_content = json.loads(response.content) - if "error" not in response_content.keys(): - if "valid" in response_content.keys(): + if "error" not in response_content: + if "valid" in response_content: logger.debug( - "Changed permissions of user %s on group %s?", userId, groupId + "Changed permissions of user %s on group %s?", user_id, group_id ) return response_content["valid"] return False return False logger.debug( "Changing assignment on user %s with group %s failed with %s", - userId, - groupId, + user_id, + group_id, response.content, ) return False - def message(self, groupId, text): - """Posts a chat message into a communi group - :param groupId: ID of the group to be used for posting - :param text: The text which should be posted - :return: true if success, false on error + def message(self, group_id: int, text: str) -> bool: + """Posts a chat message into a communi group. + + Arguments: + group_id: ID of the group to be used for posting + text: The text which should be posted + Returns: + true if success, false on error. """ url = self.communi_server + "/message" - data = {"message": text, "conversation": f"group-{groupId}"} + data = {"message": text, "conversation": f"group-{group_id}"} response = self.session.post(url, json=data) if response.status_code == requests.codes.ok: response_content = json.loads(response.content) - if "error" not in response_content.keys(): - if "valid" in response_content.keys(): + if "error" not in response_content: + if "valid" in response_content: logger.debug("Posted message %s", data) return response_content["valid"] return False diff --git a/generate_pyproj.py b/generate_pyproj.py index 57c3c11..e3f9832 100644 --- a/generate_pyproj.py +++ b/generate_pyproj.py @@ -17,10 +17,10 @@ "description": "A python wrapper for use with Communi API", "authors": ["bensteUEM"], "homepage": "https://github.com/bensteUEM/CommuniAPI", - "license": "CC-BY-SA", + "license": "CC-BY-SA-2.0", "readme": "README.md", "dependencies": { - "python": "^3.10", + "python": "^3.11", "churchtools-api": { "git": "https://github.com/bensteUEM/ChurchToolsAPI.git", "rev": "main", @@ -29,13 +29,13 @@ "group": { "dev": { "dependencies": { - "poetry": "^1.6.1", + "poetry": "^2.3.3", "tomli_w": "^1.0.0", - "wheel": "^0.41.2", - "setuptools": "^66.1.1", + "wheel": "^0.46.3", + "setuptools": "^82.0.1", "autopep8": "^2.0.4", "pytest": "^8.3.4", - "ruff": "^0.9.1", + "ruff": "^0.15.5", "ipykernel": "^6.29.5", } } diff --git a/pyproject.toml b/pyproject.toml index c90bb4e..34384b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [tool.poetry] name = "communi-api" -version = "1.2.1" +version = "1.3.0" description = "A python wrapper for use with Communi API" authors = [ "bensteUEM", ] homepage = "https://github.com/bensteUEM/CommuniAPI" -license = "CC-BY-SA" +license = "CC-BY-SA-2.0" readme = "README.md" [tool.poetry.dependencies] @@ -17,15 +17,14 @@ git = "https://github.com/bensteUEM/ChurchToolsAPI.git" rev = "main" [tool.poetry.group.dev.dependencies] -poetry = "^1.6.1" +poetry = "^2.3.3" tomli_w = "^1.0.0" -wheel = "^0.41.2" -setuptools = "^66.1.1" +wheel = "^0.46.3" +setuptools = "^82.0.1" autopep8 = "^2.0.4" pytest = "^8.3.4" -ipykernel = "^6.29.5" ruff = "^0.15.5" -pandas = "^3.0.1" +ipykernel = "^6.29.5" [tool.ruff] exclude = [ diff --git a/tests/test_communi_api.py b/tests/test_communi_api.py index 42e2c0a..1c48987 100644 --- a/tests/test_communi_api.py +++ b/tests/test_communi_api.py @@ -1,11 +1,16 @@ +"""This file contains tests for the CommuniAPI class. + +It uses pytest as testing framework. +""" + import json import logging import logging.config import os -import unittest from datetime import datetime, timezone from pathlib import Path +import pytest from churchtools_api.churchtools_api import ChurchToolsApi from communi_api.churchToolsActions import create_event_chats, delete_event_chats @@ -22,7 +27,9 @@ logging.config.dictConfig(config=logging_config) -class TestsCommuniApp(): +class TestsCommuniApp: + """TestClass for CommuniAPI.""" + def setup_class(self) -> None: """Common setup with testing provides api connections.""" if "COMMUNI_TOKEN" in os.environ: @@ -33,12 +40,12 @@ def setup_class(self) -> None: self.CT_DOMAIN = os.environ["CT_DOMAIN"] logger.info("using connection details provided with ENV variables") else: - from secure.config import communiAppId, rest_server, token + from secure.config import communiAppId, rest_server, token # noqa: PLC0415 self.COMMUNI_TOKEN = token self.COMMUNI_SERVER = rest_server self.COMMUNI_APPID = communiAppId - from secure.config import ct_domain, ct_token + from secure.config import ct_domain, ct_token # noqa: PLC0415 self.CT_TOKEN = ct_token self.CT_DOMAIN = ct_domain @@ -60,10 +67,10 @@ def test_config(self) -> None: assert self.api.communi_appid != 0, ( "Please configure a propper App ID in config.py" ) - assert self.api.communi_token != "ENTER-YOUR-TOKEN-HERE", ( + assert self.api.communi_token != "ENTER-YOUR-TOKEN-HERE", ( # noqa: S105 "Please change the default token in config.py" ) - assert self.api.communi_server == "https://api.communiapp.de/rest", ( + assert self.api.communi_server == "https://api.communiapp.de/rest/", ( "Are you sure your server is correct?" ) @@ -76,14 +83,14 @@ def test_login(self) -> None: def test_login_wrong_app(self) -> None: """Check incorrect logins.""" - temp_COMMUNI_APPID = self.api.communi_appid + temp_communi_appid = self.api.communi_appid self.api.communi_appid = 9999 if self.api.session is not None: self.api.session.close() result = self.api.login() assert not result - self.api.communi_appid = temp_COMMUNI_APPID + self.api.communi_appid = temp_communi_appid if self.api.session is not None: self.api.session.close() result = self.api.login() @@ -113,113 +120,138 @@ def test_who_am_i(self) -> None: assert result.get("lastName") assert result.get("mailadresse") - def test_getUserList(self) -> None: + def test_get_user_list(self) -> None: """Check getUserList API. - IMPORTANT - This test method and the parameters used depend on the target system! - userId = 28057 => Admin + IMPORTANT - This test method and the parameters used + depend on the target system! + user_id = 28057 => Admin """ - userId = 28057 + user_id = 28057 - result = self.api.getUserList() + result = self.api.get_user_list() assert len(result) > 0 - result = self.api.getUserList(userId=userId) + result = self.api.get_user_list(userId=user_id) assert "id" in result - def test_getGroups(self) -> None: + def test_get_user(self) -> None: + """Check get_user API. + + IMPORTANT - This test method and the parameters used + depend on the target system! + user_id = 28057 => Admin + """ + user_id = 28057 + + result = self.api.get_user(user_id) + assert isinstance(result, dict) + assert result.get("id") == user_id + assert result.get("firstName") + assert result.get("lastName") + assert result.get("mailadresse") + + def test_get_groups(self) -> None: """Check getGroups API. - IMPORTANT - This test method and the parameters used depend on the target system! + IMPORTANT - This test method and the parameters used + depend on the target system! """ - result = self.api.getGroups() + result = self.api.get_groups() assert len(result) > 0 - result = self.api.getGroups(id=7525)["title"] + result = self.api.get_groups(id=7525)["title"] test_title = "Evang. Kirche Baiersbronn" assert result == test_title - result = self.api.getGroups(name="Admins und Moderatoren")["id"] - test_id = 7676 + result = self.api.get_groups(name="APITest")["id"] + test_id = 89686 assert result == test_id - def test_createDeleteGroup(self) -> None: + def test_create_delete_group(self) -> None: """Check create/delete Group APIs. Test that creates two new groups and deletes them one by id and one by name - IMPORTANT - This test method and the parameters used depend on the target system! + IMPORTANT - This test method and the parameters used + depend on the target system! :return: """ description = ( - "created by test_deleteGroup() function should be auto deleted after successful test\n" + "created by test_deleteGroup() function should be" + " auto deleted after successful test\n" "feel free to delete this group manually if it still exists" ) - group1 = self.api.createGroup("Test1", description) - group2 = self.api.createGroup("Test2", description) - group2 = self.api.getGroups(id=group2["id"]) + group1 = self.api.create_group("Test1", description) + group2 = self.api.create_group("Test2", description) + group2 = self.api.get_groups(id=group2["id"]) - result = self.api.deleteGroup(id=group1["id"]) + result = self.api.delete_group(id=group1["id"]) assert result - result2 = self.api.deleteGroup(name=group2["title"]) + result2 = self.api.delete_group(name=group2["title"]) assert result2 - def test_userGroupList(self) -> None: + def test_user_group_list(self) -> None: """Check userGroupList. - IMPORTANT - This test method and the parameters used depend on the target system! + IMPORTANT - This test method and the parameters used + depend on the target system! :return: """ user_id = 28057 - group_id = self.api.createGroup( + group_id = self.api.create_group( "_test_userGroupList ", "If this group exists some test failed - please delete", )["id"] - self.api.changeUserGroup(userId=user_id, groupId=group_id, add_user=True) + self.api.change_user_group(user_id=user_id, group_id=group_id, add_user=True) - result = self.api.getUserGroupList() + result = self.api.get_user_group_list() assert len(result) > 0 - result = self.api.getUserGroupList(user=user_id) + result = self.api.get_user_group_list(user=user_id) assert len(result) > 1 - result = self.api.getUserGroupList(group=group_id) + result = self.api.get_user_group_list(group=group_id) assert len(result) > 1 - result = self.api.getUserGroupList(group=group_id, user=user_id) + result = self.api.get_user_group_list(group=group_id, user=user_id) assert len(result) == 1 assert result[0]["user"] == user_id assert result[0]["group"] == group_id - result = self.api.deleteGroup(id=group_id) + result = self.api.delete_group(id=group_id) assert result - def test_changeUserGroup(self) -> None: - """Check changeUserGroup API. + def test_change_user_group(self) -> None: + """Check change_user_group API. Tries to add and remove a user from a test group - IMPORTANT - This test method and the parameters used depend on the target system! + IMPORTANT - This test method and the parameters used + depend on the target system! Testing with userID 28057 (admin) and groupID 21037 (_TEST Gruppe - UserAdd) """ user_id = 28057 - group_id = self.api.createGroup( + group_id = self.api.create_group( "_test_changeUserGroup ", "If this group exists some test failed - please delete", )["id"] - assert not self.api.changeUserGroup(0, 0, add_user=False) - assert self.api.changeUserGroup(user_id, group_id, add_user=True) + assert not self.api.change_user_group(0, 0, add_user=False) + assert self.api.change_user_group(user_id, group_id, add_user=True) - test_result = self.api.getUserGroupList(user=user_id, group=group_id) + test_result = self.api.get_user_group_list(user=user_id, group=group_id) assert len(test_result) == 1 - assert test_result[0]["status"] == 2 + # status 2 means user is in group, status 4 means user is not in group + EXPECTED_STATUS = 2 # noqa: N806 + assert test_result[0]["status"] == EXPECTED_STATUS - assert not self.api.changeUserGroup(0, 0, add_user=False) - assert self.api.changeUserGroup(user_id, group_id, add_user=False) - test_result = self.api.getUserGroupList(user=user_id, group=group_id) - assert test_result[0]["status"] == 4 + assert not self.api.change_user_group(0, 0, add_user=False) + assert self.api.change_user_group(user_id, group_id, add_user=False) + test_result = self.api.get_user_group_list(user=user_id, group=group_id) + EXPECTED_STATUS = 4 # noqa: N806 + assert test_result[0]["status"] == EXPECTED_STATUS - result = self.api.deleteGroup(id=group_id) + result = self.api.delete_group(id=group_id) assert result def test_message(self) -> None: @@ -228,25 +260,31 @@ def test_message(self) -> None: Attempts to post a chat message text into a new test group and deletes it afterwards. """ - group_id = self.api.createGroup( + group_id = self.api.create_group( "_test_message ", "If this group exists some test failed - please delete" )["id"] timestamp = datetime.now(tz=timezone.utc) result = self.api.message( - groupId=group_id, text=f"Hello World from test_postInGroup - on {timestamp}" + group_id=group_id, + text=f"Hello World from test_postInGroup - on {timestamp}", ) assert result - result = self.api.deleteGroup(id=group_id) + result = self.api.delete_group(id=group_id) assert result + @pytest.mark.skip( + reason="Depends on CT system and was not tested with more recent versions" + ) def test_create_event_chats(self) -> None: """Check create_event_chat functions. Testing method to check if event creation and user update works - IMPORTANT - This test method and the parameters used depend on the target system! - event ID 2626 on elkw1610.krz.tools represents a rest event with multiple services + IMPORTANT - This test method and the parameters used + depend on the target system! + event ID 2626 on elkw1610.krz.tools + represents a rest event with multiple services :return: """ test_event_ids = [2626] @@ -264,7 +302,7 @@ def test_recommendation(self) -> None: Attempts to post a recommendation into a new test group and deletes it afterwards. """ - group_id = self.api.createGroup( + group_id = self.api.create_group( "_test_recommendation ", "If this group exists some test failed - please delete", )["id"] @@ -281,5 +319,5 @@ def test_recommendation(self) -> None: ) assert result - result = self.api.deleteGroup(id=group_id) + result = self.api.delete_group(id=group_id) assert result diff --git a/version.py b/version.py index e29c86e..42fcd64 100644 --- a/version.py +++ b/version.py @@ -1,6 +1,6 @@ import os -VERSION = "1.2.1" +VERSION = "1.3.0" __version__ = VERSION if __name__ == "__main__":