From ddbc8beb359ce1dcc56175e76ce7dac22fc89e3d Mon Sep 17 00:00:00 2001
From: Christopher Bohn <bohn@unl.edu>
Date: Wed, 15 Jan 2020 09:57:34 -0600
Subject: [PATCH] Added static typing to classes that interface directly with
 Gitlab API

---
 api/canvas_classes.py |  21 +++++-
 api/gitlab_classes.py | 171 ++++++++++++++++++++++--------------------
 2 files changed, 108 insertions(+), 84 deletions(-)

diff --git a/api/canvas_classes.py b/api/canvas_classes.py
index 1963bde..c05ab63 100644
--- a/api/canvas_classes.py
+++ b/api/canvas_classes.py
@@ -1,16 +1,18 @@
+from typing import ClassVar, Dict, Iterable, List, Union
+
 from canvasapi import Canvas
 from canvasapi.assignment import Assignment, AssignmentGroup
+from canvasapi.course import Course
 from canvasapi.group import Group, GroupCategory
 from canvasapi.quiz import QuizSubmission, QuizSubmissionQuestion
 from canvasapi.submission import Submission
 from canvasapi.user import User
-from config import Config
 
-from typing import Iterable, List, Union, Dict
+from config import Config
 
 
 class CanvasSession:
-    __instance = None
+    __instance: ClassVar[Canvas] = None
 
     @staticmethod
     def get_session() -> Canvas:
@@ -23,6 +25,8 @@ class CanvasSession:
 
 
 class CanvasUser:
+    canvas_user: User
+
     # @overload
     # def __init__(self, user: int):  # by NUID
     #     super().__init__()
@@ -123,6 +127,8 @@ class CanvasUser:
 
 
 class CanvasUserGroup:
+    canvas_user_group: Group
+
     def __init__(self, group: Group):
         super().__init__()
         self.canvas_user_group = group
@@ -206,6 +212,8 @@ class CanvasUserGroup:
 
 
 class CanvasUserGroupSet:  # aka, group_category
+    canvas_group_category: GroupCategory
+
     def __init__(self, group_category: GroupCategory):
         super().__init__()
         self.canvas_group_category = group_category
@@ -286,6 +294,8 @@ class CanvasUserGroupSet:  # aka, group_category
 
 
 class CanvasAssignment:
+    canvas_assignment: Assignment
+
     def __init__(self, assignment: Assignment):
         super().__init__()
         self.canvas_assignment = assignment
@@ -574,7 +584,8 @@ class CanvasAssignment:
 
 
 class CanvasAssignmentGroup:
-    # from canvasapi.assignment import Assignment
+    canvas_assignment_group: AssignmentGroup
+
     def __init__(self, group: AssignmentGroup):
         super().__init__()
         self.canvas_assignment_group = group
@@ -621,6 +632,8 @@ class CanvasAssignmentGroup:
 
 
 class CanvasCourse:
+    canvas_course: Course
+
     def __init__(self, course_id: int):
         self.canvas_course = CanvasSession.get_session().get_course(course_id)
 
diff --git a/api/gitlab_classes.py b/api/gitlab_classes.py
index 9b12b76..f129c66 100644
--- a/api/gitlab_classes.py
+++ b/api/gitlab_classes.py
@@ -1,22 +1,26 @@
 from datetime import datetime
+from typing import ClassVar, Dict, Iterable, List, Set, Union
 
-import gitlab
+from gitlab import Gitlab, MAINTAINER_ACCESS
+from gitlab.v4.objects import Issue, Project, ProjectCommit, User
 
 from config import Config
 
 
 class GitlabSession:
-    __instance = None
+    __instance: ClassVar[Gitlab] = None
 
     @staticmethod
-    def get_session():
+    def get_session() -> Gitlab:
         if GitlabSession.__instance is None:
-            GitlabSession.__instance = gitlab.Gitlab(Config.gitlab_url, private_token=Config.gitlab_api_key)
+            GitlabSession.__instance = Gitlab(Config.gitlab_url, private_token=Config.gitlab_api_key)
         return GitlabSession.__instance
 
 
 class GitlabUser:
