diff --git a/.github/linters/.mypy.ini b/.github/linters/.mypy.ini index f0d4703..3e79b06 100644 --- a/.github/linters/.mypy.ini +++ b/.github/linters/.mypy.ini @@ -1,5 +1,5 @@ [mypy] disable_error_code = attr-defined, import-not-found -[mypy-github3.*] +[mypy-github.*] ignore_missing_imports = True diff --git a/auth.py b/auth.py index 4a13250..a1727cb 100644 --- a/auth.py +++ b/auth.py @@ -1,39 +1,6 @@ """This is the module that contains functions related to authenticating to GitHub with a personal access token.""" -import github3 -import requests -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry - -# Retry strategy: 5 retries with exponential backoff for transient errors -RETRY_STRATEGY = Retry( - total=5, - backoff_factor=1, # 1s, 2s, 4s, 8s, 16s - status_forcelist=[429, 500, 502, 503, 504], - raise_on_status=False, -) -REQUEST_TIMEOUT = 30 # seconds - - -def _configure_session(github_connection: github3.GitHub) -> None: - """Mount retry adapter and set timeout on the github3 session.""" - session = getattr(github_connection, "session", None) - if session is None: - return - adapter = HTTPAdapter(max_retries=RETRY_STRATEGY) - session.mount("https://", adapter) - session.mount("http://", adapter) - session.request = _timeout_wrapper(session.request, REQUEST_TIMEOUT) # type: ignore[method-assign] - - -def _timeout_wrapper(original_request, default_timeout): - """Wrap a session.request method to inject a default timeout.""" - - def wrapper(*args, **kwargs): - kwargs.setdefault("timeout", default_timeout) - return original_request(*args, **kwargs) - - return wrapper +from github import Auth, Github, GithubIntegration def auth_to_github( @@ -43,7 +10,7 @@ def auth_to_github( gh_app_private_key_bytes: bytes, ghe: str, gh_app_enterprise_only: bool, -) -> github3.GitHub: +) -> Github: """ Connect to GitHub.com or GitHub Enterprise, depending on env variables. @@ -56,30 +23,34 @@ def auth_to_github( gh_app_enterprise_only (bool): Set this to true if the GH APP is created on GHE and needs to communicate with GHE api only Returns: - github3.GitHub: the GitHub connection object + Github: the GitHub connection object """ + ghe = ghe.rstrip("/") + if gh_app_id and gh_app_private_key_bytes and gh_app_installation_id: + app_auth = Auth.AppAuth(int(gh_app_id), gh_app_private_key_bytes.decode()) + installation_auth = app_auth.get_installation_auth(int(gh_app_installation_id)) if ghe and gh_app_enterprise_only: - gh = github3.github.GitHubEnterprise(url=ghe) + github_connection = Github( + base_url=f"{ghe}/api/v3", + auth=installation_auth, + ) else: - gh = github3.github.GitHub() - gh.login_as_app_installation( - gh_app_private_key_bytes, str(gh_app_id), gh_app_installation_id - ) - github_connection = gh + github_connection = Github(auth=installation_auth) elif ghe and token: - github_connection = github3.github.GitHubEnterprise(url=ghe, token=token) + github_connection = Github( + base_url=f"{ghe}/api/v3", + auth=Auth.Token(token), + ) elif token: - github_connection = github3.login(token=token) + github_connection = Github(auth=Auth.Token(token)) else: raise ValueError( - "GH_TOKEN or the set of [GH_APP_ID, GH_APP_INSTALLATION_ID, GH_APP_PRIVATE_KEY] environment variables are not set" + "GH_TOKEN or the set of [GH_APP_ID, GH_APP_INSTALLATION_ID, " + "GH_APP_PRIVATE_KEY] environment variables are not set" ) - if not github_connection: - raise ValueError("Unable to authenticate to GitHub") - _configure_session(github_connection) - return github_connection # type: ignore + return github_connection def get_github_app_installation_token( @@ -99,23 +70,24 @@ def get_github_app_installation_token( gh_app_installation_id (str): the GitHub App Installation ID Returns: - str: the GitHub App token + str | None: the GitHub App token, or None if the request fails """ - jwt_headers = github3.apps.create_jwt_headers(gh_app_private_key_bytes, gh_app_id) - api_endpoint = f"{ghe}/api/v3" if ghe else "https://api.github.com" - url = f"{api_endpoint}/app/installations/{gh_app_installation_id}/access_tokens" - try: - response = requests.post(url, headers=jwt_headers, json=None, timeout=5) - response.raise_for_status() - except requests.exceptions.RequestException as e: + ghe = ghe.rstrip("/") + app_auth = Auth.AppAuth(int(gh_app_id), gh_app_private_key_bytes.decode()) + if ghe: + gi = GithubIntegration(auth=app_auth, base_url=f"{ghe}/api/v3") + else: + gi = GithubIntegration(auth=app_auth) + installation_token = gi.get_access_token(int(gh_app_installation_id)) + return installation_token.token + except Exception as e: # pylint: disable=broad-exception-caught print(f"Request failed: {e}") return None - return response.json().get("token") def get_team_members( - github_connection: github3.GitHub, + github_connection: Github, org: str, team_slug: str, ) -> list[str]: @@ -123,7 +95,7 @@ def get_team_members( Fetch the members of a GitHub team by slug. Args: - github_connection: Authenticated github3 connection. + github_connection: Authenticated GitHub connection. org: The organization that owns the team. team_slug: The team slug (e.g., "nux-reviewers"). @@ -132,13 +104,12 @@ def get_team_members( Returns an empty list if the team is not found or an error occurs. """ try: - organization = github_connection.organization(org) + organization = github_connection.get_organization(org) if not organization: print(f" ⚠️ Organization '{org}' not found, skipping team '{team_slug}'") return [] - # team_by_name accepts a slug despite its name (hits /orgs/{org}/teams/{slug}) - team = organization.team_by_name(team_slug) + team = organization.get_team_by_slug(team_slug) if not team: print( f" ⚠️ Team '{team_slug}' not found in '{org}', " @@ -146,7 +117,7 @@ def get_team_members( ) return [] - members = [m.login for m in team.members()] + members = [m.login for m in team.get_members()] print(f" Resolved team {org}/{team_slug}: {len(members)} member(s)") return members except Exception as e: # pylint: disable=broad-except diff --git a/conflict_detector.py b/conflict_detector.py index ea56909..43117b5 100644 --- a/conflict_detector.py +++ b/conflict_detector.py @@ -156,9 +156,9 @@ def verify_conflict( when VERIFY_CONFLICTS=true. """ try: - repo = github_connection.repository(owner, repo_name) # type: ignore[union-attr] - pr_a = repo.pull_request(conflict.pr_a.number) - pr_b = repo.pull_request(conflict.pr_b.number) + repo = github_connection.get_repo(f"{owner}/{repo_name}") # type: ignore[union-attr] + pr_a = repo.get_pull(conflict.pr_a.number) + pr_b = repo.get_pull(conflict.pr_b.number) # If either PR is not mergeable on its own, they likely conflict if pr_a.mergeable is False or pr_b.mergeable is False: diff --git a/issue_writer.py b/issue_writer.py index 68f49d7..db4036e 100644 --- a/issue_writer.py +++ b/issue_writer.py @@ -8,7 +8,7 @@ from conflict_detector import ConflictCluster, cluster_conflicts if TYPE_CHECKING: - from github3.repos.repo import Repository + from github.Repository import Repository logger = logging.getLogger(__name__) @@ -28,7 +28,7 @@ def create_or_update_issue( """Create or update an issue in the repository with conflict information. Args: - repo: A github3.py repository object. + repo: A PyGithub repository object. conflicts: List of ConflictResult objects for this repository. report_title: Title for the issue. dry_run: If True, log what would happen but make no API calls. @@ -67,7 +67,7 @@ def create_or_update_issue( def _find_existing_issue(repo: Repository, title: str): """Search open issues for one matching the given title and hidden tag.""" - for issue in repo.issues(state="open"): + for issue in repo.get_issues(state="open"): if issue.title == title and ISSUE_TAG in (issue.body or ""): return issue return None diff --git a/pr_comment.py b/pr_comment.py index d92790a..8ec9568 100644 --- a/pr_comment.py +++ b/pr_comment.py @@ -70,8 +70,7 @@ def post_pr_comments( for repo_name in all_repo_names: conflicts = all_conflicts_by_repo.get(repo_name, []) - owner, repo = repo_name.split("/") - repo_obj = github_connection.repository(owner, repo) + repo_obj = github_connection.get_repo(repo_name) # Group active conflicts by PR pr_conflicts = group_conflicts_by_pr(conflicts) if conflicts else {} @@ -163,8 +162,8 @@ def _find_existing_comments(repo: Any, pr_number: int) -> list[Any]: List of comment objects with the bot signature (may be empty). """ try: - pr = repo.pull_request(pr_number) - return [c for c in pr.issue_comments() if COMMENT_SIGNATURE in c.body] + pr = repo.get_pull(pr_number) + return [c for c in pr.get_issue_comments() if COMMENT_SIGNATURE in c.body] except Exception as e: # pylint: disable=broad-except logger.warning("Failed to check existing comments on PR #%s: %s", pr_number, e) return [] @@ -222,8 +221,8 @@ def _post_comment(repo, pr_number: int, body: str) -> bool: True if successful, False otherwise """ try: - pr = repo.pull_request(pr_number) - pr.create_comment(body) + pr = repo.get_pull(pr_number) + pr.create_issue_comment(body=body) logger.info("Posted comment to PR #%s", pr_number) return True except Exception as e: # pylint: disable=broad-except diff --git a/pr_conflict_detector.py b/pr_conflict_detector.py index edbb64e..8194d7b 100644 --- a/pr_conflict_detector.py +++ b/pr_conflict_detector.py @@ -217,8 +217,7 @@ def main(): print("Report issue creation disabled (ENABLE_REPORT_ISSUES=false)") elif not env_vars.dry_run: for repo_full_name, conflicts in all_conflicts.items(): - owner, rname = repo_full_name.split("/") - repo_obj = github_connection.repository(owner, rname) + repo_obj = github_connection.get_repo(repo_full_name) issue_url = create_or_update_issue( repo_obj, conflicts, env_vars.report_title, env_vars.dry_run ) @@ -273,19 +272,18 @@ def get_repos_iterator(github_connection, env_vars): """Get an iterator of repositories to scan. Args: - github_connection: Authenticated github3 connection. + github_connection: Authenticated PyGithub connection. env_vars: Environment variables dataclass. Returns: - Iterator of github3 repository objects. + Iterator of PyGithub repository objects. """ if env_vars.organization and not env_vars.repository_list: - return github_connection.organization(env_vars.organization).repositories() + return github_connection.get_organization(env_vars.organization).get_repos() repos = [] for repo_full_name in env_vars.repository_list: - owner, repo_name = repo_full_name.split("/") - repos.append(github_connection.repository(owner, repo_name)) + repos.append(github_connection.get_repo(repo_full_name)) return repos diff --git a/pr_data.py b/pr_data.py index 98cff3e..50dcc73 100644 --- a/pr_data.py +++ b/pr_data.py @@ -59,17 +59,17 @@ def parse_patch_line_ranges(patch: str | None) -> list[tuple[int, int]]: def get_open_prs(repo: object, include_drafts: bool = True) -> list[PullRequestData]: """ - Fetch all open PRs from a github3-py repository object. + Fetch all open PRs from a PyGithub repository object. Args: - repo: A github3-py repository object. + repo: A PyGithub repository object. include_drafts: If False, filter out draft PRs. Returns: A list of PullRequestData objects (without changed files populated). """ prs: list[PullRequestData] = [] - for pr in repo.pull_requests(state="open"): # type: ignore[attr-defined] + for pr in repo.get_pulls(state="open"): # type: ignore[attr-defined] is_draft = getattr(pr, "draft", False) or False if not include_drafts and is_draft: continue @@ -97,12 +97,12 @@ def get_pr_changed_files( # pylint: disable=unused-argument """ Fetch the list of changed files for a given pull request. - Uses the github3-py pull request's files() method, then parses + Uses the PyGithub pull request's get_files() method, then parses each file's patch to extract modified line ranges. Args: - pull_request: A github3-py ShortPullRequest or PullRequest object. - github_connection: The github3-py GitHub connection (unused but kept + pull_request: A PyGithub ShortPullRequest or PullRequest object. + github_connection: The PyGithub GitHub connection (unused but kept for API consistency with other OSPO actions). owner: The repository owner. repo_name: The repository name. @@ -111,7 +111,7 @@ def get_pr_changed_files( # pylint: disable=unused-argument A list of ChangedFile objects. """ changed_files: list[ChangedFile] = [] - for f in pull_request.files(): # type: ignore[attr-defined] + for f in pull_request.get_files(): # type: ignore[attr-defined] patch = getattr(f, "patch", None) changed_files.append( ChangedFile( @@ -137,9 +137,9 @@ def fetch_all_pr_data( Fetch all open PRs and their changed files from a repository. Args: - repo: A github3-py repository object. + repo: A PyGithub repository object. include_drafts: If False, filter out draft PRs. - github_connection: The github3-py GitHub connection. + github_connection: The PyGithub GitHub connection. owner: The repository owner. repo_name: The repository name. filter_authors: If provided, only fetch file changes for PRs authored @@ -171,8 +171,8 @@ def fetch_all_pr_data( print(f" Progress: {i + 1}/{total} PRs processed") try: - # Re-fetch the full PR object to call files() - full_pr = repo.pull_request(pr_data.number) # type: ignore[attr-defined] + # Re-fetch the full PR object to call get_files() + full_pr = repo.get_pull(pr_data.number) # type: ignore[attr-defined] pr_data.changed_files = get_pr_changed_files( full_pr, github_connection, owner, repo_name ) diff --git a/pyproject.toml b/pyproject.toml index ee38cd7..690fa10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,9 +4,7 @@ version = "1.0.0" description = "Detect pull request conflicts and notify" requires-python = ">=3.11" dependencies = [ - "cryptography==49.0.0", - "github3-py==4.0.1", - "pyjwt==2.13.0", + "PyGithub>=2.6.0", "python-dotenv==1.2.2", "requests==2.34.2", ] diff --git a/test_auth.py b/test_auth.py index 1072f12..c18be29 100644 --- a/test_auth.py +++ b/test_auth.py @@ -1,13 +1,9 @@ """Test cases for the auth module.""" -# pylint: disable=protected-access - import unittest from unittest.mock import MagicMock, patch import auth -import github3 -import requests class TestAuth(unittest.TestCase): @@ -15,16 +11,17 @@ class TestAuth(unittest.TestCase): Test case for the auth module. """ - @patch("github3.login") - def test_auth_to_github_with_token(self, mock_login): + @patch("auth.Github") + def test_auth_to_github_with_token(self, mock_github_cls): """ Test the auth_to_github function when the token is provided. """ - mock_login.return_value = "Authenticated to GitHub.com" + mock_github_cls.return_value = MagicMock() result = auth.auth_to_github("token", "", "", b"", "", False) - self.assertEqual(result, "Authenticated to GitHub.com") + self.assertEqual(result, mock_github_cls.return_value) + mock_github_cls.assert_called_once() def test_auth_to_github_without_token(self): """ @@ -39,131 +36,136 @@ def test_auth_to_github_without_token(self): "GH_TOKEN or the set of [GH_APP_ID, GH_APP_INSTALLATION_ID, GH_APP_PRIVATE_KEY] environment variables are not set", ) - @patch("github3.github.GitHubEnterprise") - def test_auth_to_github_with_ghe(self, mock_ghe): + @patch("auth.Github") + def test_auth_to_github_with_ghe(self, mock_github_cls): """ Test the auth_to_github function when the GitHub Enterprise URL is provided. """ - mock_ghe.return_value = "Authenticated to GitHub Enterprise" + mock_github_cls.return_value = MagicMock() result = auth.auth_to_github( "token", "", "", b"", "https://github.example.com", False ) - self.assertEqual(result, "Authenticated to GitHub Enterprise") + self.assertEqual(result, mock_github_cls.return_value) + call_kwargs = mock_github_cls.call_args[1] + self.assertEqual(call_kwargs["base_url"], "https://github.example.com/api/v3") - @patch("github3.github.GitHubEnterprise") - def test_auth_to_github_with_ghe_and_ghe_app(self, mock_ghe): + @patch("auth.Github") + @patch("auth.Auth.AppAuth") + def test_auth_to_github_with_ghe_and_ghe_app( + self, mock_app_auth_cls, mock_github_cls + ): """ - Test the auth_to_github function when the GitHub Enterprise URL is provided and the app was created in GitHub Enterprise URL. + Test the auth_to_github function when the GitHub Enterprise URL is provided + and the app was created in GitHub Enterprise URL. """ - mock = mock_ghe.return_value - mock.login_as_app_installation = MagicMock(return_value=True) + mock_app_auth = MagicMock() + mock_app_auth_cls.return_value = mock_app_auth + mock_installation_auth = MagicMock() + mock_app_auth.get_installation_auth.return_value = mock_installation_auth + mock_github_cls.return_value = MagicMock() + result = auth.auth_to_github( "", 123, 456, b"123", "https://github.example.com", True ) - mock.login_as_app_installation.assert_called_once_with(b"123", "123", 456) - self.assertEqual(result, mock) - @patch("github3.github.GitHub") - def test_auth_to_github_with_app(self, mock_gh): + mock_app_auth_cls.assert_called_once_with(123, "123") + mock_app_auth.get_installation_auth.assert_called_once_with(456) + call_kwargs = mock_github_cls.call_args[1] + self.assertEqual(call_kwargs["base_url"], "https://github.example.com/api/v3") + self.assertEqual(call_kwargs["auth"], mock_installation_auth) + self.assertEqual(result, mock_github_cls.return_value) + + @patch("auth.Github") + @patch("auth.Auth.AppAuth") + def test_auth_to_github_with_app(self, mock_app_auth_cls, mock_github_cls): """ Test the auth_to_github function when app credentials are provided + without GHE enterprise-only flag. """ - mock = mock_gh.return_value - mock.login_as_app_installation = MagicMock(return_value=True) - result = auth.auth_to_github( - "", 123, 456, b"123", "https://github.example.com", False - ) - mock.login_as_app_installation.assert_called_once_with(b"123", "123", 456) - self.assertEqual(result, mock) + mock_app_auth = MagicMock() + mock_app_auth_cls.return_value = mock_app_auth + mock_installation_auth = MagicMock() + mock_app_auth.get_installation_auth.return_value = mock_installation_auth + mock_github_cls.return_value = MagicMock() - @patch("github3.apps.create_jwt_headers", MagicMock(return_value="gh_token")) - @patch("requests.post") - def test_get_github_app_installation_token(self, mock_post): + result = auth.auth_to_github("", 123, 456, b"123", "", False) + + mock_app_auth_cls.assert_called_once_with(123, "123") + mock_app_auth.get_installation_auth.assert_called_once_with(456) + self.assertEqual(result, mock_github_cls.return_value) + + @patch("auth.GithubIntegration") + @patch("auth.Auth.AppAuth") + def test_get_github_app_installation_token( + self, mock_app_auth_cls, mock_integration_cls + ): """ Test the get_github_app_installation_token function. """ dummy_token = "dummytoken" - mock_response = MagicMock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"token": dummy_token} - mock_post.return_value = mock_response + mock_app_auth = MagicMock() + mock_app_auth_cls.return_value = mock_app_auth + + mock_integration = MagicMock() + mock_integration_cls.return_value = mock_integration + mock_access_token = MagicMock() + mock_access_token.token = dummy_token + mock_integration.get_access_token.return_value = mock_access_token result = auth.get_github_app_installation_token( - b"ghe", "gh_private_token", "gh_app_id", "gh_installation_id" + "", "12345", b"gh_private_token", "67890" ) + mock_app_auth_cls.assert_called_once_with(12345, "gh_private_token") + mock_integration_cls.assert_called_once_with(auth=mock_app_auth) + mock_integration.get_access_token.assert_called_once_with(67890) self.assertEqual(result, dummy_token) - @patch("github3.apps.create_jwt_headers", MagicMock(return_value="gh_token")) - @patch("auth.requests.post") - def test_get_github_app_installation_token_request_failure(self, mock_post): + @patch("auth.GithubIntegration") + @patch("auth.Auth.AppAuth") + def test_get_github_app_installation_token_with_ghe( + self, mock_app_auth_cls, mock_integration_cls + ): """ - Test the get_github_app_installation_token function returns None when the request fails. + Test the get_github_app_installation_token function with a GHE URL. """ - mock_post.side_effect = requests.exceptions.RequestException("Request failed") + dummy_token = "ghetoken" + mock_app_auth = MagicMock() + mock_app_auth_cls.return_value = mock_app_auth + + mock_integration = MagicMock() + mock_integration_cls.return_value = mock_integration + mock_access_token = MagicMock() + mock_access_token.token = dummy_token + mock_integration.get_access_token.return_value = mock_access_token result = auth.get_github_app_installation_token( - ghe="https://api.github.com", - gh_app_id=12345, - gh_app_private_key_bytes=b"private_key", - gh_app_installation_id=678910, + "https://github.example.com", "12345", b"gh_private_token", "67890" ) - self.assertIsNone(result) + mock_app_auth_cls.assert_called_once_with(12345, "gh_private_token") + mock_integration_cls.assert_called_once_with( + auth=mock_app_auth, base_url="https://github.example.com/api/v3" + ) + mock_integration.get_access_token.assert_called_once_with(67890) + self.assertEqual(result, dummy_token) - @patch("github3.login") - def test_auth_to_github_invalid_credentials(self, mock_login): + @patch("auth.Auth.AppAuth") + def test_get_github_app_installation_token_request_failure(self, mock_app_auth_cls): """ - Test the auth_to_github function raises correct ValueError - when credentials are present but incorrect. + Test the get_github_app_installation_token function returns None when the request fails. """ - mock_login.return_value = None - with self.assertRaises(ValueError) as context_manager: - auth.auth_to_github("not_a_valid_token", "", "", b"", "", False) + mock_app_auth_cls.side_effect = Exception("Request failed") - the_exception = context_manager.exception - self.assertEqual( - str(the_exception), - "Unable to authenticate to GitHub", + result = auth.get_github_app_installation_token( + ghe="https://api.github.com", + gh_app_id="12345", + gh_app_private_key_bytes=b"private_key", + gh_app_installation_id="678910", ) - @patch("github3.login") - def test_auth_configures_retry_session(self, mock_login): - """Test that auth configures retry adapter and timeout on the session.""" - mock_gh = MagicMock() - mock_session = MagicMock() - mock_gh.session = mock_session - mock_login.return_value = mock_gh - - result = auth.auth_to_github("token", "", "", b"", "", False) - - self.assertEqual(result, mock_gh) - # Retry adapter should be mounted - mock_session.mount.assert_any_call("https://", unittest.mock.ANY) - mock_session.mount.assert_any_call("http://", unittest.mock.ANY) - - def test_timeout_wrapper_injects_default(self): - """Test that the timeout wrapper injects a default timeout.""" - original = MagicMock(return_value="response") - wrapped = auth._timeout_wrapper( - original, 30 - ) # pylint: disable=protected-access - - wrapped("GET", "https://api.github.com") - - original.assert_called_once_with("GET", "https://api.github.com", timeout=30) - - def test_timeout_wrapper_respects_explicit_timeout(self): - """Test that an explicit timeout is not overridden.""" - original = MagicMock(return_value="response") - wrapped = auth._timeout_wrapper( - original, 30 - ) # pylint: disable=protected-access - - wrapped("GET", "https://api.github.com", timeout=60) - - original.assert_called_once_with("GET", "https://api.github.com", timeout=60) + self.assertIsNone(result) class TestGetTeamMembers(unittest.TestCase): @@ -179,23 +181,23 @@ def test_get_team_members_success(self): member1.login = "alice" member2 = MagicMock() member2.login = "bob" - mock_team.members.return_value = [member1, member2] + mock_team.get_members.return_value = [member1, member2] - mock_org.team_by_name.return_value = mock_team - mock_gh.organization.return_value = mock_org + mock_org.get_team_by_slug.return_value = mock_team + mock_gh.get_organization.return_value = mock_org result = auth.get_team_members(mock_gh, "my-org", "my-team") self.assertEqual(result, ["alice", "bob"]) - mock_gh.organization.assert_called_once_with("my-org") - mock_org.team_by_name.assert_called_once_with("my-team") + mock_gh.get_organization.assert_called_once_with("my-org") + mock_org.get_team_by_slug.assert_called_once_with("my-team") def test_get_team_members_team_not_found(self): """Test that a missing team returns an empty list.""" mock_gh = MagicMock() mock_org = MagicMock() - mock_org.team_by_name.return_value = None - mock_gh.organization.return_value = mock_org + mock_org.get_team_by_slug.return_value = None + mock_gh.get_organization.return_value = mock_org result = auth.get_team_members(mock_gh, "my-org", "nonexistent-team") @@ -204,7 +206,7 @@ def test_get_team_members_team_not_found(self): def test_get_team_members_org_not_found(self): """Test that a missing organization returns an empty list.""" mock_gh = MagicMock() - mock_gh.organization.return_value = None + mock_gh.get_organization.return_value = None result = auth.get_team_members(mock_gh, "nonexistent-org", "my-team") @@ -213,26 +215,12 @@ def test_get_team_members_org_not_found(self): def test_get_team_members_api_error(self): """Test that API errors are caught and return an empty list.""" mock_gh = MagicMock() - mock_gh.organization.side_effect = Exception("API rate limit exceeded") + mock_gh.get_organization.side_effect = Exception("API rate limit exceeded") result = auth.get_team_members(mock_gh, "my-org", "my-team") self.assertEqual(result, []) - def test_team_by_name_exists_on_organization(self): - """Verify that github3.py Organization actually has team_by_name. - - This guards against calling a method that doesn't exist on the real - class, which MagicMock would silently allow. See PR #25 for context: - the original code called team_by_slug which never existed in github3.py - v4.0.1, and MagicMock-based tests couldn't catch it. - """ - self.assertTrue( - hasattr(github3.orgs.Organization, "team_by_name"), - "github3.orgs.Organization is missing team_by_name - " - "check github3.py version compatibility", - ) - if __name__ == "__main__": unittest.main() diff --git a/test_conflict_detector.py b/test_conflict_detector.py index adb2b3a..3d5e1c6 100644 --- a/test_conflict_detector.py +++ b/test_conflict_detector.py @@ -333,10 +333,10 @@ def test_with_verify_flag(self): mock_gh = MagicMock() mock_repo = MagicMock() - mock_gh.repository.return_value = mock_repo + mock_gh.get_repo.return_value = mock_repo mock_pr = MagicMock() mock_pr.mergeable = False - mock_repo.pull_request.return_value = mock_pr + mock_repo.get_pull.return_value = mock_pr results = detect_conflicts( [pr_a, pr_b], @@ -384,10 +384,10 @@ def test_verify_returns_true_when_not_mergeable(self): """Test that verify returns True when PR is not mergeable.""" mock_gh = MagicMock() mock_repo = MagicMock() - mock_gh.repository.return_value = mock_repo + mock_gh.get_repo.return_value = mock_repo mock_pr = MagicMock() mock_pr.mergeable = False - mock_repo.pull_request.return_value = mock_pr + mock_repo.get_pull.return_value = mock_pr conflict = self._make_conflict() result = verify_conflict(conflict, mock_gh, "owner", "repo") @@ -398,10 +398,10 @@ def test_verify_returns_false_when_mergeable(self): """Test that verify returns False when PR is mergeable.""" mock_gh = MagicMock() mock_repo = MagicMock() - mock_gh.repository.return_value = mock_repo + mock_gh.get_repo.return_value = mock_repo mock_pr = MagicMock() mock_pr.mergeable = True - mock_repo.pull_request.return_value = mock_pr + mock_repo.get_pull.return_value = mock_pr conflict = self._make_conflict() result = verify_conflict(conflict, mock_gh, "owner", "repo") @@ -411,7 +411,7 @@ def test_verify_returns_false_when_mergeable(self): def test_verify_returns_false_on_api_error(self): """Test that verify returns False when the API raises an error.""" mock_gh = MagicMock() - mock_gh.repository.side_effect = Exception("API error") + mock_gh.get_repo.side_effect = Exception("API error") conflict = self._make_conflict() result = verify_conflict(conflict, mock_gh, "owner", "repo") @@ -422,10 +422,10 @@ def test_verify_returns_false_when_mergeable_is_none(self): """When GitHub hasn't computed mergeability yet, mergeable is None.""" mock_gh = MagicMock() mock_repo = MagicMock() - mock_gh.repository.return_value = mock_repo + mock_gh.get_repo.return_value = mock_repo mock_pr = MagicMock() mock_pr.mergeable = None - mock_repo.pull_request.return_value = mock_pr + mock_repo.get_pull.return_value = mock_pr conflict = self._make_conflict() result = verify_conflict(conflict, mock_gh, "owner", "repo") diff --git a/test_issue_writer.py b/test_issue_writer.py index 591209b..c84eb80 100644 --- a/test_issue_writer.py +++ b/test_issue_writer.py @@ -34,10 +34,10 @@ def _make_conflict(pr_a, pr_b, files=None): def _make_mock_repo(existing_issues=None): - """Create a mock github3.py repository.""" + """Create a mock PyGithub repository.""" repo = MagicMock() repo.full_name = "owner/repo" - repo.issues.return_value = existing_issues or [] + repo.get_issues.return_value = existing_issues or [] new_issue = MagicMock() new_issue.html_url = "https://github.com/owner/repo/issues/99" repo.create_issue.return_value = new_issue diff --git a/test_pr_comment_integration.py b/test_pr_comment_integration.py index ebeda66..b40a281 100644 --- a/test_pr_comment_integration.py +++ b/test_pr_comment_integration.py @@ -80,7 +80,7 @@ def test_resolved_only_pr_gets_comment(self, _mock_find, mock_post): gh = MagicMock() repo_mock = MagicMock() - gh.repository.return_value = repo_mock + gh.get_repo.return_value = repo_mock result = pr_comment.post_pr_comments( conflicts, gh, resolved_entries=resolved_entries diff --git a/test_pr_comment_posting.py b/test_pr_comment_posting.py index 826c9a9..944716d 100644 --- a/test_pr_comment_posting.py +++ b/test_pr_comment_posting.py @@ -27,7 +27,7 @@ def test_post_pr_comments_success(self, _mock_find, mock_post): gh = MagicMock() repo_mock = MagicMock() - gh.repository.return_value = repo_mock + gh.get_repo.return_value = repo_mock result = pr_comment.post_pr_comments(conflicts, gh) @@ -49,7 +49,7 @@ def test_post_pr_comments_updates_existing(self, mock_find, mock_update): gh = MagicMock() repo_mock = MagicMock() - gh.repository.return_value = repo_mock + gh.get_repo.return_value = repo_mock result = pr_comment.post_pr_comments(conflicts, gh) @@ -67,7 +67,7 @@ def test_post_pr_comments_dry_run(self, _mock_find): result = pr_comment.post_pr_comments(conflicts, gh, dry_run=True) self.assertTrue(result) - gh.repository.assert_called_once_with("org", "repo") + gh.get_repo.assert_called_once_with("org/repo") @patch("pr_comment._post_comment", return_value=True) @patch("pr_comment._find_existing_comments", return_value=[]) @@ -88,7 +88,7 @@ def test_multiple_conflicts_single_comment(self, _mock_find, mock_post): gh = MagicMock() repo_mock = MagicMock() - gh.repository.return_value = repo_mock + gh.get_repo.return_value = repo_mock result = pr_comment.post_pr_comments(conflicts, gh) @@ -112,7 +112,7 @@ def test_post_pr_comments_with_new_conflict_keys(self, _mock_find, mock_post): conflicts = {"org/repo": [conflict]} gh = MagicMock() - gh.repository.return_value = MagicMock() + gh.get_repo.return_value = MagicMock() new_keys = {(1, 2)} result = pr_comment.post_pr_comments(conflicts, gh, new_conflict_keys=new_keys) @@ -136,7 +136,7 @@ def test_post_pr_comments_new_conflict_keys_reverse_pair( conflicts = {"org/repo": [conflict]} gh = MagicMock() - gh.repository.return_value = MagicMock() + gh.get_repo.return_value = MagicMock() # Only the reversed tuple is present — the disjunction's second half must fire. new_keys = {(2, 1)} @@ -158,7 +158,7 @@ def test_post_pr_comments_post_failure_returns_false(self, _mock_find, _mock_pos conflicts = {"org/repo": [conflict]} gh = MagicMock() - gh.repository.return_value = MagicMock() + gh.get_repo.return_value = MagicMock() result = pr_comment.post_pr_comments(conflicts, gh) self.assertFalse(result) @@ -175,7 +175,7 @@ def test_post_pr_comments_update_failure_returns_false( mock_find.return_value = [MagicMock()] gh = MagicMock() - gh.repository.return_value = MagicMock() + gh.get_repo.return_value = MagicMock() result = pr_comment.post_pr_comments(conflicts, gh) self.assertFalse(result) @@ -188,13 +188,13 @@ def test_find_existing_comments_found(self): """Should return all comments with the bot signature.""" repo = MagicMock() pr = MagicMock() - repo.pull_request.return_value = pr + repo.get_pull.return_value = pr comment1 = MagicMock() comment1.body = "Some random comment" comment2 = MagicMock() comment2.body = f"{pr_comment.COMMENT_SIGNATURE}\nConflict info" - pr.issue_comments.return_value = [comment1, comment2] + pr.get_issue_comments.return_value = [comment1, comment2] result = pr_comment._find_existing_comments(repo, 123) self.assertEqual(result, [comment2]) @@ -203,7 +203,7 @@ def test_find_existing_comments_multiple(self): """Should return all bot comments for stale cleanup.""" repo = MagicMock() pr = MagicMock() - repo.pull_request.return_value = pr + repo.get_pull.return_value = pr comment1 = MagicMock() comment1.body = f"{pr_comment.COMMENT_SIGNATURE}\nOld conflict with #200" @@ -211,7 +211,7 @@ def test_find_existing_comments_multiple(self): comment2.body = f"{pr_comment.COMMENT_SIGNATURE}\nOld conflict with #300" comment3 = MagicMock() comment3.body = f"{pr_comment.COMMENT_SIGNATURE}\nOld conflict with #400" - pr.issue_comments.return_value = [comment1, comment2, comment3] + pr.get_issue_comments.return_value = [comment1, comment2, comment3] result = pr_comment._find_existing_comments(repo, 123) self.assertEqual(len(result), 3) @@ -221,11 +221,11 @@ def test_find_existing_comments_not_found(self): """Should return empty list if no matching comment exists.""" repo = MagicMock() pr = MagicMock() - repo.pull_request.return_value = pr + repo.get_pull.return_value = pr comment1 = MagicMock() comment1.body = "Regular comment" - pr.issue_comments.return_value = [comment1] + pr.get_issue_comments.return_value = [comment1] result = pr_comment._find_existing_comments(repo, 123) self.assertEqual(result, []) @@ -233,7 +233,7 @@ def test_find_existing_comments_not_found(self): def test_find_existing_comments_error_handling(self): """Should return empty list on error to avoid blocking.""" repo = MagicMock() - repo.pull_request.side_effect = Exception("API error") + repo.get_pull.side_effect = Exception("API error") result = pr_comment._find_existing_comments(repo, 123) self.assertEqual(result, []) @@ -246,19 +246,19 @@ def test_post_comment_success(self): """Should successfully post a comment.""" repo = MagicMock() pr = MagicMock() - repo.pull_request.return_value = pr + repo.get_pull.return_value = pr result = pr_comment._post_comment(repo, 123, "Test comment") self.assertTrue(result) - pr.create_comment.assert_called_once_with("Test comment") + pr.create_issue_comment.assert_called_once_with(body="Test comment") def test_post_comment_failure(self): """Should return False on error.""" repo = MagicMock() pr = MagicMock() - repo.pull_request.return_value = pr - pr.create_comment.side_effect = Exception("API error") + repo.get_pull.return_value = pr + pr.create_issue_comment.side_effect = Exception("API error") result = pr_comment._post_comment(repo, 123, "Test comment") @@ -285,7 +285,7 @@ def test_cleans_up_stale_comments(self, mock_find, mock_update, mock_delete): mock_find.return_value = [stale1, stale2, stale3] gh = MagicMock() - gh.repository.return_value = MagicMock() + gh.get_repo.return_value = MagicMock() result = pr_comment.post_pr_comments(conflicts, gh) @@ -302,7 +302,7 @@ def test_dry_run_reports_new_comments(self, _mock_find): conflicts = {"org/repo": [conflict]} gh = MagicMock() - gh.repository.return_value = MagicMock() + gh.get_repo.return_value = MagicMock() with patch("pr_comment.logger") as mock_logger: pr_comment.post_pr_comments(conflicts, gh, dry_run=True) @@ -319,7 +319,7 @@ def test_dry_run_reports_updates(self, mock_find): mock_find.return_value = [MagicMock()] gh = MagicMock() - gh.repository.return_value = MagicMock() + gh.get_repo.return_value = MagicMock() with patch("pr_comment.logger") as mock_logger: pr_comment.post_pr_comments(conflicts, gh, dry_run=True) @@ -336,7 +336,7 @@ def test_dry_run_reports_stale_cleanup(self, mock_find): mock_find.return_value = [MagicMock(), MagicMock(), MagicMock()] gh = MagicMock() - gh.repository.return_value = MagicMock() + gh.get_repo.return_value = MagicMock() with patch("pr_comment.logger") as mock_logger: pr_comment.post_pr_comments(conflicts, gh, dry_run=True) diff --git a/test_pr_conflict_detector_exclude.py b/test_pr_conflict_detector_exclude.py index 6a30d55..370e930 100644 --- a/test_pr_conflict_detector_exclude.py +++ b/test_pr_conflict_detector_exclude.py @@ -30,8 +30,8 @@ def _setup_org(self, mock_auth, repo): gh = MagicMock() mock_auth.return_value = gh org_mock = MagicMock() - org_mock.repositories.return_value = [repo] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = [repo] + gh.get_organization.return_value = org_mock @patch("pr_conflict_detector.auth.get_team_members") def test_exclude_removes_team_member( diff --git a/test_pr_conflict_detector_filtering.py b/test_pr_conflict_detector_filtering.py index 4b0eade..4c2b2bb 100644 --- a/test_pr_conflict_detector_filtering.py +++ b/test_pr_conflict_detector_filtering.py @@ -45,8 +45,8 @@ def test_skips_exempt_repos( gh = MagicMock() mock_auth.return_value = gh org_mock = MagicMock() - org_mock.repositories.return_value = [exempt_repo, normal_repo] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = [exempt_repo, normal_repo] + gh.get_organization.return_value = org_mock mock_fetch.return_value = [_make_pr(1)] # only 1 PR, won't detect @@ -91,8 +91,8 @@ def test_skips_archived_repos( gh = MagicMock() mock_auth.return_value = gh org_mock = MagicMock() - org_mock.repositories.return_value = [archived, active] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = [archived, active] + gh.get_organization.return_value = org_mock mock_fetch.return_value = [_make_pr(1)] @@ -135,8 +135,8 @@ def test_skips_repos_with_fewer_than_2_prs( gh = MagicMock() mock_auth.return_value = gh org_mock = MagicMock() - org_mock.repositories.return_value = [repo] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = [repo] + gh.get_organization.return_value = org_mock mock_fetch.return_value = [_make_pr(1)] # Only 1 PR @@ -177,8 +177,8 @@ def test_exempt_prs_filtered( gh = MagicMock() mock_auth.return_value = gh org_mock = MagicMock() - org_mock.repositories.return_value = [repo] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = [repo] + gh.get_organization.return_value = org_mock pr1, pr2, pr3 = _make_pr(1), _make_pr(2), _make_pr(3) mock_fetch.return_value = [pr1, pr2, pr3] @@ -226,8 +226,8 @@ def test_filter_authors_keeps_matching_prs( gh = MagicMock() mock_auth.return_value = gh org_mock = MagicMock() - org_mock.repositories.return_value = [repo] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = [repo] + gh.get_organization.return_value = org_mock pr_alice = _make_pr(1, author="alice") pr_bob = _make_pr(2, author="bob") @@ -263,8 +263,8 @@ def test_filter_authors_no_matching_prs_skips_repo( gh = MagicMock() mock_auth.return_value = gh org_mock = MagicMock() - org_mock.repositories.return_value = [repo] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = [repo] + gh.get_organization.return_value = org_mock pr_bob = _make_pr(1, author="bob") pr_charlie = _make_pr(2, author="charlie") @@ -308,8 +308,8 @@ def test_filter_teams_resolves_members( gh = MagicMock() mock_auth.return_value = gh org_mock = MagicMock() - org_mock.repositories.return_value = [repo] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = [repo] + gh.get_organization.return_value = org_mock mock_get_team.return_value = ["alice", "bob"] @@ -353,8 +353,8 @@ def test_filter_teams_combined_with_filter_authors( gh = MagicMock() mock_auth.return_value = gh org_mock = MagicMock() - org_mock.repositories.return_value = [repo] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = [repo] + gh.get_organization.return_value = org_mock mock_get_team.return_value = ["alice", "bob"] @@ -398,8 +398,8 @@ def test_filter_teams_empty_resolution_warns( gh = MagicMock() mock_auth.return_value = gh org_mock = MagicMock() - org_mock.repositories.return_value = [repo] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = [repo] + gh.get_organization.return_value = org_mock mock_get_team.return_value = [] @@ -444,8 +444,8 @@ def test_filter_teams_overlapping_members_deduplicated( gh = MagicMock() mock_auth.return_value = gh org_mock = MagicMock() - org_mock.repositories.return_value = [repo] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = [repo] + gh.get_organization.return_value = org_mock # alice is in both teams mock_get_team.side_effect = [["alice", "bob"], ["alice", "charlie"]] diff --git a/test_pr_conflict_detector_flags.py b/test_pr_conflict_detector_flags.py index 23c11e3..64eb0d7 100644 --- a/test_pr_conflict_detector_flags.py +++ b/test_pr_conflict_detector_flags.py @@ -56,8 +56,8 @@ def test_dry_run_skips_issues( gh = MagicMock() mock_auth.return_value = gh org_mock = MagicMock() - org_mock.repositories.return_value = [repo] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = [repo] + gh.get_organization.return_value = org_mock mock_fetch.return_value = [_make_pr(1), _make_pr(2)] mock_detect.return_value = [MagicMock()] @@ -102,8 +102,8 @@ def test_issues_not_created_when_disabled( gh = MagicMock() mock_auth.return_value = gh org_mock = MagicMock() - org_mock.repositories.return_value = [repo] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = [repo] + gh.get_organization.return_value = org_mock pr1, pr2 = _make_pr(1), _make_pr(2) mock_fetch.return_value = [pr1, pr2] @@ -135,8 +135,8 @@ def test_issues_created_when_enabled( gh = MagicMock() mock_auth.return_value = gh org_mock = MagicMock() - org_mock.repositories.return_value = [repo] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = [repo] + gh.get_organization.return_value = org_mock pr1, pr2 = _make_pr(1), _make_pr(2) mock_fetch.return_value = [pr1, pr2] @@ -199,8 +199,8 @@ def _setup_mocks( mock_auth.return_value = gh repo = _make_repo("test-org/repo-a") org_mock = MagicMock() - org_mock.repositories.return_value = [repo] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = [repo] + gh.get_organization.return_value = org_mock mock_fetch.return_value = [_make_pr(1), _make_pr(2)] mock_detect.return_value = [conflict] @@ -395,8 +395,8 @@ def test_same_author_conflicts_filtered( gh = MagicMock() mock_auth.return_value = gh org_mock = MagicMock() - org_mock.repositories.return_value = [repo] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = [repo] + gh.get_organization.return_value = org_mock pr1 = _make_pr(1, author="alice") pr2 = _make_pr(2, author="alice") diff --git a/test_pr_conflict_detector_main.py b/test_pr_conflict_detector_main.py index d46cadd..7c2dba0 100644 --- a/test_pr_conflict_detector_main.py +++ b/test_pr_conflict_detector_main.py @@ -38,8 +38,8 @@ def test_main_with_organization( gh = MagicMock() mock_auth.return_value = gh org_mock = MagicMock() - org_mock.repositories.return_value = [repo] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = [repo] + gh.get_organization.return_value = org_mock pr1, pr2 = _make_pr(1), _make_pr(2) mock_fetch.return_value = [pr1, pr2] @@ -100,7 +100,7 @@ def test_main_with_repository_list( gh = MagicMock() mock_auth.return_value = gh repo = _make_repo("owner/repo-x") - gh.repository.return_value = repo + gh.get_repo.return_value = repo pr1, pr2 = _make_pr(1), _make_pr(2) mock_fetch.return_value = [pr1, pr2] @@ -108,7 +108,7 @@ def test_main_with_repository_list( main() - gh.repository.assert_called_once_with("owner", "repo-x") + gh.get_repo.assert_called_once_with("owner/repo-x") mock_fetch.assert_called_once() mock_detect.assert_called_once() # No conflicts → no issue created diff --git a/test_pr_conflict_detector_repos.py b/test_pr_conflict_detector_repos.py index 798d741..91f3b82 100644 --- a/test_pr_conflict_detector_repos.py +++ b/test_pr_conflict_detector_repos.py @@ -15,13 +15,13 @@ def test_get_repos_iterator_org(self): env_vars = _make_env_vars(organization="test-org", repository_list=[]) gh = MagicMock() org_mock = MagicMock() - org_mock.repositories.return_value = ["repo1", "repo2"] - gh.organization.return_value = org_mock + org_mock.get_repos.return_value = ["repo1", "repo2"] + gh.get_organization.return_value = org_mock result = get_repos_iterator(gh, env_vars) - gh.organization.assert_called_once_with("test-org") - org_mock.repositories.assert_called_once() + gh.get_organization.assert_called_once_with("test-org") + org_mock.get_repos.assert_called_once() self.assertEqual(result, ["repo1", "repo2"]) @@ -37,11 +37,11 @@ def test_get_repos_iterator_repo_list(self): gh = MagicMock() repo_a = MagicMock() repo_b = MagicMock() - gh.repository.side_effect = [repo_a, repo_b] + gh.get_repo.side_effect = [repo_a, repo_b] result = get_repos_iterator(gh, env_vars) - self.assertEqual(gh.repository.call_count, 2) - gh.repository.assert_any_call("owner", "repo-a") - gh.repository.assert_any_call("owner", "repo-b") + self.assertEqual(gh.get_repo.call_count, 2) + gh.get_repo.assert_any_call("owner/repo-a") + gh.get_repo.assert_any_call("owner/repo-b") self.assertEqual(result, [repo_a, repo_b]) diff --git a/test_pr_data.py b/test_pr_data.py index 1130e63..d670b9e 100644 --- a/test_pr_data.py +++ b/test_pr_data.py @@ -77,7 +77,7 @@ def _make_mock_pr( base_ref: str = "main", head_ref: str = "feature", ): - """Create a mock github3 pull request object.""" + """Create a mock PyGithub pull request object.""" pr = MagicMock() pr.number = number pr.title = title @@ -95,7 +95,7 @@ class TestGetOpenPrs(unittest.TestCase): def test_basic_pr_listing(self): """Should return PullRequestData objects for all open PRs.""" mock_repo = MagicMock() - mock_repo.pull_requests.return_value = [ + mock_repo.get_pulls.return_value = [ _make_mock_pr(number=1, title="First PR"), _make_mock_pr(number=2, title="Second PR"), ] @@ -106,12 +106,12 @@ def test_basic_pr_listing(self): self.assertEqual(result[0].number, 1) self.assertEqual(result[0].title, "First PR") self.assertEqual(result[1].number, 2) - mock_repo.pull_requests.assert_called_once_with(state="open") + mock_repo.get_pulls.assert_called_once_with(state="open") def test_filter_drafts(self): """When include_drafts=False, draft PRs should be excluded.""" mock_repo = MagicMock() - mock_repo.pull_requests.return_value = [ + mock_repo.get_pulls.return_value = [ _make_mock_pr(number=1, draft=False), _make_mock_pr(number=2, draft=True), _make_mock_pr(number=3, draft=False), @@ -126,7 +126,7 @@ def test_filter_drafts(self): def test_include_drafts(self): """When include_drafts=True (default), drafts should be included.""" mock_repo = MagicMock() - mock_repo.pull_requests.return_value = [ + mock_repo.get_pulls.return_value = [ _make_mock_pr(number=1, draft=True), _make_mock_pr(number=2, draft=True), ] @@ -140,7 +140,7 @@ def test_include_drafts(self): def test_empty_repo(self): """A repo with no open PRs should return an empty list.""" mock_repo = MagicMock() - mock_repo.pull_requests.return_value = [] + mock_repo.get_pulls.return_value = [] result = get_open_prs(mock_repo) @@ -149,7 +149,7 @@ def test_empty_repo(self): def test_pr_data_fields(self): """All PullRequestData fields should be correctly populated.""" mock_repo = MagicMock() - mock_repo.pull_requests.return_value = [ + mock_repo.get_pulls.return_value = [ _make_mock_pr( number=42, title="Add feature X", @@ -181,7 +181,7 @@ def _make_mock_file( changes: int = 7, patch_str: str | None = "@@ -1,3 +1,5 @@\n+new line", ): - """Create a mock github3 pull request file object.""" + """Create a mock PyGithub pull request file object.""" f = MagicMock() f.filename = filename f.additions = additions @@ -197,7 +197,7 @@ class TestGetPrChangedFiles(unittest.TestCase): def test_basic_changed_files(self): """Should return ChangedFile objects with parsed line ranges.""" mock_pr = MagicMock() - mock_pr.files.return_value = [ + mock_pr.get_files.return_value = [ _make_mock_file( filename="src/main.py", additions=3, @@ -219,7 +219,7 @@ def test_basic_changed_files(self): def test_binary_file_no_patch(self): """Binary files with no patch should have empty patch_lines.""" mock_pr = MagicMock() - mock_pr.files.return_value = [ + mock_pr.get_files.return_value = [ _make_mock_file(filename="image.png", patch_str=None), ] @@ -232,7 +232,7 @@ def test_binary_file_no_patch(self): def test_multiple_files(self): """Should handle multiple changed files.""" mock_pr = MagicMock() - mock_pr.files.return_value = [ + mock_pr.get_files.return_value = [ _make_mock_file(filename="a.py", patch_str="@@ -1,2 +1,3 @@\n+x"), _make_mock_file(filename="b.py", patch_str="@@ -5,4 +5,6 @@\n+y"), _make_mock_file(filename="c.bin", patch_str=None), @@ -248,7 +248,7 @@ def test_multiple_files(self): def test_empty_files_list(self): """A PR with no changed files should return empty list.""" mock_pr = MagicMock() - mock_pr.files.return_value = [] + mock_pr.get_files.return_value = [] result = get_pr_changed_files(mock_pr, MagicMock(), "owner", "repo") @@ -283,17 +283,17 @@ def test_basic_orchestration(self, mock_get_open_prs): mock_repo = MagicMock() mock_full_pr = MagicMock() - mock_full_pr.files.return_value = [ + mock_full_pr.get_files.return_value = [ _make_mock_file(filename="test.py", patch_str="@@ -1,2 +1,3 @@\n+x"), ] - mock_repo.pull_request.return_value = mock_full_pr + mock_repo.get_pull.return_value = mock_full_pr result = fetch_all_pr_data(mock_repo, True, MagicMock(), "owner", "repo") self.assertEqual(len(result), 2) self.assertEqual(len(result[0].changed_files), 1) self.assertEqual(result[0].changed_files[0].filename, "test.py") - self.assertEqual(mock_repo.pull_request.call_count, 2) + self.assertEqual(mock_repo.get_pull.call_count, 2) @patch("pr_data.get_open_prs") def test_empty_repo(self, mock_get_open_prs): @@ -329,7 +329,7 @@ def test_api_error_handling(self, mock_get_open_prs): mock_repo = MagicMock() mock_full_pr_good = MagicMock() - mock_full_pr_good.files.return_value = [ + mock_full_pr_good.get_files.return_value = [ _make_mock_file(filename="good.py"), ] @@ -338,7 +338,7 @@ def side_effect(number): raise RuntimeError("API rate limit exceeded") return mock_full_pr_good - mock_repo.pull_request.side_effect = side_effect + mock_repo.get_pull.side_effect = side_effect result = fetch_all_pr_data(mock_repo, True, MagicMock(), "owner", "repo") @@ -367,8 +367,8 @@ def test_progress_reporting(self, mock_print, mock_get_open_prs): mock_repo = MagicMock() mock_full_pr = MagicMock() - mock_full_pr.files.return_value = [] - mock_repo.pull_request.return_value = mock_full_pr + mock_full_pr.get_files.return_value = [] + mock_repo.get_pull.return_value = mock_full_pr fetch_all_pr_data(mock_repo, True, MagicMock(), "owner", "repo") @@ -423,10 +423,10 @@ def test_filter_authors_skips_file_fetch(self, mock_get_open_prs): mock_repo = MagicMock() mock_full_pr = MagicMock() - mock_full_pr.files.return_value = [ + mock_full_pr.get_files.return_value = [ _make_mock_file(filename="test.py", patch_str="@@ -1,2 +1,3 @@\n+x"), ] - mock_repo.pull_request.return_value = mock_full_pr + mock_repo.get_pull.return_value = mock_full_pr result = fetch_all_pr_data( mock_repo, @@ -442,7 +442,7 @@ def test_filter_authors_skips_file_fetch(self, mock_get_open_prs): authors = {pr.author for pr in result} self.assertEqual(authors, {"alice", "bob"}) # Only 2 API calls for files, not 3 - self.assertEqual(mock_repo.pull_request.call_count, 2) + self.assertEqual(mock_repo.get_pull.call_count, 2) @patch("pr_data.get_open_prs") def test_filter_authors_none_fetches_all(self, mock_get_open_prs): @@ -469,8 +469,8 @@ def test_filter_authors_none_fetches_all(self, mock_get_open_prs): mock_repo = MagicMock() mock_full_pr = MagicMock() - mock_full_pr.files.return_value = [] - mock_repo.pull_request.return_value = mock_full_pr + mock_full_pr.get_files.return_value = [] + mock_repo.get_pull.return_value = mock_full_pr result = fetch_all_pr_data( mock_repo, @@ -482,4 +482,4 @@ def test_filter_authors_none_fetches_all(self, mock_get_open_prs): ) self.assertEqual(len(result), 2) - self.assertEqual(mock_repo.pull_request.call_count, 2) + self.assertEqual(mock_repo.get_pull.call_count, 2) diff --git a/uv.lock b/uv.lock index 26dbbaa..93ab068 100644 --- a/uv.lock +++ b/uv.lock @@ -449,21 +449,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, ] -[[package]] -name = "github3-py" -version = "4.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyjwt", extra = ["crypto"] }, - { name = "python-dateutil" }, - { name = "requests" }, - { name = "uritemplate" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/89/91/603bcaf8cd1b3927de64bf56c3a8915f6653ea7281919140c5bcff2bfe7b/github3.py-4.0.1.tar.gz", hash = "sha256:30d571076753efc389edc7f9aaef338a4fcb24b54d8968d5f39b1342f45ddd36", size = 36214038, upload-time = "2023-04-26T17:56:37.677Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/2394d4fb542574678b0ba342daf734d4d811768da3c2ee0c84d509dcb26c/github3.py-4.0.1-py3-none-any.whl", hash = "sha256:a89af7de25650612d1da2f0609622bcdeb07ee8a45a1c06b2d16a05e4234e753", size = 151800, upload-time = "2023-04-26T17:56:25.015Z" }, -] - [[package]] name = "idna" version = "3.15" @@ -674,9 +659,7 @@ name = "pr-conflict-detector" version = "1.0.0" source = { virtual = "." } dependencies = [ - { name = "cryptography" }, - { name = "github3-py" }, - { name = "pyjwt" }, + { name = "pygithub" }, { name = "python-dotenv" }, { name = "requests" }, ] @@ -694,9 +677,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "cryptography", specifier = "==49.0.0" }, - { name = "github3-py", specifier = "==4.0.1" }, - { name = "pyjwt", specifier = "==2.13.0" }, + { name = "pygithub", specifier = ">=2.6.0" }, { name = "python-dotenv", specifier = "==1.2.2" }, { name = "requests", specifier = "==2.34.2" }, ] @@ -739,6 +720,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, ] +[[package]] +name = "pygithub" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "pynacl" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/c3/8465a311197e16cf5ab68789fe689535e90f6b61ab524cc32a39e67237ae/pygithub-2.9.1.tar.gz", hash = "sha256:59771d7ff63d54d427be2e7d0dad2208dfffc2b0a045fec959263787739b611c", size = 2594989, upload-time = "2026-04-14T07:26:13.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/aa/81a5506f089a26338bff17535e4339b3b22049ebd1bcdeff756c4d7a7559/pygithub-2.9.1-py3-none-any.whl", hash = "sha256:2ec78fca30092d51a42d76f4ddb02131b6f0c666a35dfdf364cf302cdda115b9", size = 449710, upload-time = "2026-04-14T07:26:12.382Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -780,6 +777,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/da/acb2e7d4dbd2dfb792d38c0d850481f29ad7049b356d23f56c687d35203b/pylint-4.0.6-py3-none-any.whl", hash = "sha256:d11a0e1fdb7b1cd46ec5d6fc78fee8b95f28695b2d6140e5809925f61e32ea54", size = 538389, upload-time = "2026-06-14T14:43:24.873Z" }, ] +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + [[package]] name = "pytest" version = "9.1.0" @@ -810,18 +842,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - [[package]] name = "python-dotenv" version = "1.2.2" @@ -880,15 +900,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "tomli" version = "2.4.0" @@ -973,15 +984,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] -[[package]] -name = "uritemplate" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, -] - [[package]] name = "urllib3" version = "2.7.0"