1
0
mirror of https://gitlab.com/fdroid/fdroidserver.git synced 2024-11-13 02:30:11 +01:00

Merge branch 'gh-releases' into 'master'

🛰️  deploy: github releases

See merge request fdroid/fdroidserver!1471
This commit is contained in:
Hans-Christoph Steiner 2024-06-28 13:21:55 +00:00
commit 4a362541bd
6 changed files with 768 additions and 5 deletions

View File

@ -211,6 +211,37 @@
# - url: https://gitlab.com/user/repo # - url: https://gitlab.com/user/repo
# index_only: true # index_only: true
# These settings allow using `fdroid deploy` for publishing APK files from
# your repository to GitHub Releases. (You should also run `fdroid update`
# every time before deploying to GitHub releases to update index files.) Here's
# an example for this deployment automation:
# https://github.com/f-droid/fdroidclient/releases/
#
# Currently, versions which are assigned to a release channel (e.g. alpha or
# beta releases) are ignored.
#
# In the example below, tokens are read from environment variables. Putting
# tokens directly into the config file is also supported but discouraged. It is
# highly recommended to use a "Fine-grained personal access token", which is
# restricted to the minimum required permissions, which are:
# * Metadata - read
# * Contents - read/write
# (https://github.com/settings/personal-access-tokens/new)
#
# github_token: {env: GITHUB_TOKEN}
# github_releases:
# - projectUrl: https://github.com/f-droid/fdroidclient
# packageNames:
# - org.fdroid.basic
# - org.fdroid.fdroid
# release_notes_prepend: |
# Re-post of official F-Droid App release from https://f-droid.org
# - projectUrl: https://github.com/example/app
# packageNames: com.example.app
# token: {env: GITHUB_TOKEN_EXAMPLE}
# Most git hosting services have hard size limits for each git repo. # Most git hosting services have hard size limits for each git repo.
# `fdroid deploy` will delete the git history when the git mirror repo # `fdroid deploy` will delete the git history when the git mirror repo
# approaches this limit to ensure that the repo will still fit when # approaches this limit to ensure that the repo will still fit when

View File

