This is an automated email from the ASF dual-hosted git repository.

raulcd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow.git


The following commit(s) were added to refs/heads/main by this push:
     new 98ee71ec2b GH-33241: [Archery] Replace github3 with pygithub (#48886)
98ee71ec2b is described below

commit 98ee71ec2be85d3ac535be367c6edacc0bc09c7c
Author: Fangchen Li <[email protected]>
AuthorDate: Fri Jun 19 03:20:02 2026 -0700

    GH-33241: [Archery] Replace github3 with pygithub (#48886)
    
    ### Rationale for this change
    Archery currently uses both pygithub and github3. It's unnecessary and 
increases maintenance burden.
    
    ### What changes are included in this PR?
    Replace all github3 usage with pygithub.
    
    ### Are these changes tested?
    Yes, unittests added.
    
    ### Are there any user-facing changes?
    No.
    
    * GitHub Issue: #33241
    
    Authored-by: Fangchen Li <[email protected]>
    Signed-off-by: Raúl Cumplido <[email protected]>
---
 ci/conda_env_archery.txt                        |   1 -
 ci/conda_env_crossbow.txt                       |   2 +-
 dev/archery/README.md                           |   2 -
 dev/archery/archery/crossbow/cli.py             |  11 +-
 dev/archery/archery/crossbow/core.py            | 146 +++++------
 dev/archery/archery/crossbow/tests/test_core.py | 316 ++++++++++++++++++++++++
 dev/archery/setup.py                            |   4 +-
 7 files changed, 379 insertions(+), 103 deletions(-)

diff --git a/ci/conda_env_archery.txt b/ci/conda_env_archery.txt
index ace7a42acb..f1de5b1989 100644
--- a/ci/conda_env_archery.txt
+++ b/ci/conda_env_archery.txt
@@ -19,7 +19,6 @@
 click
 
 # bot, crossbow
-github3.py
 jinja2
 jira
 pygit2
diff --git a/ci/conda_env_crossbow.txt b/ci/conda_env_crossbow.txt
index 347294650c..c99de6b472 100644
--- a/ci/conda_env_crossbow.txt
+++ b/ci/conda_env_crossbow.txt
@@ -16,10 +16,10 @@
 # under the License.
 
 click
-github3.py
 jinja2
 jira
 pygit2
+pygithub
 ruamel.yaml
 setuptools_scm
 toolz
diff --git a/dev/archery/README.md b/dev/archery/README.md
index a82e9edaa5..febdbf88f0 100644
--- a/dev/archery/README.md
+++ b/dev/archery/README.md
@@ -41,8 +41,6 @@ to use the functionality of it:
   To install: `pip install -e "arrow/dev/archery[release]"`
 * crossbow – to trigger + interact with the crossbow build system
   To install: `pip install -e "arrow/dev/archery[crossbow]"`
-* crossbow-upload
-  To install: `pip install -e "arrow/dev/archery[crossbow-upload]"`
 
 Additionally, if you would prefer to install everything at once,
 `pip install -e "arrow/dev/archery[all]"` is an alias for all of
diff --git a/dev/archery/archery/crossbow/cli.py 
b/dev/archery/archery/crossbow/cli.py
index 10aa3dedf4..35e0d4d2c0 100644
--- a/dev/archery/archery/crossbow/cli.py
+++ b/dev/archery/archery/crossbow/cli.py
@@ -531,13 +531,13 @@ def download_artifacts(obj, job_name, target_dir, 
dry_run, fetch,
                 return False
 
             if need_download():
-                import github3
+                from github import GithubException
                 max_n_retries = 5
                 n_retries = 0
                 while True:
                     try:
-                        asset.download(path)
-                    except github3.exceptions.GitHubException as error:
+                        asset.download_asset(str(path))
+                    except GithubException as error:
                         n_retries += 1
                         if n_retries == max_n_retries:
                             raise
@@ -565,12 +565,11 @@ def download_artifacts(obj, job_name, target_dir, 
dry_run, fetch,
 @click.argument('patterns', nargs=-1, required=True)
 @click.option('--sha', required=True, help='Target committish')
 @click.option('--tag', required=True, help='Target tag')
[email protected]('--method', default='curl', help='Use cURL to upload')
 @click.pass_obj
-def upload_artifacts(obj, tag, sha, patterns, method):
+def upload_artifacts(obj, tag, sha, patterns):
     queue = obj['queue']
     queue.github_overwrite_release_assets(
-        tag_name=tag, target_commitish=sha, method=method, patterns=patterns
+        tag_name=tag, target_commitish=sha, patterns=patterns
     )
 
 
diff --git a/dev/archery/archery/crossbow/core.py 
b/dev/archery/archery/crossbow/core.py
index f12afc082f..0a9d1bd5d3 100644
--- a/dev/archery/archery/crossbow/core.py
+++ b/dev/archery/archery/crossbow/core.py
@@ -22,7 +22,6 @@ import glob
 import time
 import logging
 import mimetypes
-import subprocess
 import textwrap
 import uuid
 from io import StringIO
@@ -34,11 +33,11 @@ import jinja2
 from ruamel.yaml import YAML
 
 try:
-    import github3
-    _have_github3 = True
+    from github import Github, GithubException
+    from github import Auth as GithubAuth
+    _have_github = True
 except ImportError:
-    github3 = object
-    _have_github3 = False
+    _have_github = False
 
 try:
     import pygit2
@@ -52,7 +51,7 @@ else:
 from ..utils.source import ArrowSources
 
 
-for pkg in ["requests", "urllib3", "github3"]:
+for pkg in ["requests", "urllib3", "github"]:
     logging.getLogger(pkg).setLevel(logging.WARNING)
 
 logger = logging.getLogger("crossbow")
@@ -448,52 +447,48 @@ class Repo:
         blob = self.repo[entry.id]
         return blob.data
 
-    def _github_login(self, github_token):
-        """Returns a logged in github3.GitHub instance"""
-        if not _have_github3:
-            raise ImportError('Must install github3.py')
+    def _github_login(self, github_token=None):
+        """Returns a logged in Github instance using PyGithub"""
+        if not _have_github:
+            raise ImportError('Must install PyGithub')
         github_token = github_token or self.github_token
-        session = github3.session.GitHubSession(
-            default_connect_timeout=10,
-            default_read_timeout=30
-        )
-        github = github3.GitHub(session=session)
-        github.login(token=github_token)
-        return github
+        return Github(auth=GithubAuth.Token(github_token), timeout=30)
 
     def as_github_repo(self, github_token=None):
         """Converts it to a repository object which wraps the GitHub API"""
         if self._github_repo is None:
             github = self._github_login(github_token)
             username, reponame = _parse_github_user_repo(self.remote_url)
-            self._github_repo = github.repository(username, reponame)
+            self._github_repo = github.get_repo(f"{username}/{reponame}")
         return self._github_repo
 
     def token_expiration_date(self, github_token=None):
         """Returns the expiration date for the github_token provided"""
         github = self._github_login(github_token)
-        # github3 hides the headers from us. Use the _get method
-        # to access the response headers.
-        resp = github._get(github.session.base_url)
-        # Response in the form '2023-01-23 10:40:28 UTC'
-        date_string = resp.headers.get(
-            'github-authentication-token-expiration')
+        # PyGithub doesn't expose the token expiration header through a
+        # dedicated API, so request it via the public Requester escape hatch.
+        headers, _ = github.requester.requestJsonAndCheck("GET", "/user")
+        # Response header in the form '2023-01-23 10:40:28 UTC'
+        date_string = headers.get('github-authentication-token-expiration')
         if date_string:
             return date.fromisoformat(date_string.split()[0])
+        return None
 
     def github_commit(self, sha):
         repo = self.as_github_repo()
-        return repo.commit(sha)
+        return repo.get_commit(sha)
 
     def github_release(self, tag):
         repo = self.as_github_repo()
         try:
-            return repo.release_from_tag(tag)
-        except github3.exceptions.NotFoundError:
-            return None
-
-    def github_upload_asset_requests(self, release, path, name, mime,
-                                     max_retries=None, retry_backoff=None):
+            return repo.get_release(tag)
+        except GithubException as e:
+            if e.status == 404:
+                return None
+            raise
+
+    def github_upload_asset(self, release, path, name, mime,
+                            max_retries=None, retry_backoff=None):
         if max_retries is None:
             max_retries = int(os.environ.get('CROSSBOW_MAX_RETRIES', 8))
         if retry_backoff is None:
@@ -501,57 +496,36 @@ class Repo:
 
         for i in range(max_retries):
             try:
-                with open(path, 'rb') as fp:
-                    result = release.upload_asset(name=name, asset=fp,
-                                                  content_type=mime)
-            except github3.exceptions.ResponseError as e:
+                result = release.upload_asset(path, name=name,
+                                              content_type=mime)
+                logger.info(f"Attempt {i + 1} has finished.")
+                return result
+            except GithubException as e:
                 logger.error(f"Attempt {i + 1} has failed with message: {e}.")
-                logger.error(f"Error message {e.msg}")
-                logger.error("List of errors provided by GitHub:")
-                for err in e.errors:
-                    logger.error(f" - {err}")
+                if hasattr(e, 'data'):
+                    logger.error(f"Error data: {e.data}")
 
-                if e.code == 422:
+                if e.status == 422:
                     # 422 Validation Failed, probably raised because
                     # ReleaseAsset already exists, so try to remove it before
                     # reattempting the asset upload
-                    for asset in release.assets():
+                    for asset in release.get_assets():
                         if asset.name == name:
                             logger.info(f"Release asset {name} already exists, 
"
                                         "removing it...")
-                            asset.delete()
+                            asset.delete_asset()
                             logger.info(f"Asset {name} removed.")
                             break
-            except github3.exceptions.ConnectionError as e:
+            except IOError as e:
+                # Catch network and file I/O errors (includes requests 
exceptions)
                 logger.error(f"Attempt {i + 1} has failed with message: {e}.")
-            else:
-                logger.info(f"Attempt {i + 1} has finished.")
-                return result
 
             time.sleep(retry_backoff)
 
         raise RuntimeError('GitHub asset uploading has failed!')
 
-    def github_upload_asset_curl(self, release, path, name, mime):
-        upload_url, _ = release.upload_url.split('{?')
-        upload_url += f"?name={name}"
-
-        command = [
-            'curl',
-            '--fail',
-            '-H', f"Authorization: token {self.github_token}",
-            '-H', f"Content-Type: {mime}",
-            '--data-binary', f'@{path}',
-            upload_url
-        ]
-        return subprocess.run(command, shell=False, check=True)
-
     def github_overwrite_release_assets(self, tag_name, target_commitish,
-                                        patterns, method='requests'):
-        # Since github has changed something the asset uploading via requests
-        # got instable, so prefer the cURL alternative.
-        # Potential cause:
-        #    sigmavirus24/github3.py/issues/779#issuecomment-379470626
+                                        patterns):
         repo = self.as_github_repo()
         if not tag_name:
             raise CrossbowError('Empty tag name')
@@ -560,13 +534,14 @@ class Repo:
 
         # remove the whole release if it already exists
         try:
-            release = repo.release_from_tag(tag_name)
-        except github3.exceptions.NotFoundError:
-            pass
-        else:
-            release.delete()
-
-        release = repo.create_release(tag_name, target_commitish)
+            release = repo.get_release(tag_name)
+            release.delete_release()
+        except GithubException as e:
+            if e.status != 404:
+                raise
+
+        release = repo.create_git_release(tag_name, tag_name, "",
+                                          target_commitish=target_commitish)
         for pattern in patterns:
             for path in glob.glob(pattern, recursive=True):
                 name = os.path.basename(path)
@@ -578,16 +553,7 @@ class Repo:
                     f"{size}..."
                 )
 
-                if method == 'requests':
-                    self.github_upload_asset_requests(release, path, name=name,
-                                                      mime=mime)
-                elif method == 'curl':
-                    self.github_upload_asset_curl(release, path, name=name,
-                                                  mime=mime)
-                else:
-                    raise CrossbowError(
-                        f"Unsupported upload method {method}"
-                    )
+                self.github_upload_asset(release, path, name=name, mime=mime)
 
     def github_pr(self, title, head=None, base=None, body=None,
                   github_token=None, create=False):
@@ -598,12 +564,11 @@ class Repo:
         repo = self.as_github_repo(github_token=github_token)
         if create:
             return repo.create_pull(title=title, base=base, head=head,
-                                    body=body)
+                                    body=body or "")
         else:
             # Retrieve open PR for base and head.
             # There should be a single open one with that title.
