From e7966a38d6a1f3719406852d609c0b5da9feb304 Mon Sep 17 00:00:00 2001 From: JP Gehrig Date: Sun, 13 Mar 2022 14:59:30 +0100 Subject: [PATCH] wip --- .flake8 | 4 ++ .gitignore | 5 +++ README.md | 38 +++++++++++++++++ bexio/__init__.py | 8 ++++ bexio/api/__init__.py | 5 +++ bexio/api/client.py | 90 +++++++++++++++++++++++++++++++++++++++++ bexio/api/exceptions.py | 10 +++++ bexio/cli/__init__.py | 31 ++++++++++++++ bexio/cli/config.py | 10 +++++ bexio/cli/file.py | 56 +++++++++++++++++++++++++ bexio/config.py | 3 ++ setup.py | 39 ++++++++++++++++++ 12 files changed, 299 insertions(+) create mode 100644 .flake8 create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bexio/__init__.py create mode 100644 bexio/api/__init__.py create mode 100644 bexio/api/client.py create mode 100644 bexio/api/exceptions.py create mode 100644 bexio/cli/__init__.py create mode 100644 bexio/cli/config.py create mode 100644 bexio/cli/file.py create mode 100644 bexio/config.py create mode 100644 setup.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..043dce0 --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +max-line-length = 150 +per-file-ignores = data.py:E501 +exclude = .git,.github,.venv diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01b2d4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.pyc +*.DS_Store +*.venv* +out/ +bexio_python.egg-info/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c481b87 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Bexio Python Library + +The Bexio Python CLI... + + +## Prerequisites + +* Python 3.7 or better + + +## Installation + +Clone the repository: + + $ git clone https://github.com/jpgehrig/bexio-python.git + +Create virtual environment and activate it: + + $ python3 -m venv .venv + $ source .venv/bin/activate + +Install the package: + + $ pip install --editable . + + +## Authentication + +Navigate to your Bexio account settings page and create/copy an API Token. Then set the following environment variable: + + $ export BEXIO_API_TOKEN= + + +## Get help + +Just type: + + $ bexio diff --git a/bexio/__init__.py b/bexio/__init__.py new file mode 100644 index 0000000..e3e261c --- /dev/null +++ b/bexio/__init__.py @@ -0,0 +1,8 @@ +from . import config + +# global API configuration +config.api_token = None +config.api_host = 'https://api.bexio.com' + +# exceptions +from .api import exceptions # noqa diff --git a/bexio/api/__init__.py b/bexio/api/__init__.py new file mode 100644 index 0000000..4fbfb5a --- /dev/null +++ b/bexio/api/__init__.py @@ -0,0 +1,5 @@ +from .client import APIClient + +__all__ = ( + 'APIClient', +) diff --git a/bexio/api/client.py b/bexio/api/client.py new file mode 100644 index 0000000..2a4bbd8 --- /dev/null +++ b/bexio/api/client.py @@ -0,0 +1,90 @@ +import requests + +from json.decoder import JSONDecodeError +from urllib.parse import urljoin + +from requests.adapters import HTTPAdapter +from requests.packages.urllib3.util.retry import Retry + +from .exceptions import BexioAPIError + +from .. import config + + +def requests_retry_session( + retries=3, + backoff_factor=0.3, + status_forcelist=(500, 502, 504), + session=None, +): + session = session or requests.Session() + retry = Retry( + total=retries, + read=retries, + connect=retries, + backoff_factor=backoff_factor, + status_forcelist=status_forcelist, + ) + adapter = HTTPAdapter(max_retries=retry) + session.mount('http://', adapter) + session.mount('https://', adapter) + return session + + +def raise_api_error(response): + try: + body = response.json() + except JSONDecodeError: + error = response.reason + else: + if isinstance(body, list): + error = str(body) + else: + error = body.get('detail', f'{response.reason} {response.text}') + url = f'{response.request.method} {response.request.url}' + raise BexioAPIError( + message=error, + url=url, + status_code=response.status_code) + + +class APIClient: + + def __init__(self): + self.session = requests_retry_session() + + def __default_headers(self): + headers = { + 'accept': 'application/json', + 'content-type': 'application/json', + } + if config.api_token: + headers['authorization'] = 'Bearer {}'.format(config.api_token) + return headers + + def __url(self, path): + return urljoin(config.api_host, path) + + def __request(self, method, path, data=None, params=None): + headers = self.__default_headers() + response = self.session.request( + method, + self.__url(path), + headers=headers, + params=params, + json=data) + if not response.ok: + raise_api_error(response) + return response + + def get(self, path, params=None): + return self.__request('GET', path, params=params) + + def put(self, path, data=None, params=None): + return self.__request('PUT', path, data=data, params=params) + + def post(self, path, data=None, params=None, files=None): + return self.__request('POST', path, data=data, params=params, files=files) + + def delete(self, path): + return self.__request('DELETE', path) diff --git a/bexio/api/exceptions.py b/bexio/api/exceptions.py new file mode 100644 index 0000000..4f4384d --- /dev/null +++ b/bexio/api/exceptions.py @@ -0,0 +1,10 @@ +class BexioError(Exception): + pass + + +class BexioAPIError(BexioError): + + def __init__(self, message, url, status_code): + self.url = url + self.status_code = status_code + super().__init__(message) diff --git a/bexio/cli/__init__.py b/bexio/cli/__init__.py new file mode 100644 index 0000000..6bff06f --- /dev/null +++ b/bexio/cli/__init__.py @@ -0,0 +1,31 @@ +import click +import bexio +import os + +from .config import pass_config + +from .file import file + + +class CatchBexioAPIError(click.Group): + + def __call__(self, *args, **kwargs): + try: + return self.main(*args, **kwargs) + except bexio.exceptions.BexioAPIError as e: + click.secho(f'\n! API ERROR: {e} [{e.status_code}]', bold=True, fg='red') + click.secho(f'! endpoint: {e.url}\n', fg='red') + + +@click.group(cls=CatchBexioAPIError) +@click.option('--api-token', envvar='BEXIO_API_TOKEN', required=True) +@click.option('--api-host', envvar='BEXIO_API_HOST', default='https://api.bexio.com') +@click.option('--verbose', is_flag=True) +@pass_config +def main(config, api_token, api_host, verbose): + config.verbose = verbose + bexio.config.api_token = api_token + bexio.config.api_host = api_host + + +main.add_command(file) diff --git a/bexio/cli/config.py b/bexio/cli/config.py new file mode 100644 index 0000000..c596dba --- /dev/null +++ b/bexio/cli/config.py @@ -0,0 +1,10 @@ +import click + + +class Config(object): + + def __init__(self): + self.verbose = False + + +pass_config = click.make_pass_decorator(Config, ensure=True) diff --git a/bexio/cli/file.py b/bexio/cli/file.py new file mode 100644 index 0000000..9cc301d --- /dev/null +++ b/bexio/cli/file.py @@ -0,0 +1,56 @@ +import click + +from pathlib import Path +from dateutil import parser + +from ..api import APIClient +from . import pass_config + + +@click.group() +def file(): + """ + Manage files. + """ + pass + +@file.command() +@click.option('-s', '--archived_state', + required=True, + type=click.Choice(['all', 'archived', 'not_archived']), + help='The `archived_state` of the files to download.') +@click.option('-d', '--directory', + required=True, + type=click.Path(exists=True, file_okay=False, path_type=Path), + help='The directory to save files.') +@click.option('-p', '--prefix', + type=click.Choice(['date']), + help='Prefix to apply to download files.') +@pass_config +def download_all(config, archived_state, directory, prefix): + """ + Download all files. + """ + client = APIClient() + params = { + 'archived_state': archived_state, + 'offset': 0, + 'limit': 100 + } + response = client.get('3.0/files', params=params) + count = int(response.headers['X-Total-Count']) + with click.progressbar(length=count, label=f'Downloading {count} files') as bar: + while response.json(): + for i, f in enumerate(response.json()): + created_at = parser.parse(f['created_at']) + date = created_at.strftime('%Y%m%d') + filename = f"{f['name']}.{f['extension']}" + if prefix == 'date': + filename = f'{date}_{filename}' + r = client.get(f"3.0/files/{f['id']}/download") + filepath = directory / filename + with filepath.open('wb') as f: + f.write(r.content) + bar.update(1) + params['offset'] += len(response.json()) + response = client.get('3.0/files', params=params) diff --git a/bexio/config.py b/bexio/config.py new file mode 100644 index 0000000..597e00e --- /dev/null +++ b/bexio/config.py @@ -0,0 +1,3 @@ +# global API configuration +api_token = None +api_host = 'https://api.bexio.com' diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..25066b6 --- /dev/null +++ b/setup.py @@ -0,0 +1,39 @@ +from setuptools import setup, find_packages + + +setup( + name='bexio-python', + version='0.0.1', + description='', + url='https://github.com/jpgehrig/bexio-python', + keywords=[ + 'python', + 'bexio', + 'cli', + ], + author='jpgehrig', + license='unlicensed', + packages=find_packages(exclude=('tests')), + install_requires=[ + 'click~=8.0', + 'requests~=2.25', + 'python-dateutil~=2.8' + ], + entry_points=''' + [console_scripts] + bexio=bexio.cli:main + ''', + python_requires=">=3.7", + classifiers=[ + "Natural Language :: English", + "Intended Audience :: Developers", + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Topic :: Software Development :: Libraries", + "Programming Language :: Python", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + ], +)