-    def __init__(self, user):
+    git_user: User
+
+    def __init__(self, user: Union[int, str, User]):
         """
         Creates a User object, populating the backing git_user instance with the appropriate gitlab.User object
         :param user: must be either a gitlab.User object, an integer representing the user ID, or a string containing
@@ -30,34 +34,36 @@ class GitlabUser:
         else:
             self.git_user = user
 
-    def get_user_id(self):
+    def get_user_id(self) -> int:
         return self.git_user.id
 
-    def get_name(self):
+    def get_name(self) -> str:
         return self.git_user.name
 
-    def get_username(self):
+    def get_username(self) -> str:
         return self.git_user.username
 
-    def get_site(self):
+    def get_site(self) -> str:
         return self.git_user.web_url
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         username = self.get_username()
         return f'@{username}'
 
-    def __eq__(self, other):
-        if isinstance(other, GitlabUser):
-            return self.get_username() == other.get_username()
-        else:
-            return False
+    def __eq__(self, other: "GitlabUser") -> bool:
+        # if isinstance(other, GitlabUser):
+        return self.get_username() == other.get_username()
+        # else:
+        # return False
 
-    def __ne__(self, other):
+    def __ne__(self, other: "GitlabUser") -> bool:
         return not self.__eq__(other)
 
 
 class GitlabIssue:
-    def __init__(self, issue):
+    git_issue: Issue
+
+    def __init__(self, issue: Issue):
         """
         Creates an Issue object, populating the backing git_issue instance with the appropriate gitlab.Issue object
         :param issue: must be a gitlab.Issue object
@@ -65,60 +71,60 @@ class GitlabIssue:
         super().__init__()
         self.git_issue = issue
 
-    def get_universal_issue_id(self):
+    def get_universal_issue_id(self) -> int:
         """
         :return: universally-unique identifier
         """
         return self.git_issue.id
 
-    def get_project_issue_id(self):
+    def get_project_issue_id(self) -> int:
         """
         :return: project-unique identifier
         """
         return self.git_issue.iid
 
-    def get_title(self):
+    def get_title(self) -> str:
         """
         :return: issue's title
         """
         return self.git_issue.title
 
-    def get_description(self):
+    def get_description(self) -> str:
         """
         :return: issue's description
         """
         return self.git_issue.description
 
-    def get_state(self):
+    def get_state(self) -> str:
         """
         :return: opened or closed
         """
         return self.git_issue.state
 
-    def get_created_at(self):
+    def get_created_at(self) -> datetime:
         """
         :return: an "aware" datetime object representing the creation date/time
         """
-        created_at = self.git_issue.created_at
+        created_at: str = self.git_issue.created_at
         if created_at[-1] in ('z', 'Z'):  # Didn't encounter this problem with created_at
             created_at = created_at[:-1] + '+00:00'
         return datetime.fromisoformat(created_at)
 
-    def get_updated_at(self):
+    def get_updated_at(self) -> datetime:
         """
         :return: an "aware" datetime object representing the last date/time the issue was updated
         """
-        updated_at = self.git_issue.updated_at
+        updated_at: str = self.git_issue.updated_at
         if updated_at[-1] in ('z', 'Z'):  # Didn't encounter this problem with updated_at
             updated_at = updated_at[:-1] + '+00:00'
         return datetime.fromisoformat(updated_at)
 
-    def get_closed_at(self):
+    def get_closed_at(self) -> Union[datetime, None]:
         """
         :return: an "aware" datetime object representing the last date/time the issue was closed, or None if the issue
         is open
         """
-        closed_at = self.git_issue.closed_at
+        closed_at: str = self.git_issue.closed_at
         if closed_at is None:
             return None
         else:
@@ -126,20 +132,20 @@ class GitlabIssue:
                 closed_at = closed_at[:-1] + '+00:00'
             return datetime.fromisoformat(closed_at)
 
-    def get_labels(self):
+    def get_labels(self) -> Set[str]:
         """
         :return: set of label names
         """
         return set(self.git_issue.labels)
         # return self.git_issue.labels.list(all=True)
 
-    def get_page(self):
+    def get_page(self) -> str:
         """
         :return: HTTPS URL to issue's page
         """
         return self.git_issue.web_url
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         issue_number = self.get_project_issue_id()
         title = self.get_title()
         return f'{issue_number}. {title}'
@@ -171,34 +177,36 @@ class GitlabIssue:
 
 
 class GitlabCommit:
+    gitlab_commit: ProjectCommit
+
     # noinspection PyShadowingNames
-    def __init__(self, commit):
+    def __init__(self, commit: ProjectCommit):
         super().__init__()
         self.gitlab_commit = commit
 
-    def get_author(self):
+    def get_author(self) -> Dict[str, str]:
         return {'name': self.gitlab_commit.author_name, 'email': self.gitlab_commit.author_email}
 
-    def get_timestamp(self):
+    def get_timestamp(self) -> str:  # TODO: is a string (vs a datetime) really what we want?
         return self.gitlab_commit.created_at
 
-    def get_message(self):
+    def get_message(self) -> str:
         return self.gitlab_commit.message
 
