Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[flake8]
max-line-length = 150
per-file-ignores = data.py:E501
exclude = .git,.github,.venv
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
*.pyc
*.DS_Store
*.venv*
out/
bexio_python.egg-info/
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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=<your-api-token>


## Get help

Just type:

$ bexio
8 changes: 8 additions & 0 deletions bexio/__init__.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions bexio/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .client import APIClient

__all__ = (
'APIClient',
)
90 changes: 90 additions & 0 deletions bexio/api/client.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 10 additions & 0 deletions bexio/api/exceptions.py
Original file line number Diff line number Diff line change
@@ -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)
31 changes: 31 additions & 0 deletions bexio/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 10 additions & 0 deletions bexio/cli/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import click


class Config(object):

def __init__(self):
self.verbose = False


pass_config = click.make_pass_decorator(Config, ensure=True)
56 changes: 56 additions & 0 deletions bexio/cli/file.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions bexio/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# global API configuration
api_token = None
api_host = 'https://api.bexio.com'
39 changes: 39 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -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",
],
)