@ -31,9 +31,10 @@ import yaml
from argparse import ArgumentParser from argparse import ArgumentParser
import logging import logging
from shlex import split from shlex import split
import pathlib
import shutil import shutil
import git import git
from pathlib import Path import fdroidserver.github
from . import _ from . import _
from . import common from . import common
@ -663,7 +664,7 @@ def update_servergitmirrors(servergitmirrors, repo_section):
return return
options = common.get_options() options = common.get_options()
workspace_dir = Path(os.getcwd()) workspace_dir = pathlib.Path(os.getcwd())
# right now we support only 'repo' git-mirroring # right now we support only 'repo' git-mirroring
if repo_section == 'repo': if repo_section == 'repo':
@ -1115,6 +1116,139 @@ def push_binary_transparency(git_repo_path, git_remote):
raise FDroidException(_("Pushing to remote server failed!")) raise FDroidException(_("Pushing to remote server failed!"))
def find_release_infos(index_v2_path, repo_dir, package_names):
"""Find files, texts, etc. for uploading to a release page in index-v2.json.
This function parses index-v2.json for file-paths elegible for deployment
to release pages. (e.g. GitHub releases) It also groups these files by
packageName and versionName. e.g. to get a list of files for all specific
release of fdroid client you may call:
find_binary_release_infos()['org.fdroid.fdroid']['0.19.2']
All paths in the returned data-structure are of type pathlib.Path.
"""
release_infos = {}
with open(index_v2_path, 'r') as f:
idx = json.load(f)
for package_name in package_names:
package = idx.get('packages', {}).get(package_name, {})
for version in package.get('versions', {}).values():
if package_name not in release_infos:
release_infos[package_name] = {}
version_name = version['manifest']['versionName']
version_path = repo_dir / version['file']['name'].lstrip("/")
files = [version_path]
asc_path = pathlib.Path(str(version_path) + '.asc')
if asc_path.is_file():
files.append(asc_path)
sig_path = pathlib.Path(str(version_path) + '.sig')
if sig_path.is_file():
files.append(sig_path)
release_infos[package_name][version_name] = {
'files': files,
'whatsNew': version.get('whatsNew', {}).get("en-US"),
'hasReleaseChannels': len(version.get('releaseChannels', [])) > 0,
}
return release_infos
def upload_to_github_releases(repo_section, gh_config, global_gh_token):
repo_dir = pathlib.Path(repo_section)
index_v2_path = repo_dir / 'index-v2.json'
if not index_v2_path.is_file():
logging.warning(
_(
"Error deploying 'github_releases', {} not present. (You might "
"need to run `fdroid update` first.)"
).format(index_v2_path)
)
return
package_names = []
for repo_conf in gh_config:
for package_name in repo_conf.get('packageNames', []):
package_names.append(package_name)
release_infos = fdroidserver.deploy.find_release_infos(
index_v2_path, repo_dir, package_names
)
for repo_conf in gh_config:
upload_to_github_releases_repo(repo_conf, release_infos, global_gh_token)
def upload_to_github_releases_repo(repo_conf, release_infos, global_gh_token):
projectUrl = repo_conf.get("projectUrl")
if not projectUrl:
logging.warning(
_(
"One of the 'github_releases' config items is missing the "
"'projectUrl' value. skipping ..."
)
)
return
token = repo_conf.get("token") or global_gh_token
if not token:
logging.warning(
_(
"One of the 'github_releases' config itmes is missing the "
"'token' value. skipping ..."
)
)
return
conf_package_names = repo_conf.get("packageNames", [])
if type(conf_package_names) == str:
conf_package_names = [conf_package_names]
if not conf_package_names:
logging.warning(
_(
"One of the 'github_releases' config itmes is missing the "
"'packageNames' value. skipping ..."
)
)
return
# lookup all versionNames (git tags) for all packages available in the
# local fdroid repo
all_local_versions = set()
for package_name in conf_package_names:
for version in release_infos.get(package_name, {}).keys():
all_local_versions.add(version)
gh = fdroidserver.github.GithubApi(token, projectUrl)
unreleased_tags = gh.list_unreleased_tags()
for version in all_local_versions:
if version in unreleased_tags:
# Making sure we're not uploading this version when releaseChannels
# is set. (releaseChannels usually mean it's e.g. an alpha or beta
# version)
if (
not release_infos.get(conf_package_names[0], {})
.get(version, {})
.get('hasReleaseChannels')
):
# collect files associated with this github release
files = []
for package in conf_package_names:
files.extend(
release_infos.get(package, {}).get(version, {}).get('files', [])
)
# always use the whatsNew text from the first app listed in
# config.yml github_releases.packageNames
text = (
release_infos.get(conf_package_names[0], {})
.get(version, {})
.get('whatsNew')
or ''
)
if 'release_notes_prepend' in repo_conf:
text = repo_conf['release_notes_prepend'] + "\n\n" + text
# create new release on github and upload all associated files
gh.create_release(version, files, text)
def main(): def main():
global config global config
@ -1194,12 +1328,14 @@ def main():
and not config.get('androidobservatory') and not config.get('androidobservatory')
and not config.get('binary_transparency_remote') and not config.get('binary_transparency_remote')
and not config.get('virustotal_apikey') and not config.get('virustotal_apikey')
and not config.get('github_releases')
and local_copy_dir is None and local_copy_dir is None
): ):
logging.warning( logging.warning(
_('No option set! Edit your config.yml to set at least one of these:') _('No option set! Edit your config.yml to set at least one of these:')
+ '\nserverwebroot, servergitmirrors, local_copy_dir, awsbucket, ' + '\nserverwebroot, servergitmirrors, local_copy_dir, awsbucket, '
+ 'virustotal_apikey, androidobservatory, or binary_transparency_remote' + 'virustotal_apikey, androidobservatory, github_releases '
+ 'or binary_transparency_remote'
) )
sys.exit(1) sys.exit(1)
@ -1236,6 +1372,10 @@ def main():
upload_to_android_observatory(repo_section) upload_to_android_observatory(repo_section)
if config.get('virustotal_apikey'): if config.get('virustotal_apikey'):
upload_to_virustotal(repo_section, config.get('virustotal_apikey')) upload_to_virustotal(repo_section, config.get('virustotal_apikey'))
if config.get('github_releases'):
upload_to_github_releases(
repo_section, config.get('github_releases'), config.get('github_token')
)
binary_transparency_remote = config.get('binary_transparency_remote') binary_transparency_remote = config.get('binary_transparency_remote')
if binary_transparency_remote: if binary_transparency_remote:

163
fdroidserver/github.py Normal file
View File