-    def is_merge(self):
+    def is_merge(self) -> bool:
         return len(self.gitlab_commit.parent_ids) > 1
 
     # noinspection PyShadowingNames
-    def get_diffs(self):
-        diffs = []
-        gitlab_diffs = self.gitlab_commit.diff()
+    def get_diffs(self) -> List[Dict[str, Union[str, int]]]:
+        diffs: List[Dict[str, Union[str, int]]] = []
+        gitlab_diffs: List[Dict[str, str]] = self.gitlab_commit.diff()
         for diff in gitlab_diffs:
             diffs.append({'file': diff['new_path'], 'text': diff['diff'],
                           '+': diff['diff'].count('\n+'), '-': diff['diff'].count('\n-')})
         return diffs
 
     # noinspection PyShadowingNames
-    def get_diff_size(self):
+    def get_diff_size(self) -> int:
         insertions = 0
         deletions = 0
         if not self.is_merge():
@@ -229,7 +237,9 @@ class GitlabCommit:
 
 
 class GitlabProject:
-    def __init__(self, project):
+    git_project: Project
+
+    def __init__(self, project: Union[int, str, Project]):
         """
         Creates a Project object, populating the backing git_project instance with the appropriate gitlab.Project object
         :param project: must be either a gitlab.Project object, an integer representing the project ID, or a string
@@ -245,143 +255,144 @@ class GitlabProject:
             self.git_project = GitlabSession.get_session().projects.get(project.id)
 
     @staticmethod
-    def get_projects_by_group(group):
+    def get_projects_by_group(group: Union[int, str]) -> List["GitlabProject"]:
         """
         :param group: must be either an integer representing the group ID, or a string containing the group's namespace
         :return: list of projects in the specified group
         """
         if isinstance(group, int):  # by group id
-            gitlab_projects = GitlabSession.get_session().groups.get(group).projects.list(all=True)
+            gitlab_projects: Iterable[Project] = GitlabSession.get_session().groups.get(group).projects.list(all=True)
         else:  # isinstance(group, str):        # by path
             gitlab_projects = GitlabSession.get_session().groups.get(group).projects.list(all=True)
-        projects = []
+        projects: List[GitlabProject] = []
         for project in gitlab_projects:
             projects.append(GitlabProject(project))
         return projects
 
     @staticmethod
-    def get_projects_by_keyword(search_term):
-        gitlab_projects = GitlabSession.get_session().projects.list(search=search_term, all=True)
-        projects = []
+    def get_projects_by_keyword(search_term: str) -> List["GitlabProject"]:
+        gitlab_projects: Iterable[Project] = GitlabSession.get_session().projects.list(search=search_term, all=True)
+        projects: List[GitlabProject] = []
         for project in gitlab_projects:
             projects.append(GitlabProject(project))
         return projects
 
     @staticmethod
-    def create_project(project_name):
+    def create_project(project_name: str) -> "GitlabProject":
         return GitlabProject(GitlabSession.get_session().projects.create({'name': project_name}))
 
     @staticmethod
-    def create_project_in_group(group_name, project_name):
+    def create_project_in_group(group_name: str, project_name: str) -> "GitlabProject":
         group_id = GitlabSession.get_session().groups.get(group_name).id
         return GitlabProject(GitlabSession.get_session().projects.create({'name': project_name,
                                                                           'namespace_id': group_id}))
 
-    def get_project_id(self):
+    def get_project_id(self) -> int:
         return self.git_project.id
 
-    def get_name(self):
+    def get_name(self) -> str:
         """
         :return: project name without namespace
         """
         return self.git_project.name
 
-    def get_name_with_namespace(self):
+    def get_name_with_namespace(self) -> str:
         """
         :return: project name with namespace, spaces around slashes
         """
         return self.git_project.name_with_namespace
 
-    def get_path(self):
+    def get_path(self) -> str:
         """
         :return: path without namespace (may differ from name if name has spaces)
         """
         return self.git_project.path
 
-    def get_path_with_namespace(self):
+    def get_path_with_namespace(self) -> str:
         """
         :return: path with namespace, no spaces around slashes
         """
         return self.git_project.path_with_namespace
 
-    def get_cloning_url(self):
+    def get_cloning_url(self) -> str:
         """
         :return: SSH URL to clone repository
         """
         return self.git_project.ssh_url_to_repo
 
-    def get_site(self):
+    def get_site(self) -> str:
         """
         :return: HTTPS URL to git site
         """
         return self.git_project.web_url
 
-    def get_readme_url(self):
+    def get_readme_url(self) -> str:
         """
         :return: HTTPS URL to README.md
         """
         return self.git_project.readme_url
 
-    def get_visibility(self):
+    def get_visibility(self) -> str:
         """
         :return: 'private', etc.
         """
         return self.git_project.visibility
 
-    def get_creator(self):
+    def get_creator(self) -> GitlabUser:
         """
         :return: User object backed by the gitlab.User object representing the user who created the repo
         """
         return GitlabUser(self.git_project.creator_id)
 
-    def get_created_at(self):
+    def get_created_at(self) -> datetime:
         """
         :return: an "aware" datetime object representing the creation date/time
         """
-        created_at = self.git_project.created_at
+        created_at: str = self.git_project.created_at
         if created_at[-1] in ('z', 'Z'):
             created_at = created_at[:-1] + '+00:00'
         return datetime.fromisoformat(created_at)
 
-    def get_users(self):
+    def get_users(self) -> List[GitlabUser]:
         """
         :return: List of User objects representing the project's members (not including inherited members)
         """
-        gitlab_users = self.git_project.members.list(all=True)
-        users = []
+        gitlab_users: Iterable[User] = self.git_project.members.list(all=True)
+        users: List[GitlabUser] = []
         for user in gitlab_users:
             users.append(GitlabUser(user))
         return users
 
-    def get_all_users(self):
+    def get_all_users(self) -> List[GitlabUser]:
         """
         :return: List of User objects representing all of the project's members (including inherited members)
         """
-        gitlab_users = self.git_project.members.all(all=True)
-        users = []
+        gitlab_users: Iterable[User] = self.git_project.members.all(all=True)
+        users: List[GitlabUser] = []
         for user in gitlab_users:
             users.append(GitlabUser(user))
         return users
 
-    def add_user_as_maintainer(self, user):
-        self.git_project.members.create({'user_id': user.get_user_id(), 'access_level': gitlab.MAINTAINER_ACCESS})
+    def add_user_as_maintainer(self, user: GitlabUser) -> None:
+        self.git_project.members.create({'user_id': user.get_user_id(), 'access_level': MAINTAINER_ACCESS})
 
-    def get_issues(self):
+    def get_issues(self) -> List[GitlabIssue]:
         """
         :return: List of Issue objects representing project's issues, sorted by creation date
         """
-        gitlab_issues = self.git_project.issues.list(order_by='created_at', sort='asc', all=True)
-        issues = []
+        gitlab_issues: Iterable[Issue] = self.git_project.issues.list(order_by='created_at', sort='asc', all=True)
+        issues: List[GitlabIssue] = []
         for issue in gitlab_issues:
             issues.append(GitlabIssue(issue))
         return issues
 
-    def create_issue(self, title, description):
-        gitlab_issue = self.git_project.issues.create({'title': title, 'description': description})
+    def create_issue(self, title: str, description: str) -> GitlabIssue:
+        gitlab_issue: Issue = self.git_project.issues.create({'title': title, 'description': description})
         return GitlabIssue(gitlab_issue)
 
     # noinspection PyShadowingNames
-    def get_commits(self, branch_name='', after_date='1970-01-01', before_date='9999-12-31'):
+    def get_commits(self, branch_name: str = '', after_date: str = '1970-01-01', before_date: str = '9999-12-31') -> \
+            List[GitlabCommit]:
         """
         :param branch_name: the branch to retrieve commits from; if an empty string (default) then retrieves commits
         from all branches <-- NO, RETRIEVES FROM DEFAULT BRANCH; WILL NEED TO FIX THAT #TODO
@@ -391,7 +402,7 @@ class GitlabProject:
         having no latest-bound
         :return: List of Commit objects representing the project's commits that meet the specified constraints
         """
-        filters = {}
+        filters: Dict[str, str] = {}
         if branch_name != '':
             filters['ref_name'] = branch_name
         if after_date != '1970-01-01':
@@ -399,21 +410,21 @@ class GitlabProject:
         if before_date != '9999-12-31':
             filters['until'] = before_date
         if len(filters) == 0:
-            gitlab_commits = self.git_project.commits.list(all=True)
+            gitlab_commits: Iterable[ProjectCommit] = self.git_project.commits.list(all=True)
         else:
             gitlab_commits = self.git_project.commits.list(all=True, query_parameters=filters)
-        commits = []
+        commits: List[GitlabCommit] = []
         for commit in gitlab_commits:
             commits.append(GitlabCommit(commit))
         return commits
 
-    def get_labels(self):
+    def get_labels(self) -> Set[str]:
         """
         :return: set of label names
         """
         return set(map(lambda label: label.name, self.git_project.labels.list()))
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         return self.get_name_with_namespace()
 
     # other git_project fields:
-- 
GitLab