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'],