@ -0,0 +1,163 @@
#!/usr/bin/env python3
#
# github.py - part of the FDroid server tools
# Copyright (C) 2024, Michael Pöhn, michael@poehn.at
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
import pathlib
import urllib.request
import urllib.parse
class GithubApi:
"""
Warpper for some select calls to GitHub Json/REST API.
This class wraps some calls to api.github.com. This is not intended to be a
general API wrapper. Instead it's purpose is to return pre-filtered and
transformed data that's playing well with other fdroidserver functions.
"""
def __init__(self, api_token, repo_path):
self._api_token = api_token
if repo_path.startswith("https://github.com/"):
self._repo_path = repo_path[19:]
else:
self._repo_path = repo_path
def _req(self, url, data=None):
h = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {self._api_token}",
"X-GitHub-Api-Version": "2022-11-28",
}
return urllib.request.Request(
url,
headers=h,
data=data,
)
def list_released_tags(self):
"""List of all tags that are associated with a release for this repo on GitHub."""
names = []
req = self._req(f"https://api.github.com/repos/{self._repo_path}/releases")
with urllib.request.urlopen(req) as resp: # nosec CWE-22 disable bandit warning
releases = json.load(resp)
for release in releases:
names.append(release['tag_name'])
return names
def list_unreleased_tags(self):
all_tags = self.list_all_tags()
released_tags = self.list_released_tags()
return [x for x in all_tags if x not in released_tags]
def tag_exists(self, tag):
"""
Check if git tag is present on github.
https://docs.github.com/en/rest/git/refs?apiVersion=2022-11-28#list-matching-references--fine-grained-access-tokens
"""
req = self._req(
f"https://api.github.com/repos/{self._repo_path}/git/matching-refs/tags/{tag}"
)
with urllib.request.urlopen(req) as resp: # nosec CWE-22 disable bandit warning
rd = json.load(resp)
return len(rd) == 1 and rd[0].get("ref", False) == f"refs/tags/{tag}"
return False
def list_all_tags(self):
"""Get list of all tags for this repo on GitHub."""
tags = []
req = self._req(
f"https://api.github.com/repos/{self._repo_path}/git/matching-refs/tags/"
)
with urllib.request.urlopen(req) as resp: # nosec CWE-22 disable bandit warning
refs = json.load(resp)
for ref in refs:
r = ref.get('ref', '')
if r.startswith('refs/tags/'):
tags.append(r[10:])
return tags
def create_release(self, tag, files, body=''):
"""
Create a new release on github.
also see: https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#create-a-release
:returns: True if release was created, False if release already exists
:raises: urllib exceptions in case of network or api errors, also
raises an exception when the tag doesn't exists.
"""
# Querying github to create a new release for a non-existent tag, will
# also create that tag on github. So we need an additional check to
# prevent this behavior.
if not self.tag_exists(tag):
raise Exception(
f"can't create github release for {self._repo_path} {tag}, tag doesn't exists"
)
# create the relase on github
req = self._req(
f"https://api.github.com/repos/{self._repo_path}/releases",
data=json.dumps(
{
"tag_name": tag,
"body": body,
}
).encode("utf-8"),
)
try:
with urllib.request.urlopen( # nosec CWE-22 disable bandit warning
req
) as resp:
release_id = json.load(resp)['id']
except urllib.error.HTTPError as e:
if e.status == 422:
codes = [x['code'] for x in json.load(e).get('errors', [])]
if "already_exists" in codes:
return False
raise e
# attach / upload all files for the relase
for file in files:
self._create_release_asset(release_id, file)
return True
def _create_release_asset(self, release_id, file):
"""
Attach a file to a release on GitHub.
This uploads a file to github relases, it will be attached to the supplied release
also see: https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#upload-a-release-asset
"""
file = pathlib.Path(file)
with open(file, 'rb') as f:
req = urllib.request.Request(
f"https://uploads.github.com/repos/{self._repo_path}/releases/{release_id}/assets?name={file.name}",
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {self._api_token}",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/octet-stream",
},
data=f.read(),
)
with urllib.request.urlopen(req): # nosec CWE-22 disable bandit warning
return True
return False

View File

