diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2a88d41 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,76 @@ +# Pre-commit hooks for luno-python +# See https://pre-commit.com for more information +repos: + # General file checks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-added-large-files + args: ['--maxkb=500'] + - id: check-merge-conflict + - id: check-case-conflict + - id: mixed-line-ending + args: ['--fix=lf'] + - id: detect-private-key + + # Python code formatting + - repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + language_version: python3 + args: ['--line-length=120'] + + # Import sorting + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + args: ['--profile=black', '--line-length=120'] + + # Linting + - repo: https://github.com/pycqa/flake8 + rev: 7.1.1 + hooks: + - id: flake8 + args: ['--max-line-length=120', '--extend-ignore=E203,W503'] + additional_dependencies: [flake8-docstrings, flake8-bugbear] + + # Security checks + - repo: https://github.com/PyCQA/bandit + rev: 1.7.10 + hooks: + - id: bandit + args: ['-c', 'pyproject.toml'] + additional_dependencies: ['bandit[toml]'] + + # Python upgrade syntax + - repo: https://github.com/asottile/pyupgrade + rev: v3.19.0 + hooks: + - id: pyupgrade + args: [--py37-plus] + + # Check for common Python bugs + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: python-check-blanket-noqa + - id: python-check-blanket-type-ignore + - id: python-use-type-annotations + + # Tests + - repo: local + hooks: + - id: pytest + name: pytest + entry: env/bin/pytest + args: ['-v', '--override-ini=addopts='] + language: system + pass_filenames: false + always_run: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13c5262..ea40d7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,13 @@ # Contributing ## Clone + ```bash git clone https://github.com/luno/luno-python.git ``` - ## Create Virtual env + +## Create Virtual env + ```bash cd luno-python python -m venv env @@ -12,12 +15,52 @@ source env/bin/activate ``` ## Install Dependencies + ```bash python -m pip install --upgrade pip setuptools wheel -pip install -e '.[test]' +pip install -e '.[dev]' +``` + +This installs the package in editable mode with all development dependencies including testing tools and pre-commit hooks. + +## Set Up Pre-commit Hooks + +This project uses [pre-commit](https://pre-commit.com/) to maintain code quality and consistency. The hooks run automatically before commits and pushes. + +### Install the git hook scripts + +```bash +pre-commit install +``` + +This will run code formatting, linting, security checks, and tests on every commit. + +### Run hooks manually + +To run all hooks on all files manually: + +```bash +pre-commit run --all-files +``` + +### What the hooks do + +- **Code formatting**: Automatically formats code with `black` and sorts imports with `isort` +- **Linting**: Checks code quality with `flake8` +- **Security**: Scans for common security issues with `bandit` +- **File checks**: Fixes trailing whitespace, ensures files end with newlines, validates YAML/JSON +- **Tests**: Runs the full test suite (via `pytest`) + +### Skip hooks (use sparingly) + +If you need to skip hooks for a specific commit: + +```bash +git commit --no-verify ``` ## Run Tests + ```bash pytest -``` \ No newline at end of file +``` diff --git a/LICENSE.txt b/LICENSE.txt index bd9fd42..e6e8691 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -19,4 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - diff --git a/README.md b/README.md index 54894de..3f1ee23 100644 --- a/README.md +++ b/README.md @@ -31,4 +31,3 @@ except Exception as e: ### License [MIT](https://github.com/luno/luno-python/blob/master/LICENSE.txt) - diff --git a/examples/readonly.py b/examples/readonly.py index 00729d1..9d4f1f8 100644 --- a/examples/readonly.py +++ b/examples/readonly.py @@ -3,29 +3,27 @@ from luno_python.client import Client - -if __name__ == '__main__': - c = Client(api_key_id=os.getenv('LUNO_API_KEY_ID'), - api_key_secret=os.getenv('LUNO_API_KEY_SECRET')) +if __name__ == "__main__": + c = Client(api_key_id=os.getenv("LUNO_API_KEY_ID"), api_key_secret=os.getenv("LUNO_API_KEY_SECRET")) res = c.get_tickers() print(res) time.sleep(0.5) - res = c.get_ticker(pair='XBTZAR') + res = c.get_ticker(pair="XBTZAR") print(res) time.sleep(0.5) - res = c.get_order_book(pair='XBTZAR') + res = c.get_order_book(pair="XBTZAR") print(res) time.sleep(0.5) - since = int(time.time()*1000)-24*60*59*1000 - res = c.list_trades(pair='XBTZAR', since=since) + since = int(time.time() * 1000) - 24 * 60 * 59 * 1000 + res = c.list_trades(pair="XBTZAR", since=since) print(res) time.sleep(0.5) - res = c.get_candles(pair='XBTZAR', since=since, duration=300) + res = c.get_candles(pair="XBTZAR", since=since, duration=300) print(res) time.sleep(0.5) @@ -33,9 +31,9 @@ print(res) time.sleep(0.5) - aid = '' - if res['balance']: - aid = res['balance'][0]['account_id'] + aid = "" + if res["balance"]: + aid = res["balance"][0]["account_id"] if aid: res = c.list_transactions(id=aid, min_row=1, max_row=10) @@ -51,15 +49,15 @@ print(res) time.sleep(0.5) - res = c.list_user_trades(pair='XBTZAR') + res = c.list_user_trades(pair="XBTZAR") print(res) time.sleep(0.5) - res = c.get_fee_info(pair='XBTZAR') + res = c.get_fee_info(pair="XBTZAR") print(res) time.sleep(0.5) - res = c.get_funding_address(asset='XBT') + res = c.get_funding_address(asset="XBT") print(res) time.sleep(0.5) @@ -67,9 +65,9 @@ print(res) time.sleep(0.5) - wid = '' - if res['withdrawals']: - wid = res['withdrawals'][0]['id'] + wid = "" + if res["withdrawals"]: + wid = res["withdrawals"][0]["id"] if wid: res = c.get_withdrawal(id=wid) diff --git a/luno_python/__init__.py b/luno_python/__init__.py index 636ac44..a1d5a2f 100644 --- a/luno_python/__init__.py +++ b/luno_python/__init__.py @@ -1 +1 @@ -VERSION = '0.0.10' +VERSION = "0.0.10" diff --git a/luno_python/base_client.py b/luno_python/base_client.py index 85c07fc..ed7f7f7 100644 --- a/luno_python/base_client.py +++ b/luno_python/base_client.py @@ -1,5 +1,6 @@ import json import platform + import requests import six @@ -11,7 +12,7 @@ from . import VERSION from .error import APIError -DEFAULT_BASE_URL = 'https://api.luno.com' +DEFAULT_BASE_URL = "https://api.luno.com" DEFAULT_TIMEOUT = 10 PYTHON_VERSION = platform.python_version() SYSTEM = platform.system() @@ -19,8 +20,7 @@ class BaseClient: - def __init__(self, base_url='', timeout=0, - api_key_id='', api_key_secret=''): + def __init__(self, base_url="", timeout=0, api_key_id="", api_key_secret=""): """ :type base_url: str :type timeout: float @@ -47,9 +47,9 @@ def set_base_url(self, base_url): :type base_url: str """ - if base_url == '': + if base_url == "": base_url = DEFAULT_BASE_URL - self.base_url = base_url.rstrip('/') + self.base_url = base_url.rstrip("/") def set_timeout(self, timeout): """Sets the timeout, in seconds, for requests made by the client. @@ -74,19 +74,19 @@ def do(self, method, path, req=None, auth=False): params = json.loads(json.dumps(req)) except Exception: params = None - headers = {'User-Agent': self.make_user_agent()} + headers = {"User-Agent": self.make_user_agent()} args = dict(timeout=self.timeout, params=params, headers=headers) if auth: - args['auth'] = (self.api_key_id, self.api_key_secret) + args["auth"] = (self.api_key_id, self.api_key_secret) url = self.make_url(path, params) res = self.session.request(method, url, **args) try: e = res.json() - if 'error' in e and 'error_code' in e: - raise APIError(e['error_code'], e['error']) + if "error" in e and "error_code" in e: + raise APIError(e["error_code"], e["error"]) return e except JSONDecodeError: - raise Exception('luno: unknown API error (%s)' % res.status_code) + raise Exception("luno: unknown API error (%s)" % res.status_code) def make_url(self, path, params): """ @@ -94,13 +94,12 @@ def make_url(self, path, params): :rtype: str """ if params: - for k, v in six.iteritems(params): - path = path.replace('{' + k + '}', str(v)) - return self.base_url + '/' + path.lstrip('/') + for k, v in params.items(): + path = path.replace("{" + k + "}", str(v)) + return self.base_url + "/" + path.lstrip("/") def make_user_agent(self): """ :rtype: str """ - return "LunoPythonSDK/%s python/%s %s %s" % \ - (VERSION, PYTHON_VERSION, SYSTEM, ARCH) + return f"LunoPythonSDK/{VERSION} python/{PYTHON_VERSION} {SYSTEM} {ARCH}" diff --git a/luno_python/client.py b/luno_python/client.py index c0e2c77..a6a7dde 100644 --- a/luno_python/client.py +++ b/luno_python/client.py @@ -30,9 +30,9 @@ def cancel_withdrawal(self, id): :type id: int """ req = { - 'id': id, + "id": id, } - return self.do('DELETE', '/api/1/withdrawals/{id}', req=req, auth=True) + return self.do("DELETE", "/api/1/withdrawals/{id}", req=req, auth=True) def create_account(self, currency, name): """Makes a call to POST /api/1/accounts. @@ -51,10 +51,10 @@ def create_account(self, currency, name): :type name: str """ req = { - 'currency': currency, - 'name': name, + "currency": currency, + "name": name, } - return self.do('POST', '/api/1/accounts', req=req, auth=True) + return self.do("POST", "/api/1/accounts", req=req, auth=True) def create_beneficiary(self, account_type, bank_account_number, bank_name, bank_recipient): """Makes a call to POST /api/1/beneficiaries. @@ -73,12 +73,12 @@ def create_beneficiary(self, account_type, bank_account_number, bank_name, bank_ :type bank_recipient: str """ req = { - 'account_type': account_type, - 'bank_account_number': bank_account_number, - 'bank_name': bank_name, - 'bank_recipient': bank_recipient, + "account_type": account_type, + "bank_account_number": bank_account_number, + "bank_name": bank_name, + "bank_recipient": bank_recipient, } - return self.do('POST', '/api/1/beneficiaries', req=req, auth=True) + return self.do("POST", "/api/1/beneficiaries", req=req, auth=True) def create_funding_address(self, asset, account_id=None, name=None): """Makes a call to POST /api/1/funding_address. @@ -97,11 +97,11 @@ def create_funding_address(self, asset, account_id=None, name=None): :type name: str """ req = { - 'asset': asset, - 'account_id': account_id, - 'name': name, + "asset": asset, + "account_id": account_id, + "name": name, } - return self.do('POST', '/api/1/funding_address', req=req, auth=True) + return self.do("POST", "/api/1/funding_address", req=req, auth=True) def create_withdrawal(self, amount, type, beneficiary_id=None, external_id=None, fast=None, reference=None): """Makes a call to POST /api/1/withdrawals. @@ -132,14 +132,14 @@ def create_withdrawal(self, amount, type, beneficiary_id=None, external_id=None, :type reference: str """ req = { - 'amount': amount, - 'type': type, - 'beneficiary_id': beneficiary_id, - 'external_id': external_id, - 'fast': fast, - 'reference': reference, + "amount": amount, + "type": type, + "beneficiary_id": beneficiary_id, + "external_id": external_id, + "fast": fast, + "reference": reference, } - return self.do('POST', '/api/1/withdrawals', req=req, auth=True) + return self.do("POST", "/api/1/withdrawals", req=req, auth=True) def delete_beneficiary(self, id): """Makes a call to DELETE /api/1/beneficiaries/{id}. @@ -152,9 +152,9 @@ def delete_beneficiary(self, id): :type id: int """ req = { - 'id': id, + "id": id, } - return self.do('DELETE', '/api/1/beneficiaries/{id}', req=req, auth=True) + return self.do("DELETE", "/api/1/beneficiaries/{id}", req=req, auth=True) def get_balances(self, assets=None, account_id=None): """Makes a call to GET /api/1/balance. @@ -173,19 +173,19 @@ def get_balances(self, assets=None, account_id=None): :type account_id: str """ req = { - 'assets': assets, + "assets": assets, } - response = self.do('GET', '/api/1/balance', req=req, auth=True) - + response = self.do("GET", "/api/1/balance", req=req, auth=True) + # If account_id is specified, filter to return only that account if account_id is not None: - if 'balance' in response: - for account in response['balance']: - if str(account.get('account_id')) == str(account_id): + if "balance" in response: + for account in response["balance"]: + if str(account.get("account_id")) == str(account_id): return account # If account_id not found, return None return None - + # Return full response if no account_id specified (backward compatibility) return response @@ -209,11 +209,11 @@ def get_candles(self, duration, pair, since): :type since: int """ req = { - 'duration': duration, - 'pair': pair, - 'since': since, + "duration": duration, + "pair": pair, + "since": since, } - return self.do('GET', '/api/exchange/1/candles', req=req, auth=True) + return self.do("GET", "/api/exchange/1/candles", req=req, auth=True) def get_fee_info(self, pair): """Makes a call to GET /api/1/fee_info. @@ -226,9 +226,9 @@ def get_fee_info(self, pair): :type pair: str """ req = { - 'pair': pair, + "pair": pair, } - return self.do('GET', '/api/1/fee_info', req=req, auth=True) + return self.do("GET", "/api/1/fee_info", req=req, auth=True) def get_funding_address(self, asset, address=None): """Makes a call to GET /api/1/funding_address. @@ -247,10 +247,10 @@ def get_funding_address(self, asset, address=None): :type address: str """ req = { - 'asset': asset, - 'address': address, + "asset": asset, + "address": address, } - return self.do('GET', '/api/1/funding_address', req=req, auth=True) + return self.do("GET", "/api/1/funding_address", req=req, auth=True) def get_move(self, client_move_id=None, id=None): """Makes a call to GET /api/exchange/1/move. @@ -269,10 +269,10 @@ def get_move(self, client_move_id=None, id=None): :type id: str """ req = { - 'client_move_id': client_move_id, - 'id': id, + "client_move_id": client_move_id, + "id": id, } - return self.do('GET', '/api/exchange/1/move', req=req, auth=True) + return self.do("GET", "/api/exchange/1/move", req=req, auth=True) def get_order(self, id): """Makes a call to GET /api/1/orders/{id}. @@ -285,9 +285,9 @@ def get_order(self, id): :type id: str """ req = { - 'id': id, + "id": id, } - return self.do('GET', '/api/1/orders/{id}', req=req, auth=True) + return self.do("GET", "/api/1/orders/{id}", req=req, auth=True) def get_order_book(self, pair): """Makes a call to GET /api/1/orderbook_top. @@ -302,9 +302,9 @@ def get_order_book(self, pair): :type pair: str """ req = { - 'pair': pair, + "pair": pair, } - return self.do('GET', '/api/1/orderbook_top', req=req, auth=False) + return self.do("GET", "/api/1/orderbook_top", req=req, auth=False) def get_order_book_full(self, pair): """Makes a call to GET /api/1/orderbook. @@ -323,9 +323,9 @@ def get_order_book_full(self, pair): :type pair: str """ req = { - 'pair': pair, + "pair": pair, } - return self.do('GET', '/api/1/orderbook', req=req, auth=False) + return self.do("GET", "/api/1/orderbook", req=req, auth=False) def get_order_v2(self, id): """Makes a call to GET /api/exchange/2/orders/{id}. @@ -338,9 +338,9 @@ def get_order_v2(self, id): :type id: str """ req = { - 'id': id, + "id": id, } - return self.do('GET', '/api/exchange/2/orders/{id}', req=req, auth=True) + return self.do("GET", "/api/exchange/2/orders/{id}", req=req, auth=True) def get_order_v3(self, client_order_id=None, id=None): """Makes a call to GET /api/exchange/3/order. @@ -355,10 +355,10 @@ def get_order_v3(self, client_order_id=None, id=None): :type id: str """ req = { - 'client_order_id': client_order_id, - 'id': id, + "client_order_id": client_order_id, + "id": id, } - return self.do('GET', '/api/exchange/3/order', req=req, auth=True) + return self.do("GET", "/api/exchange/3/order", req=req, auth=True) def get_ticker(self, pair): """Makes a call to GET /api/1/ticker. @@ -371,9 +371,9 @@ def get_ticker(self, pair): :type pair: str """ req = { - 'pair': pair, + "pair": pair, } - return self.do('GET', '/api/1/ticker', req=req, auth=False) + return self.do("GET", "/api/1/ticker", req=req, auth=False) def get_tickers(self, pair=None): """Makes a call to GET /api/1/tickers. @@ -388,9 +388,9 @@ def get_tickers(self, pair=None): :type pair: list """ req = { - 'pair': pair, + "pair": pair, } - return self.do('GET', '/api/1/tickers', req=req, auth=False) + return self.do("GET", "/api/1/tickers", req=req, auth=False) def get_withdrawal(self, id): """Makes a call to GET /api/1/withdrawals/{id}. @@ -403,9 +403,9 @@ def get_withdrawal(self, id): :type id: int """ req = { - 'id': id, + "id": id, } - return self.do('GET', '/api/1/withdrawals/{id}', req=req, auth=True) + return self.do("GET", "/api/1/withdrawals/{id}", req=req, auth=True) def list_beneficiaries(self, bank_recipient=None): """Makes a call to GET /api/1/beneficiaries. @@ -417,9 +417,9 @@ def list_beneficiaries(self, bank_recipient=None): :param bank_recipient: :type bank_recipient: str """ req = { - 'bank_recipient': bank_recipient, + "bank_recipient": bank_recipient, } - return self.do('GET', '/api/1/beneficiaries', req=req, auth=True) + return self.do("GET", "/api/1/beneficiaries", req=req, auth=True) def list_moves(self, before=None, limit=None): """Makes a call to GET /api/exchange/1/move/list_moves. @@ -435,10 +435,10 @@ def list_moves(self, before=None, limit=None): :type limit: int """ req = { - 'before': before, - 'limit': limit, + "before": before, + "limit": limit, } - return self.do('GET', '/api/exchange/1/move/list_moves', req=req, auth=True) + return self.do("GET", "/api/exchange/1/move/list_moves", req=req, auth=True) def list_orders(self, created_before=None, limit=None, pair=None, state=None): """Makes a call to GET /api/1/listorders. @@ -459,12 +459,12 @@ def list_orders(self, created_before=None, limit=None, pair=None, state=None): :type state: str """ req = { - 'created_before': created_before, - 'limit': limit, - 'pair': pair, - 'state': state, + "created_before": created_before, + "limit": limit, + "pair": pair, + "state": state, } - return self.do('GET', '/api/1/listorders', req=req, auth=True) + return self.do("GET", "/api/1/listorders", req=req, auth=True) def list_orders_v2(self, closed=None, created_before=None, limit=None, pair=None): """Makes a call to GET /api/exchange/2/listorders. @@ -487,12 +487,12 @@ def list_orders_v2(self, closed=None, created_before=None, limit=None, pair=None :type pair: str """ req = { - 'closed': closed, - 'created_before': created_before, - 'limit': limit, - 'pair': pair, + "closed": closed, + "created_before": created_before, + "limit": limit, + "pair": pair, } - return self.do('GET', '/api/exchange/2/listorders', req=req, auth=True) + return self.do("GET", "/api/exchange/2/listorders", req=req, auth=True) def list_pending_transactions(self, id): """Makes a call to GET /api/1/accounts/{id}/pending. @@ -507,9 +507,9 @@ def list_pending_transactions(self, id): :type id: int """ req = { - 'id': id, + "id": id, } - return self.do('GET', '/api/1/accounts/{id}/pending', req=req, auth=True) + return self.do("GET", "/api/1/accounts/{id}/pending", req=req, auth=True) def list_trades(self, pair, since=None): """Makes a call to GET /api/1/trades. @@ -529,10 +529,10 @@ def list_trades(self, pair, since=None): :type since: int """ req = { - 'pair': pair, - 'since': since, + "pair": pair, + "since": since, } - return self.do('GET', '/api/1/trades', req=req, auth=False) + return self.do("GET", "/api/1/trades", req=req, auth=False) def list_transactions(self, id, max_row, min_row): """Makes a call to GET /api/1/accounts/{id}/transactions. @@ -558,11 +558,11 @@ def list_transactions(self, id, max_row, min_row): :type min_row: int """ req = { - 'id': id, - 'max_row': max_row, - 'min_row': min_row, + "id": id, + "max_row": max_row, + "min_row": min_row, } - return self.do('GET', '/api/1/accounts/{id}/transactions', req=req, auth=True) + return self.do("GET", "/api/1/accounts/{id}/transactions", req=req, auth=True) def list_transfers(self, account_id, before=None, limit=None): """Makes a call to GET /api/exchange/1/transfers. @@ -591,13 +591,22 @@ def list_transfers(self, account_id, before=None, limit=None): :type limit: int """ req = { - 'account_id': account_id, - 'before': before, - 'limit': limit, + "account_id": account_id, + "before": before, + "limit": limit, } - return self.do('GET', '/api/exchange/1/transfers', req=req, auth=True) - - def list_user_trades(self, pair, after_seq=None, before=None, before_seq=None, limit=None, since=None, sort_desc=None): + return self.do("GET", "/api/exchange/1/transfers", req=req, auth=True) + + def list_user_trades( + self, + pair, + after_seq=None, + before=None, + before_seq=None, + limit=None, + since=None, + sort_desc=None, + ): """Makes a call to GET /api/1/listtrades. Returns a list of the recent Trades for a given currency pair for this user, sorted by oldest first. @@ -631,15 +640,15 @@ def list_user_trades(self, pair, after_seq=None, before=None, before_seq=None, l :type sort_desc: bool """ req = { - 'pair': pair, - 'after_seq': after_seq, - 'before': before, - 'before_seq': before_seq, - 'limit': limit, - 'since': since, - 'sort_desc': sort_desc, + "pair": pair, + "after_seq": after_seq, + "before": before, + "before_seq": before_seq, + "limit": limit, + "since": since, + "sort_desc": sort_desc, } - return self.do('GET', '/api/1/listtrades', req=req, auth=True) + return self.do("GET", "/api/1/listtrades", req=req, auth=True) def list_withdrawals(self, before_id=None, limit=None): """Makes a call to GET /api/1/withdrawals. @@ -655,10 +664,10 @@ def list_withdrawals(self, before_id=None, limit=None): :type limit: int """ req = { - 'before_id': before_id, - 'limit': limit, + "before_id": before_id, + "limit": limit, } - return self.do('GET', '/api/1/withdrawals', req=req, auth=True) + return self.do("GET", "/api/1/withdrawals", req=req, auth=True) def markets(self, pair=None): """Makes a call to GET /api/exchange/1/markets. @@ -670,9 +679,9 @@ def markets(self, pair=None): :type pair: list """ req = { - 'pair': pair, + "pair": pair, } - return self.do('GET', '/api/exchange/1/markets', req=req, auth=False) + return self.do("GET", "/api/exchange/1/markets", req=req, auth=False) def move(self, amount, credit_account_id, debit_account_id, client_move_id=None): """Makes a call to POST /api/exchange/1/move. @@ -699,14 +708,29 @@ def move(self, amount, credit_account_id, debit_account_id, client_move_id=None) :type client_move_id: str """ req = { - 'amount': amount, - 'credit_account_id': credit_account_id, - 'debit_account_id': debit_account_id, - 'client_move_id': client_move_id, + "amount": amount, + "credit_account_id": credit_account_id, + "debit_account_id": debit_account_id, + "client_move_id": client_move_id, } - return self.do('POST', '/api/exchange/1/move', req=req, auth=True) - - def post_limit_order(self, pair, price, type, volume, base_account_id=None, client_order_id=None, counter_account_id=None, post_only=None, stop_direction=None, stop_price=None, time_in_force=None, timestamp=None, ttl=None): + return self.do("POST", "/api/exchange/1/move", req=req, auth=True) + + def post_limit_order( + self, + pair, + price, + type, + volume, + base_account_id=None, + client_order_id=None, + counter_account_id=None, + post_only=None, + stop_direction=None, + stop_price=None, + time_in_force=None, + timestamp=None, + ttl=None, + ): """Makes a call to POST /api/1/postorder. Warning! Orders cannot be reversed once they have executed. @@ -766,23 +790,34 @@ def post_limit_order(self, pair, price, type, volume, base_account_id=None, clie :type ttl: int """ req = { - 'pair': pair, - 'price': price, - 'type': type, - 'volume': volume, - 'base_account_id': base_account_id, - 'client_order_id': client_order_id, - 'counter_account_id': counter_account_id, - 'post_only': post_only, - 'stop_direction': stop_direction, - 'stop_price': stop_price, - 'time_in_force': time_in_force, - 'timestamp': timestamp, - 'ttl': ttl, + "pair": pair, + "price": price, + "type": type, + "volume": volume, + "base_account_id": base_account_id, + "client_order_id": client_order_id, + "counter_account_id": counter_account_id, + "post_only": post_only, + "stop_direction": stop_direction, + "stop_price": stop_price, + "time_in_force": time_in_force, + "timestamp": timestamp, + "ttl": ttl, } - return self.do('POST', '/api/1/postorder', req=req, auth=True) - - def post_market_order(self, pair, type, base_account_id=None, base_volume=None, client_order_id=None, counter_account_id=None, counter_volume=None, timestamp=None, ttl=None): + return self.do("POST", "/api/1/postorder", req=req, auth=True) + + def post_market_order( + self, + pair, + type, + base_account_id=None, + base_volume=None, + client_order_id=None, + counter_account_id=None, + counter_volume=None, + timestamp=None, + ttl=None, + ): """Makes a call to POST /api/1/marketorder. A Market Order executes immediately, and either buys as much of the asset that can be bought for a set amount of fiat currency, or sells a set amount of the asset for as much as possible. @@ -821,19 +856,34 @@ def post_market_order(self, pair, type, base_account_id=None, base_volume=None, :type ttl: int """ req = { - 'pair': pair, - 'type': type, - 'base_account_id': base_account_id, - 'base_volume': base_volume, - 'client_order_id': client_order_id, - 'counter_account_id': counter_account_id, - 'counter_volume': counter_volume, - 'timestamp': timestamp, - 'ttl': ttl, + "pair": pair, + "type": type, + "base_account_id": base_account_id, + "base_volume": base_volume, + "client_order_id": client_order_id, + "counter_account_id": counter_account_id, + "counter_volume": counter_volume, + "timestamp": timestamp, + "ttl": ttl, } - return self.do('POST', '/api/1/marketorder', req=req, auth=True) - - def send(self, address, amount, currency, account_id=None, description=None, destination_tag=None, external_id=None, forex_notice_self_declaration=None, has_destination_tag=None, is_drb=None, is_forex_send=None, memo=None, message=None): + return self.do("POST", "/api/1/marketorder", req=req, auth=True) + + def send( + self, + address, + amount, + currency, + account_id=None, + description=None, + destination_tag=None, + external_id=None, + forex_notice_self_declaration=None, + has_destination_tag=None, + is_drb=None, + is_forex_send=None, + memo=None, + message=None, + ): """Makes a call to POST /api/1/send. Send assets from an Account. Please note that the asset type sent must match the receive address of the same cryptocurrency of the same type - Bitcoin to Bitcoin, Ethereum to Ethereum, etc. @@ -883,21 +933,21 @@ def send(self, address, amount, currency, account_id=None, description=None, des :type message: str """ req = { - 'address': address, - 'amount': amount, - 'currency': currency, - 'account_id': account_id, - 'description': description, - 'destination_tag': destination_tag, - 'external_id': external_id, - 'forex_notice_self_declaration': forex_notice_self_declaration, - 'has_destination_tag': has_destination_tag, - 'is_drb': is_drb, - 'is_forex_send': is_forex_send, - 'memo': memo, - 'message': message, + "address": address, + "amount": amount, + "currency": currency, + "account_id": account_id, + "description": description, + "destination_tag": destination_tag, + "external_id": external_id, + "forex_notice_self_declaration": forex_notice_self_declaration, + "has_destination_tag": has_destination_tag, + "is_drb": is_drb, + "is_forex_send": is_forex_send, + "memo": memo, + "message": message, } - return self.do('POST', '/api/1/send', req=req, auth=True) + return self.do("POST", "/api/1/send", req=req, auth=True) def send_fee(self, address, amount, currency): """Makes a call to GET /api/1/send_fee. @@ -923,11 +973,11 @@ def send_fee(self, address, amount, currency): :type currency: str """ req = { - 'address': address, - 'amount': amount, - 'currency': currency, + "address": address, + "amount": amount, + "currency": currency, } - return self.do('GET', '/api/1/send_fee', req=req, auth=True) + return self.do("GET", "/api/1/send_fee", req=req, auth=True) def stop_order(self, order_id): """Makes a call to POST /api/1/stoporder. @@ -943,9 +993,9 @@ def stop_order(self, order_id): :type order_id: str """ req = { - 'order_id': order_id, + "order_id": order_id, } - return self.do('POST', '/api/1/stoporder', req=req, auth=True) + return self.do("POST", "/api/1/stoporder", req=req, auth=True) def update_account_name(self, id, name): """Makes a call to PUT /api/1/accounts/{id}/name. @@ -960,12 +1010,30 @@ def update_account_name(self, id, name): :type name: str """ req = { - 'id': id, - 'name': name, + "id": id, + "name": name, } - return self.do('PUT', '/api/1/accounts/{id}/name', req=req, auth=True) - - def validate(self, address, currency, address_name=None, beneficiary_name=None, country=None, date_of_birth=None, destination_tag=None, has_destination_tag=None, institution_name=None, is_legal_entity=None, is_private_wallet=None, is_self_send=None, memo=None, nationality=None, physical_address=None, wallet_name=None): + return self.do("PUT", "/api/1/accounts/{id}/name", req=req, auth=True) + + def validate( + self, + address, + currency, + address_name=None, + beneficiary_name=None, + country=None, + date_of_birth=None, + destination_tag=None, + has_destination_tag=None, + institution_name=None, + is_legal_entity=None, + is_private_wallet=None, + is_self_send=None, + memo=None, + nationality=None, + physical_address=None, + wallet_name=None, + ): """Makes a call to POST /api/1/address/validate. Validate receive addresses, to which a customer wishes to make cryptocurrency sends, are verified under covering @@ -1021,24 +1089,24 @@ def validate(self, address, currency, address_name=None, beneficiary_name=None, :type wallet_name: str """ req = { - 'address': address, - 'currency': currency, - 'address_name': address_name, - 'beneficiary_name': beneficiary_name, - 'country': country, - 'date_of_birth': date_of_birth, - 'destination_tag': destination_tag, - 'has_destination_tag': has_destination_tag, - 'institution_name': institution_name, - 'is_legal_entity': is_legal_entity, - 'is_private_wallet': is_private_wallet, - 'is_self_send': is_self_send, - 'memo': memo, - 'nationality': nationality, - 'physical_address': physical_address, - 'wallet_name': wallet_name, + "address": address, + "currency": currency, + "address_name": address_name, + "beneficiary_name": beneficiary_name, + "country": country, + "date_of_birth": date_of_birth, + "destination_tag": destination_tag, + "has_destination_tag": has_destination_tag, + "institution_name": institution_name, + "is_legal_entity": is_legal_entity, + "is_private_wallet": is_private_wallet, + "is_self_send": is_self_send, + "memo": memo, + "nationality": nationality, + "physical_address": physical_address, + "wallet_name": wallet_name, } - return self.do('POST', '/api/1/address/validate', req=req, auth=True) + return self.do("POST", "/api/1/address/validate", req=req, auth=True) # vi: ft=python diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e4369ed --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[tool.black] +line-length = 120 +target-version = ['py37', 'py38', 'py39', 'py310', 'py311'] +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.venv + | env + | build + | dist + | \.egg-info +)/ +''' + +[tool.isort] +profile = "black" +line_length = 120 +skip_gitignore = true + +[tool.bandit] +exclude_dirs = ["tests", "env", "build"] +skips = ["B101"] # Skip assert_used check (common in tests) + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" +addopts = "-v --cov=luno_python --cov-report=term-missing" diff --git a/setup.cfg b/setup.cfg index fa878bd..0de66df 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,4 +2,4 @@ test=pytest [tool:pytest] -addopts=tests/ \ No newline at end of file +addopts=tests/ diff --git a/setup.py b/setup.py index 50365cb..3c8a0b3 100644 --- a/setup.py +++ b/setup.py @@ -1,23 +1,34 @@ -from setuptools import setup, find_packages +from setuptools import find_packages, setup from luno_python import VERSION setup( - name='luno-python', + name="luno-python", version=VERSION, - packages=find_packages(exclude=['tests']), - description='A Luno API client for Python', - author='Neil Garb', - author_email='neil@luno.com', - install_requires=['requests>=2.18.4', 'six>=1.11.0'], - license='MIT', - url='https://github.com/luno/luno-python', - download_url='https://github.com/luno/luno-python/tarball/%s' % (VERSION, ), - keywords='Luno API Bitcoin Ethereum', - test_suite='tests', - setup_requires=['pytest-runner'], + packages=find_packages(exclude=["tests"]), + description="A Luno API client for Python", + author="Neil Garb", + author_email="neil@luno.com", + install_requires=["requests>=2.18.4", "six>=1.11.0"], + license="MIT", + url="https://github.com/luno/luno-python", + download_url=f"https://github.com/luno/luno-python/tarball/{VERSION}", + keywords="Luno API Bitcoin Ethereum", + test_suite="tests", + setup_requires=["pytest-runner"], extras_require={ - "test": ["pytest", "pytest-cov", "requests_mock"] + "test": ["pytest", "pytest-cov", "requests_mock"], + "dev": [ + "pytest", + "pytest-cov", + "requests_mock", + "pre-commit", + "black", + "isort", + "flake8", + "flake8-docstrings", + "flake8-bugbear", + "bandit[toml]", + ], }, ) - diff --git a/tests/test_client.py b/tests/test_client.py index 2b0ec4a..2b40d14 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -10,212 +10,255 @@ from luno_python.client import Client from luno_python.error import APIError +# Mock response fixtures +MOCK_BALANCES_RESPONSE = { + "balance": [ + { + "account_id": "12345678910", + "asset": "XBT", + "balance": "0.00", + "reserved": "0.00", + "unconfirmed": "0.00", + }, + { + "account_id": "98765432100", + "asset": "ETH", + "balance": "1.50", + "reserved": "0.10", + "unconfirmed": "0.05", + }, + { + "account_id": "55555555555", + "asset": "ZAR", + "balance": "1000.00", + "reserved": "0.00", + "unconfirmed": "0.00", + }, + ] +} + +MOCK_BALANCES_RESPONSE_TWO_ACCOUNTS = { + "balance": [ + { + "account_id": "12345678910", + "asset": "XBT", + "balance": "0.00", + "reserved": "0.00", + "unconfirmed": "0.00", + }, + { + "account_id": "98765432100", + "asset": "ETH", + "balance": "1.50", + "reserved": "0.10", + "unconfirmed": "0.05", + }, + ] +} + +MOCK_BALANCES_RESPONSE_INTEGER_IDS = { + "balance": [ + { + "account_id": 12345678910, + "asset": "XBT", + "balance": "0.00", + "reserved": "0.00", + "unconfirmed": "0.00", + }, + { + "account_id": 98765432100, + "asset": "ETH", + "balance": "1.50", + "reserved": "0.10", + "unconfirmed": "0.05", + }, + ] +} + +MOCK_EMPTY_BALANCE_RESPONSE = {"balance": []} + +MOCK_MALFORMED_RESPONSE = {"some_other_key": "value"} + def test_client(): c = Client() - c.set_auth('api_key_id', 'api_key_secret') - c.set_base_url('base_url') + c.set_auth("api_key_id", "api_key_secret") + c.set_base_url("base_url") c.set_timeout(10) - assert c.api_key_id == 'api_key_id' - assert c.api_key_secret == 'api_key_secret' - assert c.base_url == 'base_url' + assert c.api_key_id == "api_key_id" + assert c.api_key_secret == "api_key_secret" + assert c.base_url == "base_url" assert c.timeout == 10 def test_client_do_basic(): c = Client() - c.set_base_url('mock://test/') + c.set_base_url("mock://test/") adapter = requests_mock.Adapter() - c.session.mount('mock', adapter) + c.session.mount("mock", adapter) - adapter.register_uri('GET', 'mock://test/', text='ok') + adapter.register_uri("GET", "mock://test/", text="ok") with pytest.raises(Exception): - res = c.do('GET', '/') + res = c.do("GET", "/") - adapter.register_uri('GET', 'mock://test/', text='{"key":"value"}') - res = c.do('GET', '/') - assert res['key'] == 'value' + adapter.register_uri("GET", "mock://test/", text='{"key":"value"}') + res = c.do("GET", "/") + assert res["key"] == "value" - adapter.register_uri('GET', 'mock://test/', text='{}', status_code=400) - res = c.do('GET', '/') # no exception, because no error present + adapter.register_uri("GET", "mock://test/", text="{}", status_code=400) + res = c.do("GET", "/") # no exception, because no error present - adapter.register_uri('GET', 'mock://test/', - text='{"error_code":"code","error":"message"}', - status_code=400) + adapter.register_uri("GET", "mock://test/", text='{"error_code":"code","error":"message"}', status_code=400) with pytest.raises(APIError) as e: - res = c.do('GET', '/') - assert e.value.code == 'code' - assert e.value.message == 'message' + res = c.do("GET", "/") + assert e.value.code == "code" + assert e.value.message == "message" def test_get_balances_without_account_id(): """Test get_balances without account_id parameter (backward compatibility)""" c = Client() - c.set_base_url('mock://test/') + c.set_base_url("mock://test/") adapter = requests_mock.Adapter() - c.session.mount('mock', adapter) - - # Mock the API response - mock_response = { - 'balance': [ - {'account_id': '12345678910', 'asset': 'XBT', 'balance': '0.00', 'reserved': '0.00', 'unconfirmed': '0.00'}, - {'account_id': '98765432100', 'asset': 'ETH', 'balance': '1.50', 'reserved': '0.10', 'unconfirmed': '0.05'}, - {'account_id': '55555555555', 'asset': 'ZAR', 'balance': '1000.00', 'reserved': '0.00', 'unconfirmed': '0.00'} - ] - } - - adapter.register_uri('GET', 'mock://test/api/1/balance', json=mock_response) - + c.session.mount("mock", adapter) + + adapter.register_uri("GET", "mock://test/api/1/balance", json=MOCK_BALANCES_RESPONSE) + # Test without account_id - should return full response result = c.get_balances() - assert result == mock_response - assert 'balance' in result - assert len(result['balance']) == 3 + assert result == MOCK_BALANCES_RESPONSE + assert "balance" in result + assert len(result["balance"]) == 3 def test_get_balances_with_valid_account_id(): """Test get_balances with valid account_id parameter""" c = Client() - c.set_base_url('mock://test/') + c.set_base_url("mock://test/") adapter = requests_mock.Adapter() - c.session.mount('mock', adapter) - - # Mock the API response - mock_response = { - 'balance': [ - {'account_id': '12345678910', 'asset': 'XBT', 'balance': '0.00', 'reserved': '0.00', 'unconfirmed': '0.00'}, - {'account_id': '98765432100', 'asset': 'ETH', 'balance': '1.50', 'reserved': '0.10', 'unconfirmed': '0.05'}, - {'account_id': '55555555555', 'asset': 'ZAR', 'balance': '1000.00', 'reserved': '0.00', 'unconfirmed': '0.00'} - ] - } - - adapter.register_uri('GET', 'mock://test/api/1/balance', json=mock_response) - + c.session.mount("mock", adapter) + + adapter.register_uri("GET", "mock://test/api/1/balance", json=MOCK_BALANCES_RESPONSE) + # Test with valid account_id - should return single account - result = c.get_balances(account_id='12345678910') - expected = {'account_id': '12345678910', 'asset': 'XBT', 'balance': '0.00', 'reserved': '0.00', 'unconfirmed': '0.00'} + result = c.get_balances(account_id="12345678910") + expected = { + "account_id": "12345678910", + "asset": "XBT", + "balance": "0.00", + "reserved": "0.00", + "unconfirmed": "0.00", + } assert result == expected - + # Test with another valid account_id - result = c.get_balances(account_id='98765432100') - expected = {'account_id': '98765432100', 'asset': 'ETH', 'balance': '1.50', 'reserved': '0.10', 'unconfirmed': '0.05'} + result = c.get_balances(account_id="98765432100") + expected = { + "account_id": "98765432100", + "asset": "ETH", + "balance": "1.50", + "reserved": "0.10", + "unconfirmed": "0.05", + } assert result == expected def test_get_balances_with_invalid_account_id(): """Test get_balances with invalid account_id parameter""" c = Client() - c.set_base_url('mock://test/') + c.set_base_url("mock://test/") adapter = requests_mock.Adapter() - c.session.mount('mock', adapter) - - # Mock the API response - mock_response = { - 'balance': [ - {'account_id': '12345678910', 'asset': 'XBT', 'balance': '0.00', 'reserved': '0.00', 'unconfirmed': '0.00'}, - {'account_id': '98765432100', 'asset': 'ETH', 'balance': '1.50', 'reserved': '0.10', 'unconfirmed': '0.05'} - ] - } - - adapter.register_uri('GET', 'mock://test/api/1/balance', json=mock_response) - + c.session.mount("mock", adapter) + + adapter.register_uri("GET", "mock://test/api/1/balance", json=MOCK_BALANCES_RESPONSE_TWO_ACCOUNTS) + # Test with invalid account_id - should return None - result = c.get_balances(account_id='99999999999') + result = c.get_balances(account_id="99999999999") assert result is None def test_get_balances_with_account_id_and_assets(): """Test get_balances with both account_id and assets parameters""" c = Client() - c.set_base_url('mock://test/') + c.set_base_url("mock://test/") adapter = requests_mock.Adapter() - c.session.mount('mock', adapter) - - # Mock the API response - mock_response = { - 'balance': [ - {'account_id': '12345678910', 'asset': 'XBT', 'balance': '0.00', 'reserved': '0.00', 'unconfirmed': '0.00'}, - {'account_id': '98765432100', 'asset': 'ETH', 'balance': '1.50', 'reserved': '0.10', 'unconfirmed': '0.05'} - ] - } - - adapter.register_uri('GET', 'mock://test/api/1/balance', json=mock_response) - + c.session.mount("mock", adapter) + + adapter.register_uri("GET", "mock://test/api/1/balance", json=MOCK_BALANCES_RESPONSE_TWO_ACCOUNTS) + # Test with both parameters - result = c.get_balances(assets=['XBT'], account_id='12345678910') - expected = {'account_id': '12345678910', 'asset': 'XBT', 'balance': '0.00', 'reserved': '0.00', 'unconfirmed': '0.00'} + result = c.get_balances(assets=["XBT"], account_id="12345678910") + expected = { + "account_id": "12345678910", + "asset": "XBT", + "balance": "0.00", + "reserved": "0.00", + "unconfirmed": "0.00", + } assert result == expected def test_get_balances_with_account_id_type_conversion(): """Test get_balances with account_id type conversion (string vs int)""" c = Client() - c.set_base_url('mock://test/') + c.set_base_url("mock://test/") adapter = requests_mock.Adapter() - c.session.mount('mock', adapter) - - # Mock the API response with integer account_id - mock_response = { - 'balance': [ - {'account_id': 12345678910, 'asset': 'XBT', 'balance': '0.00', 'reserved': '0.00', 'unconfirmed': '0.00'}, - {'account_id': 98765432100, 'asset': 'ETH', 'balance': '1.50', 'reserved': '0.10', 'unconfirmed': '0.05'} - ] - } - - adapter.register_uri('GET', 'mock://test/api/1/balance', json=mock_response) - + c.session.mount("mock", adapter) + + adapter.register_uri("GET", "mock://test/api/1/balance", json=MOCK_BALANCES_RESPONSE_INTEGER_IDS) + # Test with string account_id when API returns integer - should work due to type conversion - result = c.get_balances(account_id='12345678910') - expected = {'account_id': 12345678910, 'asset': 'XBT', 'balance': '0.00', 'reserved': '0.00', 'unconfirmed': '0.00'} + result = c.get_balances(account_id="12345678910") + expected = { + "account_id": 12345678910, + "asset": "XBT", + "balance": "0.00", + "reserved": "0.00", + "unconfirmed": "0.00", + } assert result == expected def test_get_balances_with_empty_balance_response(): """Test get_balances when API returns empty balance list""" c = Client() - c.set_base_url('mock://test/') + c.set_base_url("mock://test/") adapter = requests_mock.Adapter() - c.session.mount('mock', adapter) + c.session.mount("mock", adapter) + + adapter.register_uri("GET", "mock://test/api/1/balance", json=MOCK_EMPTY_BALANCE_RESPONSE) - # Mock empty response - mock_response = {'balance': []} - - adapter.register_uri('GET', 'mock://test/api/1/balance', json=mock_response) - # Test with account_id on empty response - result = c.get_balances(account_id='12345678910') + result = c.get_balances(account_id="12345678910") assert result is None - + # Test without account_id on empty response result = c.get_balances() - assert result == mock_response + assert result == MOCK_EMPTY_BALANCE_RESPONSE def test_get_balances_with_malformed_response(): """Test get_balances when API returns malformed response""" c = Client() - c.set_base_url('mock://test/') + c.set_base_url("mock://test/") adapter = requests_mock.Adapter() - c.session.mount('mock', adapter) + c.session.mount("mock", adapter) + + adapter.register_uri("GET", "mock://test/api/1/balance", json=MOCK_MALFORMED_RESPONSE) - # Mock response without 'balance' key - mock_response = {'some_other_key': 'value'} - - adapter.register_uri('GET', 'mock://test/api/1/balance', json=mock_response) - # Test with account_id on malformed response - result = c.get_balances(account_id='12345678910') + result = c.get_balances(account_id="12345678910") assert result is None - + # Test without account_id on malformed response result = c.get_balances() - assert result == mock_response + assert result == MOCK_MALFORMED_RESPONSE