diff --git a/.ci/install.sh b/.ci/install.sh new file mode 100644 index 0000000..7f87ff7 --- /dev/null +++ b/.ci/install.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e +set -x + +if [[ "$(uname -s)" == 'Darwin' ]]; then + brew update || brew update + brew outdated pyenv || brew upgrade pyenv + brew install pyenv-virtualenv + + if which pyenv > /dev/null; then + eval "$(pyenv init -)" + fi + + pyenv install 3.7.1 + pyenv virtualenv 3.7.1 conan + pyenv rehash + pyenv activate conan +fi + +pip install codecov +pip install -e .[test] diff --git a/.ci/run.sh b/.ci/run.sh new file mode 100644 index 0000000..024847d --- /dev/null +++ b/.ci/run.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -e +set -x + +if [[ "$(uname -s)" == 'Darwin' ]]; then + if which pyenv > /dev/null; then + eval "$(pyenv init -)" + fi + pyenv activate conan +fi + +python setup.py sdist +pushd tests +pytest -v -s --cov=bintray +mv .coverage .. +popd diff --git a/.gitignore b/.gitignore index 894a44c..8a9b319 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,4 @@ venv.bak/ # mypy .mypy_cache/ +.idea/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..2882cae --- /dev/null +++ b/.travis.yml @@ -0,0 +1,35 @@ +matrix: + fast_finish: true + include: + - os: linux + dist: xenial + language: python + python: '3.7' + +install: + - chmod +x .ci/install.sh + - ".ci/install.sh" + +script: + - chmod +x .ci/run.sh + - ".ci/run.sh" + +after_success: + - codecov + +deploy: + - provider: pypi + user: ${PYPI_USERNAME} + password: ${PYPI_PASSWORD} + on: + tags: true + skip_cleanup: true + skip_existing: true + - provider: pypi + user: ${TEST_PYPI_USERNAME} + server: https://test.pypi.org/legacy/ + password: ${TEST_PYPI_PASSWORD} + on: + branch: master + skip_cleanup: true + skip_existing: true diff --git a/README.md b/README.md index d556828..b78130f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,10 @@ -# bintray-python -Python wrapper for Bintray API +# Bintray Python + +**The Python wrapper for Bintray API** + +#### DOCUMENTATION + +Please, read the official documentation from Bintray: https://bintray.com/docs/api + +#### LICENSE +[MIT](LICENSE) diff --git a/bintray/__init__.py b/bintray/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bintray/bintray.py b/bintray/bintray.py new file mode 100644 index 0000000..34264aa --- /dev/null +++ b/bintray/bintray.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" Python Wrapper for Bintray API + + https://bintray.com/docs/api +""" +import os +import logging +import requests +from requests.auth import HTTPBasicAuth + + +class Bintray(object): + """ Python Wrapper for Bintray API + + """ + + # Bintray API URL + BINTRAY_URL = "https://api.bintray.com" + + def __init__(self, username=None, api_key=None): + """ Initialize arguments for login + + :param username: Bintray username + :param api_key: Bintray API Key + """ + self._username = username or os.getenv("BINTRAY_USERNAME") + self._password = api_key or os.getenv("BINTRAY_API_KEY") + + self._logger = logging.getLogger(__file__) + self._logger.setLevel(logging.INFO) + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + ch = logging.StreamHandler() + ch.setLevel(logging.INFO) + ch.setFormatter(formatter) + self._logger.addHandler(ch) + + def _get_authentication(self): + """ Retrieve Basic HTTP Authentication based on username and API key + + :return: Basic Authentication handler + """ + if not self._username or not self._password: + return None + return HTTPBasicAuth(self._username, self._password) + + def _add_status_code(self, response): + """ Update JSON result with error and status code + + :param response: Requests response + :return: Response JSON + """ + json_data = response.json() + if isinstance(json_data, list): + json_data.append({"statusCode": response.status_code, "error": not response.ok}) + else: + json_data.update({"statusCode": response.status_code, "error": not response.ok}) + return json_data + + + def _bool_to_number(self, value): + """ Convert boolean result into numeric string + + :param value: Any boolean value + :return: "1" when True. Otherwise, "0" + """ + return "1" if value else "0" + + def _raise_error(self, message, response): + try: + response.raise_for_status() + except Exception as error: + raise Exception("{} ({}): {}".format(message, response.status_code, str(error))) + + # Files + + def get_package_files(self, subject, repo, package, include_unpublished=False): + """ Get all files in a given package. + + When called by a user with publishing rights on the package, + includes unpublished files in the list. By default only published files are shown. + :param subject: username or organization + :param repo: repository name + :param package: package name + :param include_unpublished: Show not published files + :return: List with all files + """ + parameters = {"include_unpublished": self._bool_to_number(include_unpublished)} + url = "{}/packages/{}/{}/{}/files?include_unpublished={}".format(Bintray.BINTRAY_URL, + subject, + repo, + package, + include_unpublished) + response = requests.get(url, auth=self._get_authentication(), params=parameters) + if not response.ok: + self._raise_error("Could not list package files", response) + return self._add_status_code(response) + + # Content Uploading & Publishing + + def upload_content(self, subject, repo, package, version, remote_file_path, local_file_path, + publish=True, override=False, explode=False): + """ + Upload content to the specified repository path, with package and version information (both required). + + :param subject: username or organization + :param repo: repository name + :param package: package name + :param version: package version + :param remote_file_path: file name to be used on Bintray + :param local_file_path: file path to be uploaded + :param publish: publish after uploading + :param override: override remote file + :param explode: explode remote file + :return: + """ + url = "{}/content/{}/{}/{}/{}/{}".format(Bintray.BINTRAY_URL, subject, repo, package, + version, remote_file_path) + parameters = {"publish": self._bool_to_number(publish), + "override": self._bool_to_number(override), + "explode": self._bool_to_number(explode)} + + with open(local_file_path, 'rb') as file_content: + response = requests.put(url, auth=self._get_authentication(), params=parameters, + data=file_content) + if response.status_code != 201: + self._raise_error("Could not upload", response) + self._logger.info("Upload successfully: {}".format(url)) + return self._add_status_code(response) + + # Content Downloading + + def download_content(self, subject, repo, remote_file_path, local_file_path): + """ Download content from the specified repository path. + + :param subject: username or organization + :param repo: repository name + :param remote_file_path: file name to be downloaded from Bintray + :param local_file_path: file name to be stored in local storage + """ + download_base_url = "https://dl.bintray.com" + url = "{}/{}/{}/{}".format(download_base_url, subject, repo, remote_file_path) + response = requests.get(url, auth=self._get_authentication()) + if not response.ok: + self._raise_error("Could not download file content", response) + with open(local_file_path, 'wb') as local_fd: + local_fd.write(response.content) + self._logger.info("Download successfully: {}".format(url)) + return self._add_status_code(response) diff --git a/bintray/requirements.txt b/bintray/requirements.txt new file mode 100644 index 0000000..e20605c --- /dev/null +++ b/bintray/requirements.txt @@ -0,0 +1 @@ +requests==2.22.0 \ No newline at end of file diff --git a/bintray/requirements_test.txt b/bintray/requirements_test.txt new file mode 100644 index 0000000..5832bca --- /dev/null +++ b/bintray/requirements_test.txt @@ -0,0 +1,2 @@ +pytest==5.0.0 +pytest-cov==2.7.1 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2cf292b --- /dev/null +++ b/setup.py @@ -0,0 +1,124 @@ +"""A setuptools based setup module. +See: +https://packaging.python.org/en/latest/distributing.html +https://github.com/pypa/sampleproject +""" + +# Always prefer setuptools over distutils +import re +import os +from setuptools import setup, find_packages +# To use a consistent encoding +from codecs import open + + +here = os.path.abspath(os.path.dirname(__file__)) + +# Get the long description from the README file +with open(os.path.join(here, 'README.md'), encoding='utf-8') as f: + long_description = f.read() + + +def get_requires(filename): + requirements = [] + with open(filename) as req_file: + for line in req_file.read().splitlines(): + if not line.strip().startswith("#"): + requirements.append(line) + return requirements + + +def load_version(): + """Loads a file content""" + filename = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), + "bincrafters_conventions", "bincrafters_conventions.py")) + with open(filename, "rt") as version_file: + conan_init = version_file.read() + version = re.search("__version__ = '([0-9a-z.-]+)'", conan_init).group(1) + return version + +setup( + name='bincrafters_conventions', + # Versions should comply with PEP440. For a discussion on single-sourcing + # the version across setup.py and the project code, see + # https://packaging.python.org/en/latest/single_source_version.html + version=load_version(), + + # This is an optional longer description of your project that represents + # the body of text which users will see when they visit PyPI. + # + # Often, this is the same as your README, so you can just read it in from + # that file directly (as we have already done above) + # + # This field corresponds to the "Description" metadata field: + # https://packaging.python.org/specifications/core-metadata/#description-optional + long_description=long_description, # Optional + + description='Python Wrapper for Bintray API', + + # The project's main homepage. + url='https://github.com/uilianries/bintray-python', + + # Author details + author='Uilian Ries', + author_email='uilianries@gmail.com', + + # Choose your license + license='MIT', + + # See https://pypi.python.org/pypi?%3Aaction=list_classifiers + classifiers=[ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'Topic :: Software Development :: Build Tools', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', + ], + + # What does your project relate to? + keywords=['jfrog', 'bintray', 'api', 'libraries', 'developer', 'manager', + 'dependency', 'package', 'python', 'wrapper'], + + # You can just specify the packages manually here if your project is + # simple. Or you can use find_packages(). + packages=find_packages(exclude=['tests']), + + # Alternatively, if you want to distribute just a my_module.py, uncomment + # this: + # py_modules=["my_module"], + + # List run-time dependencies here. These will be installed by pip when + # your project is installed. For an analysis of "install_requires" vs pip's + # requirements files see: + # https://packaging.python.org/en/latest/requirements.html + install_requires=get_requires(os.path.join('bintray', 'requirements.txt')), + + # List additional groups of dependencies here (e.g. development + # dependencies). You can install these using the following syntax, + # for example: + # $ pip install -e .[dev,test] + extras_require={ + 'test': get_requires(os.path.join('bintray', 'requirements_test.txt')) + }, + + # If there are data files included in your packages that need to be + # installed, specify them here. If using Python 2.6 or less, then these + # have to be included in MANIFEST.in as well. + package_data={ + '': ['*.md'], + 'bintray': ['*.txt'], + }, + + # Although 'package_data' is the preferred approach, in some case you may + # need to place data files outside of your packages. See: + # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files # noqa + # In this case, 'data_file' will be installed into '/my_data' + # data_files=[('my_data', ['data/data_file'])], + + # To provide executable scripts, use entry points in preference to the + # "scripts" keyword. Entry points provide cross-platform support and allow + # pip to create the appropriate form of executable for the target platform. + #entry_points={ + # + #}, +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/packages.json b/tests/packages.json new file mode 100644 index 0000000..7def608 --- /dev/null +++ b/tests/packages.json @@ -0,0 +1 @@ +{"3.5.2": {"2bb76c9adac7b8cd7c5e3b377ac9f06934aba606": 1, "66c5327ebdcecae0a01a863939964495fa019a06": 3, "b28bcde4cbb80d2db194739511c07ae209a4ab3a": 5, "6cc50b139b9c3d27b3e9042d5f5372d327b3a9f7": 1, "63da998e3642b50bee33f4449826b2d623661505": 1, "20b82f03d2422a719e5621a9d3c32ba02e7e332d": 1, "d0ec62fc032e5a10524fa454546aa1bdbb22baf8": 1}, "3.6.1": {"480b6c2b270c52242654b615baf089e7adedb890": 2, "b759e10106fc0b4923414b05bb78eba0bbc8b30b": 1, "6cc50b139b9c3d27b3e9042d5f5372d327b3a9f7": 23, "a506942c4fdc4003ced5ed2afee2a0486db24337": 21, "3b345617ce2bd705ca33e4a8969fc7eb031ea30a": 23, "2a0b7ed68eb56c5f3ad8a5651d2edee732783124": 1, "8cf01e2f50fcd6b63525e70584df0326550364e1": 3, "c0e9c564ed574ab16d81fcdb2696580d62f12a3b": 1, "c32596dcd26b8c708dc3d19cb73738d2b48f12a8": 1, "963bb116781855de98dbb23aaac41621e5d312d8": 1, "7bd6f2c3d5c4e48a75805376b58cde753392f711": 1, "038f8796e196b3dba76fcc5fd4ef5d3d9c6866ec": 8, "ab4797bac78775d9e373e5fbcbd9b3fb01264904": 1, "56e0cf6d16ee57367a0661ab743f4e43b29223f8": 13, "053ea29eb0edc6b1695c893b738a971110c756fd": 1, "bf8bf3502ae799c155450cfd720fb00e0f31c39c": 1, "3d70729ad0dce2c6d7b062b47c24bd4534a22930": 1, "d0ec62fc032e5a10524fa454546aa1bdbb22baf8": 1, "1a651c5b4129ad794b88522bece2281a7453aee4": 2, "4d887c1c2779c63d2cdd81580698d2e22cb35b29": 9, "63da998e3642b50bee33f4449826b2d623661505": 3, "d8916f6016f745b1163b85952f5e984556ca5311": 8, "bf81e8e693fa7ccd30472785bfbb7216b8c6109b": 1, "d351525cc53ebe68279edf1978846402420066e7": 1, "853c4b61e2571e98cd7b854c1cda6bc111b8b32c": 1, "df81ad20137149d7a51276fd3e24009b45e5964a": 1, "f45f7e8b462318f40caf39189f9ba2d2d6dcfac0": 1}, "3.5.1": {"d351525cc53ebe68279edf1978846402420066e7": 90, "4d887c1c2779c63d2cdd81580698d2e22cb35b29": 10, "b759e10106fc0b4923414b05bb78eba0bbc8b30b": 1, "8cf01e2f50fcd6b63525e70584df0326550364e1": 39, "d0ec62fc032e5a10524fa454546aa1bdbb22baf8": 2, "66c5327ebdcecae0a01a863939964495fa019a06": 1, "853c4b61e2571e98cd7b854c1cda6bc111b8b32c": 1}} \ No newline at end of file diff --git a/tests/test_bintray_python.py b/tests/test_bintray_python.py new file mode 100644 index 0000000..a2bc635 --- /dev/null +++ b/tests/test_bintray_python.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import os +import tempfile +from bintray.bintray import Bintray + + +def test_download_content(): + json_file = "packages.json" + bintray = Bintray() + response = bintray.download_content("uilianries", "generic", json_file, json_file) + assert os.path.exists(json_file) + assert False == response["error"] + + +def test_get_package_files(): + bintray = Bintray() + response = bintray.get_package_files("uilianries", "generic", "statistics") + assert {'error': False, 'statusCode': 200} in response + assert {'created': '2019-07-01T20:51:42.879Z', + 'name': 'packages.json', + 'owner': 'uilianries', + 'package': 'statistics', + 'path': 'packages.json', + 'repo': 'generic', + 'sha1': '85abc6aece02515e8bd87b9754a18af697527d88', + 'sha256': '9537027db06c520b6eeb3b8317cef5c994ab93e5ad4b17fac3567fba7089b165', + 'size': 1967, + 'version': '20190701'} in response + + +def test_upload_content(): + bintray = Bintray() + _, temp_path = tempfile.mkstemp() + response = bintray.upload_content("uilianries", "generic", "statistics", "test", "test.txt", + temp_path, override=True) + assert {'error': False, 'message': 'success', 'statusCode': 201} == response + + +def test_bad_credentials_for_download_content(): + json_file = "packages.json" + bintray = Bintray("foobar", "85abc6aece02515e8bd87b9754a18af697527d88") + error_message = "" + try: + bintray.download_content("uilianries", "generic", json_file, json_file) + except Exception as error: + error_message = str(error) + assert "Could not download file content (401): 401 Client Error: Unauthorized for url: "\ + "https://dl.bintray.com/uilianries/generic/packages.json" == error_message + + +def test_bad_credentials_for_get_package_files(): + bintray = Bintray("foobar", "85abc6aece02515e8bd87b9754a18af697527d88") + error_message = "" + try: + bintray.get_package_files("uilianries", "generic", "statistics") + except Exception as error: + error_message = str(error) + assert "Could not list package files (401): 401 Client Error: Unauthorized for url: " \ + "https://api.bintray.com/packages/uilianries/generic/statistics/files?" \ + "include_unpublished=False&include_unpublished=0" == error_message + + +def test_bad_credentials_for_upload_content(): + bintray = Bintray("foobar", "85abc6aece02515e8bd87b9754a18af697527d88") + error_message = "" + try: + _, temp_path = tempfile.mkstemp() + bintray.upload_content("uilianries", "generic", "statistics", "test", "test.txt", temp_path) + except Exception as error: + error_message = str(error) + assert "Could not upload (401): 401 Client Error: Unauthorized for url: " \ + "https://api.bintray.com/content/uilianries/generic/statistics/test/test.txt?" \ + "publish=1&override=0&explode=0" == error_message