@ -1210,6 +1210,261 @@ class DeployTest(unittest.TestCase):
) )
class GitHubReleasesTest(unittest.TestCase):
def test_find_release_infos(self):
self.maxDiff = None
index_mock = b"""
{
"packages": {
"com.example.app": {
"versions": {
"2e6f263c1927506015bfc98bce0818247836f2e7fe29a04e1af2b33c97848750": {
"file": {
"name": "/com.example.app_123.apk"
},
"whatsNew": {
"en-US": "fake what's new"
},
"manifest": {
"versionName": "1.2.3",
"versionCode": "123"
}
},
"8a6f263c8327506015bfc98bce0815247836f2e7fe29a04e1af2bffa6409998d": {
"file": {
"name": "/com.example.app_100.apk"
},
"manifest": {
"versionName": "1.0-alpha",
"versionCode": "123"
},
"releaseChannels": ["alpha"]
}
}
},
"another.app": {
"versions": {
"30602ffc19a7c0601bbfa93bce00082c78a6f2ddfe29a04e1af253fc9f84eda0": {
"file": {
"name": "/another.app_1.apk"
},
"manifest": {
"versionName": "1",
"versionCode": "1"
}
}
}
},
"fildered.app": {
"versions": {
"93ae02fc19a7c0601adfa93bce0443fc78a6f2ddfe3df04e1af093fca9a1ff09": {
"file": {
"name": "/another.app_1.apk"
},
"manifest": {
"versionName": "1",
"versionCode": "1"
}
}
}
}
}
}
"""
with unittest.mock.patch(
"fdroidserver.deploy.open", unittest.mock.mock_open(read_data=index_mock)
):
release_infos = fdroidserver.deploy.find_release_infos(
"fake_path",
Path('fake_repo'),
["com.example.app", "another.app"],
)
self.assertDictEqual(
release_infos,
{
"another.app": {
"1": {
"files": [Path('fake_repo') / "another.app_1.apk"],
"hasReleaseChannels": False,
"whatsNew": None,
},
},
"com.example.app": {
"1.0-alpha": {
"files": [
Path("fake_repo") / "com.example.app_100.apk",
],
"hasReleaseChannels": True,
"whatsNew": None,
},
"1.2.3": {
"files": [
Path("fake_repo") / "com.example.app_123.apk",
],
"hasReleaseChannels": False,
"whatsNew": "fake what's new",
},
},
},
)
def test_upload_to_github_releases(self):
gh_config = [
{
"projectUrl": "https://github.com/example/app",
"packageNames": ["com.example.app", "another.app"],
},
{
"projectUrl": "https://github.com/custom/app",
"packageNames": ["more.custom.app"],
"token": "custom_token",
},
]
fri_mock = unittest.mock.Mock(return_value="fri_result")
urr_mock = unittest.mock.Mock()
with unittest.mock.patch(
"fdroidserver.deploy.find_release_infos", fri_mock
), unittest.mock.patch(
"fdroidserver.deploy.upload_to_github_releases_repo", urr_mock
), tempfile.TemporaryDirectory() as tmpdir:
with open(Path(tmpdir) / "index-v2.json", "w") as f:
f.write("")
fdroidserver.deploy.upload_to_github_releases(
tmpdir, gh_config, "fake_global_token"
)
fri_mock.assert_called_once_with(
Path(tmpdir) / "index-v2.json",
Path(tmpdir),
["com.example.app", "another.app", "more.custom.app"],
)
self.maxDiff = None
self.assertListEqual(
urr_mock.call_args_list,
[
unittest.mock.call(
{
"projectUrl": "https://github.com/example/app",
"packageNames": ["com.example.app", "another.app"],
},
"fri_result",
"fake_global_token",
),
unittest.mock.call(
{
"projectUrl": "https://github.com/custom/app",
"packageNames": ["more.custom.app"],
"token": "custom_token",
},
"fri_result",
"fake_global_token",
),
],
)
class Test_UploadToGithubReleasesRepo(unittest.TestCase):
def setUp(self):
self.repo_conf = {
"projectUrl": "https://github.com/example/app",
"packageNames": ["com.example.app", "com.example.altapp", "another.app"],
}
self.release_infos = {
"com.example.app": {
"1.0.0": {
"files": [
Path("fake_repo") / "com.example.app_100100.apk",
],
"hasReleaseChannels": False,
"whatsNew": "what's new com.example.app 1.0.0",
},
"1.0.0-beta1": {
"files": [
Path("fake_repo") / "com.example.app_100007.apk",
],
"hasReleaseChannels": True,
"whatsNew": None,
},
},
"com.example.altapp": {
"1.0.0": {
"files": [
Path("fake_repo") / "com.example.altapp_100100.apk",
Path("fake_repo") / "com.example.altapp_100100.apk.asc",
Path("fake_repo") / "com.example.altapp_100100.apk.idsig",
],
"whatsNew": "what's new com.example.altapp 1.0.0",
},
},
}
self.api = unittest.mock.Mock()
self.api.list_unreleased_tags = lambda: ["1.0.0", "1.0.0-beta1"]
self.api_constructor = unittest.mock.Mock(return_value=self.api)
def test_global_token(self):
with unittest.mock.patch("fdroidserver.github.GithubApi", self.api_constructor):
fdroidserver.deploy.upload_to_github_releases_repo(
self.repo_conf,
self.release_infos,
"global_token",
)
self.api_constructor.assert_called_once_with(
"global_token", "https://github.com/example/app"
)
self.assertListEqual(
self.api.create_release.call_args_list,
[
unittest.mock.call(
"1.0.0",
[
Path("fake_repo/com.example.app_100100.apk"),
Path("fake_repo/com.example.altapp_100100.apk"),
Path("fake_repo/com.example.altapp_100100.apk.asc"),
Path("fake_repo/com.example.altapp_100100.apk.idsig"),
],
"what's new com.example.app 1.0.0",
),
],
)
def test_local_token(self):
self.repo_conf["token"] = "local_token"
with unittest.mock.patch("fdroidserver.github.GithubApi", self.api_constructor):
fdroidserver.deploy.upload_to_github_releases_repo(
self.repo_conf,
self.release_infos,
"global_token",
)
self.api_constructor.assert_called_once_with(
"local_token", "https://github.com/example/app"
)
self.assertListEqual(
self.api.create_release.call_args_list,
[
unittest.mock.call(
"1.0.0",
[
Path("fake_repo/com.example.app_100100.apk"),
Path("fake_repo/com.example.altapp_100100.apk"),
Path("fake_repo/com.example.altapp_100100.apk.asc"),
Path("fake_repo/com.example.altapp_100100.apk.idsig"),
],
"what's new com.example.app 1.0.0",
),
],
)
if __name__ == "__main__": if __name__ == "__main__":
os.chdir(os.path.dirname(__file__)) os.chdir(os.path.dirname(__file__))
@ -1227,4 +1482,6 @@ if __name__ == "__main__":
newSuite = unittest.TestSuite() newSuite = unittest.TestSuite()
newSuite.addTest(unittest.makeSuite(DeployTest)) newSuite.addTest(unittest.makeSuite(DeployTest))
newSuite.addTest(unittest.makeSuite(GitHubReleasesTest))
newSuite.addTest(unittest.makeSuite(Test_UploadToGithubReleasesRepo))
unittest.main(failfast=False) unittest.main(failfast=False)

