diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ff3741..68ffb34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,9 @@ - Fix: Rename and organize `clean.py` module into `utils_parse` and `utils_clean` (@lwasser, @willingc, #121) - Fix: Add tests for all utils functions (@lwasser, #122) - Fix: Bug where date_accepted is removed (@lwasser, #129) -- Fix: Refactor all GitHub related methods move to gh_client module (@lwasser, #125) +- Fix: Refactor all issue related GitHub methods to gh_client module (@lwasser, #125) - Add: support for partners and emeritus_editor in contributor model (@lwasser, #133) +- Fix: Refactor all contributor GitHub related methods into gh_client module from contributors module (@lwasser, #125) ## [v0.2.3] - 2024-02-29 diff --git a/pyproject.toml b/pyproject.toml index 928660e..9b5c679 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ version.source = "vcs" build.hooks.vcs.version-file = "src/pyosmeta/_version.py" [tool.hatch.envs.test] -dependencies = ["pytest", "pytest-cov", "coverage[toml]"] +dependencies = ["pytest", "pytest-cov", "coverage[toml]", "pytest-mock"] [tool.hatch.envs.test.scripts] run-coverage = "pytest --cov-config=pyproject.toml --cov=pyosmeta --cov=tests/*" diff --git a/src/pyosmeta/cli/update_contributors.py b/src/pyosmeta/cli/update_contributors.py index 1655fd0..1066a59 100644 --- a/src/pyosmeta/cli/update_contributors.py +++ b/src/pyosmeta/cli/update_contributors.py @@ -6,6 +6,7 @@ from pyosmeta.contributors import ProcessContributors from pyosmeta.file_io import create_paths, load_pickle, open_yml_file +from pyosmeta.github_api import GitHubAPI from pyosmeta.models import PersonModel # TODO - https://stackoverflow.com @@ -62,7 +63,8 @@ def main(): print("Done processing all-contribs") # Create a list of all contributors across repositories - process_contribs = ProcessContributors(json_files) + github_api = GitHubAPI() + process_contribs = ProcessContributors(github_api, json_files) bot_all_contribs = process_contribs.combine_json_data() print("Updating contrib types and searching for new users now") @@ -71,7 +73,7 @@ def main(): # Find and populate data for any new contributors if gh_user not in all_contribs.keys(): print("Missing", gh_user, "Adding them now") - new_contrib = process_contribs.get_user_info(gh_user) + new_contrib = process_contribs.return_user_info(gh_user) new_contrib["date_added"] = datetime.now().strftime("%Y-%m-%d") all_contribs[gh_user] = PersonModel(**new_contrib) @@ -81,7 +83,7 @@ def main(): if update_all: for user in all_contribs.keys(): print("Updating all user info from github", user) - new_gh_data = process_contribs.get_user_info(user) + new_gh_data = process_contribs.return_user_info(user) # TODO: turn this into a small update method existing = all_contribs[user].model_dump() diff --git a/src/pyosmeta/contributors.py b/src/pyosmeta/contributors.py index c2903b9..6362bbd 100644 --- a/src/pyosmeta/contributors.py +++ b/src/pyosmeta/contributors.py @@ -1,10 +1,10 @@ import json -import os import requests from dataclasses import dataclass -from dotenv import load_dotenv -from typing import List, Optional, Tuple +from typing import Any, List, Optional, Tuple + +from .github_api import GitHubAPI @dataclass @@ -12,20 +12,20 @@ class ProcessContributors: """A class that contains some basic methods to support populating and updating contributor data.""" - def __init__(self, json_files: List) -> None: + def __init__(self, github_api: GitHubAPI, json_files: List) -> None: """ Parameters ---------- - + github_api : str + Instantiated instance of a GitHubAPI object json_files : list A list of string objects each of which represents a URL to a JSON file to be parsed - GITHUB_TOKEN : str - A string containing your API token needed to access the github API """ + self.github_api = github_api self.json_files = json_files - # self.GITHUB_TOKEN = GITHUB_TOKEN + self.update_keys = [ "twitter", "website", @@ -52,18 +52,6 @@ def __init__(self, json_files: List) -> None: ], } - def get_token(self) -> str: - """Fetches the GitHub API key from the users environment. If running - local from an .env file. - - Returns - ------- - str - The provided API key in the .env file. - """ - load_dotenv() - return os.environ["GITHUB_TOKEN"] - def check_contrib_type(self, json_file: str): """ Determine the type of contribution the person @@ -94,6 +82,7 @@ def check_contrib_type(self, json_file: str): contrib_type = "community" return contrib_type + # Possibly github it is a get request but it says json path def load_json(self, json_path: str) -> dict: """ Helper function that deserializes a json file to a dict. @@ -153,9 +142,9 @@ def combine_json_data(self) -> dict: print("Oops - can't process", json_file, e) return combined_data - def get_user_info( - self, username: str, aname: Optional[str] = None - ) -> dict: + def return_user_info( + self, gh_handle: str, name: Optional[str] = None + ) -> dict[str, Any]: """ Get a single user's information from their GitHub username using the GitHub API @@ -163,9 +152,9 @@ def get_user_info( Parameters ---------- - username : string + gh_handle : string Github username to retrieve data for - aname : str default=None + name : str default=None A user's name from the contributors.yml file. https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-a-user @@ -174,14 +163,8 @@ def get_user_info( Dict with updated user data grabbed from the GH API """ - url = f"https://api.github.com/users/{username}" - headers = {"Authorization": f"Bearer {self.get_token()}"} - response = requests.get(url, headers=headers) - # TODO: add check here for if credentials are bad - # if message = Bad credentials - response_json = response.json() + response_json = self.github_api.get_user_info(gh_handle, name) - # TODO: make an attribute and call it here? update_keys = { "name": "name", "location": "location", diff --git a/src/pyosmeta/file_io.py b/src/pyosmeta/file_io.py index d747ab5..18a2373 100644 --- a/src/pyosmeta/file_io.py +++ b/src/pyosmeta/file_io.py @@ -29,16 +29,24 @@ def _list_to_dict(a_list: List, a_key: str) -> Dict: def create_paths(repos: Union[list[str], str]) -> Union[list[str], str]: - """ """ + """Construct URLs for .all-contributorsrc file on GitHub for pyos repos. + + We add new contributors to each repo using the all contributors bot. This + generates urls for all of the files across all of our repos where people + contribute to our content and processes. + + Parameters: + ---------- + repos : Union[List[str], str] + A list of GitHub repository names or a single repository name. + + Returns: + ------- + Union[List[str], str] + A list of URLs if `repos` is a list, or a single URL if `repos` is a string. + """ base_url = "https://raw.githubusercontent.com/pyOpenSci/" end_url = "/main/.all-contributorsrc" - repos = [ - "python-package-guide", - "software-peer-review", - "pyopensci.github.io", - "software-review", - "update-web-metadata", - ] if isinstance(repos, list): all_paths = [base_url + repo + end_url for repo in repos] else: diff --git a/src/pyosmeta/github_api.py b/src/pyosmeta/github_api.py index 789d0c8..fb29338 100644 --- a/src/pyosmeta/github_api.py +++ b/src/pyosmeta/github_api.py @@ -15,7 +15,7 @@ import requests from dataclasses import dataclass from dotenv import load_dotenv -from typing import Any +from typing import Any, Optional, Union @dataclass @@ -224,3 +224,34 @@ def get_last_commit(self, repo: str) -> str: date = response[0]["commit"]["author"]["date"] return date + + def get_user_info( + self, gh_handle: str, name: Optional[str] = None + ) -> dict[str, Union[str, Any]]: + """ + Get a single user's information from their GitHub username using the + GitHub API + # https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-the-authenticated-user + + Parameters + ---------- + gh_handle : string + Github username to retrieve data for + name : str default=None + A user's name from the contributors.yml file. + https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-a-user + + Returns + ------- + Dict with updated user data grabbed from the GH API + """ + + url = f"https://api.github.com/users/{gh_handle}" + headers = {"Authorization": f"Bearer {self.get_token()}"} + response = requests.get(url, headers=headers) + + if response.status_code == 401: + raise ValueError( + "Oops, I couldn't authenticate. Please check your token." + ) + return response.json() diff --git a/tests/conftest.py b/tests/conftest.py index 4a8f2ac..995191c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,42 @@ import pytest +from pyosmeta.contributors import ProcessContributors from pyosmeta.github_api import GitHubAPI from pyosmeta.parse_issues import ProcessIssues +@pytest.fixture +def ghuser_response(): + """This is the initial github response. I changed the username to + create this object""" + expected_response = { + "login": "chayadecacao", + "id": 123456, + "node_id": "MDQ6VXNlcjU3ODU0Mw==", + "avatar_url": "https://avatars.githubusercontent.com/u/123456?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/cacao", + "html_url": "https://github.com/cacao", + } + return expected_response + + +@pytest.fixture +def mock_github_api(mocker, ghuser_response): + mock_api = mocker.Mock(spec=GitHubAPI) + mock_api.get_user_info.return_value = ghuser_response + return mock_api + + +@pytest.fixture +def process_contribs(contrib_github_api): + """A fixture that creates a""" + return ProcessContributors(contrib_github_api) + + @pytest.fixture def github_api(): + """A fixture that instantiates an instance of the GitHubAPI object""" return GitHubAPI( org="pyopensci", repo="pyosmeta", labels=["label1", "label2"] ) diff --git a/tests/unit/test_contributors_module.py b/tests/unit/test_contributors_module.py new file mode 100644 index 0000000..22d387f --- /dev/null +++ b/tests/unit/test_contributors_module.py @@ -0,0 +1,27 @@ +from pyosmeta.contributors import ProcessContributors +from pyosmeta.github_api import GitHubAPI + + +def test_init(mocker): + """Test that the ProcessContributors object instantiates as + expected""" + + # Create a mock GitHubAPI object + github_api_mock = mocker.MagicMock(spec=GitHubAPI) + json_files = ["file1.json", "file2.json"] + + process_contributors = ProcessContributors(github_api_mock, json_files) + + assert process_contributors.github_api == github_api_mock + assert process_contributors.json_files == json_files + + +def test_return_user_info(mock_github_api, ghuser_response): + """Test that return from github API user info returns expected + GH username.""" + + process_contributors = ProcessContributors(mock_github_api, []) + gh_handle = "chayadecacao" + user_info = process_contributors.return_user_info(gh_handle) + + assert user_info["github_username"] == gh_handle diff --git a/tests/unit/test_github_api.py b/tests/unit/test_github_api.py index 2e17e99..783056a 100644 --- a/tests/unit/test_github_api.py +++ b/tests/unit/test_github_api.py @@ -51,3 +51,31 @@ def test_api_endpoint(github_api): "issues?labels=label1,label2&state=all&per_page=100" ) assert github_api.api_endpoint == expected_endpoint + + +def test_get_user_info_successful(mocker, ghuser_response): + """Test that an expected response returns properly""" + + expected_response = ghuser_response + mock_response = mocker.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = expected_response + mocker.patch("requests.get", return_value=mock_response) + + github_api_instance = GitHubAPI() + user_info = github_api_instance.get_user_info("example_user") + + assert user_info == expected_response + + +def test_get_user_info_bad_credentials(mocker): + """Test that a value error is raised when the GH token is not + valid.""" + mock_response = mocker.Mock() + mock_response.status_code = 401 + mocker.patch("requests.get", return_value=mock_response) + + github_api = GitHubAPI() + + with pytest.raises(ValueError, match="Oops, I couldn't authenticate"): + github_api.get_user_info("example_user")