-            for pull in repo.pull_requests(state="open", head=head,
-                                           base=base):
+            for pull in repo.get_pulls(state="open", head=head, base=base):
                 if title in pull.title:
                     return pull
             raise CrossbowError(
@@ -1005,7 +970,7 @@ class TaskStatus:
 
     Parameters
     ----------
-    commit : github3.Commit
+    commit : github.Commit.Commit
         Commit to query the combined status for.
 
     Returns
@@ -1019,8 +984,8 @@ class TaskStatus:
     """
 
     def __init__(self, commit):
-        status = commit.status()
-        check_runs = list(commit.check_runs())
+        status = commit.get_combined_status()
+        check_runs = list(commit.get_check_runs())
         states = [s.state for s in status.statuses]
 
         for check in check_runs:
@@ -1068,7 +1033,7 @@ class TaskAssets(dict):
         if github_release is None:
             github_assets = {}  # no assets have been uploaded for the task
         else:
-            github_assets = {a.name: a for a in github_release.assets()}
+            github_assets = {a.name: a for a in github_release.get_assets()}
 
         if not validate_patterns:
             # shortcut to avoid pattern validation and just set all artifacts
@@ -1088,9 +1053,10 @@ class TaskAssets(dict):
             elif num_matches == 1:
                 self[pattern] = github_assets[matches[0].group(0)]
             else:
+                matched_names = [m.group(0) for m in matches]
                 raise CrossbowError(
                     f"Only a single asset should match pattern `{pattern}`, "
-                    f"there are multiple ones: {', '.join(matches)}"
+                    f"there are multiple ones: {', '.join(matched_names)}"
                 )
 
     def missing_patterns(self):
diff --git a/dev/archery/archery/crossbow/tests/test_core.py 
b/dev/archery/archery/crossbow/tests/test_core.py
index 3d538b89b2..9a38ca75d7 100644
--- a/dev/archery/archery/crossbow/tests/test_core.py
+++ b/dev/archery/archery/crossbow/tests/test_core.py
@@ -17,10 +17,15 @@
 
 from archery.utils.source import ArrowSources
 from archery.crossbow import Config, Queue
+from archery.crossbow.core import CrossbowError, Repo, TaskAssets, TaskStatus
 
 import pathlib
+from datetime import date
 from unittest import mock
 
+import pytest
+from github import GithubException
+
 
 def test_config():
     src = ArrowSources.find()
@@ -93,3 +98,314 @@ def test_latest_for_prefix(request):
             queue.latest_for_prefix("nightly-packaging")
             mocked_get.assert_called_once_with(
                 "nightly-packaging-2022-04-11-0")
+
+
+def test_github_release_found():
+    """Test github_release looks up a release by its tag name."""
+    with mock.patch.object(Repo, 'as_github_repo') as mock_repo:
+        mock_release = mock.MagicMock()
+        mock_repo.return_value.get_release.return_value = mock_release
+
+        repo = Repo('/tmp/test', github_token='test_token')
+        result = repo.github_release('nightly-packaging-2022-04-10-0')
+
+        # PyGithub's get_release dispatches str arguments to the
+        # /releases/tags/{tag} endpoint, so the tag must be passed through
+        # as a string
+        mock_repo.return_value.get_release.assert_called_once_with(
+            'nightly-packaging-2022-04-10-0')
+        assert result == mock_release
+
+
+def test_github_release_not_found():
+    """Test github_release returns None for 404."""
+    with mock.patch.object(Repo, 'as_github_repo') as mock_repo:
+        mock_repo.return_value.get_release.side_effect = GithubException(
+            404, {'message': 'Not Found'}, None
+        )
+        repo = Repo('/tmp/test', github_token='test_token')
+        result = repo.github_release('nonexistent')
+        assert result is None
+
+
+def test_github_pr_create():
+    """Test creating a pull request via PyGithub."""
+    with mock.patch.object(Repo, 'as_github_repo') as mock_repo:
+        with mock.patch.object(Repo, 'default_branch_name',
+                               new_callable=mock.PropertyMock,
+                               return_value='main'):
+            mock_pr = mock.MagicMock()
+            mock_repo.return_value.create_pull.return_value = mock_pr
+
+            repo = Repo('/tmp/test', github_token='test_token')
+            result = repo.github_pr('Test PR', head='feature',
+                                    base='main', create=True)
+
+            mock_repo.return_value.create_pull.assert_called_once()
+            assert result == mock_pr
+
+
+def test_github_pr_find():
+    """Test finding an existing pull request."""
+    with mock.patch.object(Repo, 'as_github_repo') as mock_repo:
+        mock_pr = mock.MagicMock()
+        mock_pr.title = 'Test PR Title'
+        mock_repo.return_value.get_pulls.return_value = [mock_pr]
+
+        repo = Repo('/tmp/test', github_token='test_token')
+        result = repo.github_pr('Test PR', head='feature', base='main')
+
+        assert result == mock_pr
+
+
+def test_task_status_success():
+    """Test TaskStatus with successful commit status."""
+    mock_commit = mock.MagicMock()
+    mock_status = mock.MagicMock()
+    mock_status.statuses = [
+        mock.MagicMock(state='success', target_url='http://example.com')
+    ]
+    mock_commit.get_combined_status.return_value = mock_status
+    mock_commit.get_check_runs.return_value = []
+
+    status = TaskStatus(mock_commit)
+    assert status.combined_state == 'success'
+
+
+def test_task_status_failure():
+    """Test TaskStatus with failed commit status."""
+    mock_commit = mock.MagicMock()
+    mock_status = mock.MagicMock()
+    mock_status.statuses = [
+        mock.MagicMock(state='failure', target_url='http://example.com')
+    ]
+    mock_commit.get_combined_status.return_value = mock_status
+    mock_commit.get_check_runs.return_value = []
+
+    status = TaskStatus(mock_commit)
+    assert status.combined_state == 'failure'
+
+
+def test_task_status_with_check_runs():
+    """Test TaskStatus with GitHub check runs."""
+    mock_commit = mock.MagicMock()
+    mock_status = mock.MagicMock()
+    mock_status.statuses = []
+    mock_commit.get_combined_status.return_value = mock_status
+
+    mock_check = mock.MagicMock()
+    mock_check.status = 'completed'
+    mock_check.conclusion = 'failure'
+    mock_check.html_url = 'https://example.com'
+    mock_commit.get_check_runs.return_value = [mock_check]
+
+    status = TaskStatus(mock_commit)
+    assert status.combined_state == 'failure'
+
+
+def test_task_status_pending():
+    """Test TaskStatus with pending check runs."""
+    mock_commit = mock.MagicMock()
+    mock_status = mock.MagicMock()
+    mock_status.statuses = []
+    mock_commit.get_combined_status.return_value = mock_status
+
+    mock_check = mock.MagicMock()
+    mock_check.status = 'in_progress'
+    mock_check.html_url = 'https://example.com'
+    mock_commit.get_check_runs.return_value = [mock_check]
+
+    status = TaskStatus(mock_commit)
+    assert status.combined_state == 'pending'
+
+
+def test_token_expiration_date():
+    """Test token_expiration_date returns correct date from header."""
+    with mock.patch.object(Repo, '_github_login') as mock_login:
+        mock_github = mock.MagicMock()
+        mock_login.return_value = mock_github
+        mock_github.requester.requestJsonAndCheck.return_value = (
+            {'github-authentication-token-expiration': '2024-12-31 23:59:59 
UTC'},
+            {}
+        )
+
+        repo = Repo('/tmp/test', github_token='test_token')
+        result = repo.token_expiration_date()
+
+        assert result == date(2024, 12, 31)
+
+
+def test_token_expiration_date_no_header():
+    """Test token_expiration_date returns None when header is missing."""
+    with mock.patch.object(Repo, '_github_login') as mock_login:
+        mock_github = mock.MagicMock()
+        mock_login.return_value = mock_github
+        mock_github.requester.requestJsonAndCheck.return_value = (
+            {},
+            {}
+        )
+
+        repo = Repo('/tmp/test', github_token='test_token')
+        result = repo.token_expiration_date()
+
+        assert result is None
+
+
+def test_github_upload_asset_success():
+    """Test successful asset upload."""
+    with mock.patch.object(Repo, 'as_github_repo'):
+        repo = Repo('/tmp/test', github_token='test_token')
+
+        mock_release = mock.MagicMock()
+        mock_asset = mock.MagicMock()
+        mock_release.upload_asset.return_value = mock_asset
+
+        result = repo.github_upload_asset(
+            mock_release, '/path/to/file', 'file.tar.gz', 'application/gzip'
+        )
+
+        assert result == mock_asset
+        mock_release.upload_asset.assert_called_once()
+
+
+def test_github_upload_asset_retry_on_422():
+    """Test asset upload retries after deleting existing asset on 422."""
+    with mock.patch.object(Repo, 'as_github_repo'):
+        repo = Repo('/tmp/test', github_token='test_token')
+
+        mock_release = mock.MagicMock()
+        mock_asset = mock.MagicMock()
+        mock_existing_asset = mock.MagicMock()
+        mock_existing_asset.name = 'file.tar.gz'
+
+        # First call raises 422, second succeeds
+        mock_release.upload_asset.side_effect = [
+            GithubException(422, {'message': 'Validation Failed'}, None),
+            mock_asset
+        ]
+        mock_release.get_assets.return_value = [mock_existing_asset]
+
+        with mock.patch('time.sleep'):  # Skip actual sleep
+            result = repo.github_upload_asset(
+                mock_release, '/path/to/file', 'file.tar.gz',
+                'application/gzip', max_retries=3, retry_backoff=0
+            )
+
+        assert result == mock_asset
+        mock_existing_asset.delete_asset.assert_called_once()
+
+
+def test_github_overwrite_release_assets_creates_new():
+    """Test creating a new release when none exists."""
+    with mock.patch.object(Repo, 'as_github_repo') as mock_repo_method:
+        mock_repo = mock.MagicMock()
+        mock_repo_method.return_value = mock_repo
+        mock_release = mock.MagicMock()
+
+        # Release doesn't exist (404)
+        mock_repo.get_release.side_effect = GithubException(
+            404, {'message': 'Not Found'}, None
+        )
+        mock_repo.create_git_release.return_value = mock_release
+
+        repo = Repo('/tmp/test', github_token='test_token')
+
+        with mock.patch('glob.glob', return_value=[]):
+            repo.github_overwrite_release_assets(
+                'v1.0.0', 'abc123', ['*.tar.gz']
+            )
+
+        mock_repo.create_git_release.assert_called_once_with(
+            'v1.0.0', 'v1.0.0', '', target_commitish='abc123'
+        )
+
+
+def test_github_overwrite_release_assets_deletes_existing():
+    """Test deleting existing release before creating new one."""
+    with mock.patch.object(Repo, 'as_github_repo') as mock_repo_method:
+        mock_repo = mock.MagicMock()
+        mock_repo_method.return_value = mock_repo
+        mock_existing_release = mock.MagicMock()
+        mock_new_release = mock.MagicMock()
+
+        mock_repo.get_release.return_value = mock_existing_release
+        mock_repo.create_git_release.return_value = mock_new_release
+
+        repo = Repo('/tmp/test', github_token='test_token')
+
+        with mock.patch('glob.glob', return_value=[]):
+            repo.github_overwrite_release_assets(
+                'v1.0.0', 'abc123', ['*.tar.gz']
+            )
+
+        mock_existing_release.delete_release.assert_called_once()
+        mock_repo.create_git_release.assert_called_once()
+
+
+def test_task_assets_empty_patterns():
+    """Test TaskAssets returns empty dict for empty artifact patterns."""
+    mock_release = mock.MagicMock()
+    assets = TaskAssets(mock_release, [])
+    assert len(assets) == 0
+    # Verify get_assets was never called (early return)
+    mock_release.get_assets.assert_not_called()
+
+
+def test_task_assets_no_release():
+    """Test TaskAssets with None release sets patterns to None."""
+    assets = TaskAssets(None, ['file.tar.gz'])
+    assert assets['file.tar.gz'] is None
+    assert assets.missing_patterns() == ['file.tar.gz']
+    assert assets.uploaded_assets() == []
+
+
+def test_task_assets_pattern_matching():
+    """Test TaskAssets matches patterns to assets."""
+    mock_release = mock.MagicMock()
+    mock_asset = mock.MagicMock()
+    mock_asset.name = 'arrow-1.0.0.tar.gz'
+    mock_release.get_assets.return_value = [mock_asset]
+
+    assets = TaskAssets(mock_release, ['arrow-1.0.0.tar.gz'])
+    assert assets['arrow-1.0.0.tar.gz'] == mock_asset
+    assert assets.missing_patterns() == []
+    assert assets.uploaded_assets() == [mock_asset]
+
+
+def test_task_assets_regex_pattern():
+    """Test TaskAssets with regex patterns."""
+    mock_release = mock.MagicMock()
+    mock_asset = mock.MagicMock()
+    mock_asset.name = 'arrow-1.0.0-linux-x86_64.tar.gz'
+    mock_release.get_assets.return_value = [mock_asset]
+
+    assets = TaskAssets(mock_release, [r'arrow-.*\.tar\.gz'])
+    assert assets[r'arrow-.*\.tar\.gz'] == mock_asset
+
+
+def test_task_assets_multiple_matches_error():
+    """Test TaskAssets raises error when pattern matches multiple assets."""
+    mock_release = mock.MagicMock()
+    mock_asset1 = mock.MagicMock()
+    mock_asset1.name = 'arrow-1.0.0-linux.tar.gz'
+    mock_asset2 = mock.MagicMock()
+    mock_asset2.name = 'arrow-1.0.0-darwin.tar.gz'
+    mock_release.get_assets.return_value = [mock_asset1, mock_asset2]
+
+    with pytest.raises(CrossbowError, match="Only a single asset should 
match"):
+        TaskAssets(mock_release, [r'arrow-.*\.tar\.gz'])
+
+
+def test_task_assets_skip_validation():
+    """Test TaskAssets without pattern validation returns all assets."""
+    mock_release = mock.MagicMock()
+    mock_asset1 = mock.MagicMock()
+    mock_asset1.name = 'file1.tar.gz'
+    mock_asset2 = mock.MagicMock()
+    mock_asset2.name = 'file2.tar.gz'
+    mock_release.get_assets.return_value = [mock_asset1, mock_asset2]
+
+    assets = TaskAssets(mock_release, ['unused-pattern'],
+                        validate_patterns=False)
+    assert assets['file1.tar.gz'] == mock_asset1
+    assert assets['file2.tar.gz'] == mock_asset2
diff --git a/dev/archery/setup.py b/dev/archery/setup.py
index 23bb1096d7..47ae2bb26b 100755
--- a/dev/archery/setup.py
+++ b/dev/archery/setup.py
@@ -29,10 +29,8 @@ jinja_req = 'jinja2>=2.11'
 
 extras = {
     'benchmark': ['pandas'],
-    'crossbow': ['github3.py', jinja_req, 'pygit2>=1.14.0', 'requests',
+    'crossbow': [jinja_req, 'pygit2>=1.14.0', 'pygithub>=2.5.0', 'requests',
                  'ruamel.yaml', 'setuptools_scm>=8.0.0'],
-    'crossbow-upload': ['github3.py', jinja_req, 'ruamel.yaml',
-                        'setuptools_scm'],
     'docker': ['ruamel.yaml', 'python-dotenv'],
     'integration': ['cffi', 'numpy'],
     'integration-java': ['jpype1'],

Reply via email to