164
tests/github.TestCase Executable file
View File

@ -0,0 +1,164 @@
#!/usr/bin/env python3
import inspect
import optparse
import os
import sys
import unittest.mock
import testcommon
localmodule = os.path.realpath(
os.path.join(os.path.dirname(inspect.getfile(inspect.currentframe())), '..')
)
print('localmodule: ' + localmodule)
if localmodule not in sys.path:
sys.path.insert(0, localmodule)
import fdroidserver.github
import fdroidserver.common
class GithubApiTest(unittest.TestCase):
def test__init(self):
api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath')
self.assertEqual(api._api_token, 'faketoken')
self.assertEqual(api._repo_path, 'fakerepopath')
def test__req(self):
api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath')
r = api._req('https://fakeurl', data='fakedata')
self.assertEqual(r.full_url, 'https://fakeurl')
self.assertEqual(r.data, "fakedata")
self.assertDictEqual(
r.headers,
{
'Accept': 'application/vnd.github+json',
'Authorization': 'Bearer faketoken',
'X-github-api-version': '2022-11-28',
},
)
def test_list_released_tags(self):
api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath')
uomock = testcommon.mock_urlopen(
body='[{"tag_name": "fake"}, {"tag_name": "double_fake"}]'
)
with unittest.mock.patch("urllib.request.urlopen", uomock):
result = api.list_released_tags()
self.assertListEqual(result, ['fake', 'double_fake'])
def test_list_unreleased_tags(self):
api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath')
api.list_all_tags = unittest.mock.Mock(return_value=[1, 2, 3, 4])
api.list_released_tags = unittest.mock.Mock(return_value=[1, 2])
result = api.list_unreleased_tags()
self.assertListEqual(result, [3, 4])
def test_tag_exists(self):
api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath')
uomock = testcommon.mock_urlopen(body='[{"ref": "refs/tags/fake_tag"}]')
with unittest.mock.patch("urllib.request.urlopen", uomock):
result = api.tag_exists('fake_tag')
self.assertTrue(result)
def test_tag_exists_failure(self):
api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath')
uomock = testcommon.mock_urlopen(body='[{"error": "failure"}]')
with unittest.mock.patch("urllib.request.urlopen", uomock):
success = api.tag_exists('fake_tag')
self.assertFalse(success)
def test_list_all_tags(self):
api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath')
uomock = testcommon.mock_urlopen(
body='[{"ref": "refs/tags/fake"}, {"ref": "refs/tags/double_fake"}]'
)
with unittest.mock.patch("urllib.request.urlopen", uomock):
result = api.list_all_tags()
self.assertListEqual(result, ['fake', 'double_fake'])
def test_create_release(self):
api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath')
uomock = testcommon.mock_urlopen(body='{"id": "fakeid"}')
api.tag_exists = lambda x: True
api._create_release_asset = unittest.mock.Mock()
with unittest.mock.patch("urllib.request.urlopen", uomock):
success = api.create_release('faketag', ['file_a', 'file_b'], body="bdy")
self.assertTrue(success)
req = uomock.call_args_list[0][0][0]
self.assertEqual(1, len(uomock.call_args_list))
self.assertEqual(2, len(uomock.call_args_list[0]))
self.assertEqual(1, len(uomock.call_args_list[0][0]))
self.assertEqual(
req.full_url,
'https://api.github.com/repos/fakerepopath/releases',
)
self.assertEqual(req.data, b'{"tag_name": "faketag", "body": "bdy"}')
self.assertListEqual(
api._create_release_asset.call_args_list,
[
unittest.mock.call('fakeid', 'file_a'),
unittest.mock.call('fakeid', 'file_b'),
],
)
def test__create_release_asset(self):
api = fdroidserver.github.GithubApi('faketoken', 'fakerepopath')
uomock = testcommon.mock_urlopen()
with unittest.mock.patch(
'fdroidserver.github.open',
unittest.mock.mock_open(read_data=b"fake_content"),
), unittest.mock.patch("urllib.request.urlopen", uomock):
success = api._create_release_asset('fake_id', 'fake_file')
self.assertTrue(success)
req = uomock.call_args_list[0][0][0]
self.assertEqual(1, len(uomock.call_args_list))
self.assertEqual(2, len(uomock.call_args_list[0]))
self.assertEqual(1, len(uomock.call_args_list[0][0]))
self.assertEqual(
req.full_url,
'https://uploads.github.com/repos/fakerepopath/releases/fake_id/assets?name=fake_file',
)
self.assertDictEqual(
req.headers,
{
"Accept": "application/vnd.github+json",
'Authorization': 'Bearer faketoken',
'Content-type': 'application/octet-stream',
'X-github-api-version': '2022-11-28',
},
)
self.assertEqual(req.data, b'fake_content')
if __name__ == "__main__":
os.chdir(os.path.dirname(__file__))
parser = optparse.OptionParser()
parser.add_option(
"-v",
"--verbose",
action="store_true",
default=False,
help="Spew out even more information than normal",
)
(fdroidserver.common.options, args) = parser.parse_args(["--verbose"])
newSuite = unittest.TestSuite()
newSuite.addTest(unittest.makeSuite(GithubApiTest))
unittest.main(failfast=False)

View File

@ -19,9 +19,9 @@ import os
import sys import sys
import tempfile import tempfile
import unittest import unittest
import unittest.mock
from pathlib import Path from pathlib import Path
from unittest import mock
class TmpCwd: class TmpCwd:
@ -84,5 +84,13 @@ def parse_args_for_test(parser, args):
for arg in args: for arg in args:
if arg[0] == '-': if arg[0] == '-':
flags.append(flags) flags.append(flags)
with mock.patch('sys.argv', flags): with unittest.mock.patch('sys.argv', flags):
parse_args(parser) parse_args(parser)
def mock_urlopen(status=200, body=None):
resp = unittest.mock.MagicMock()
resp.getcode.return_value = status
resp.read.return_value = body
resp.__enter__.return_value = resp
return unittest.mock.Mock(return_value=resp)