Skip to content

Commit

Permalink
Release v1.0.2 (#17)
Browse files Browse the repository at this point in the history
  • Loading branch information
urischwartz-cb authored Jan 10, 2024
1 parent 7bd386e commit 8e57279
Show file tree
Hide file tree
Showing 9 changed files with 110 additions and 10 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [1.0.2] - 2024-JAN-10

### Added
- Support for files for using JSON files for API key and secret
- Improve user facing messages for common errors

## [1.0.1] - 2024-JAN-3

### Added
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Coinbase Advanced Trading API Python SDK
# Coinbase Advanced API Python SDK
[![PyPI version](https://badge.fury.io/py/coinbase-advanced-py.svg)](https://badge.fury.io/py/coinbase-advanced-py)
[![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](https://opensource.org/license/apache-2-0/)
[![Code Style](https://img.shields.io/badge/code_style-black-black)](https://black.readthedocs.io/en/stable/)
Expand Down Expand Up @@ -40,6 +40,15 @@ api_secret = "-----BEGIN EC PRIVATE KEY-----\nYOUR PRIVATE KEY\n-----END EC PRIV

client = RESTClient(api_key=api_key, api_secret=api_secret)
```
After creating your API key, a json file will be downloaded to your computer. It's possible to pass in the path to this file as an argument:
```python
client = RESTClient(key_file="path/to/coinbase_cloud_api_key.json")
```
We also support passing a file-like object as the `key_file` argument:
```python
from io import StringIO
client = RESTClient(key_file=StringIO('{"name": "key-name", "privateKey": "private-key"}'))
```
You can also set a timeout in seconds for your REST requests like so:
```python
client = RESTClient(api_key=api_key, api_secret=api_secret, timeout=5)
Expand Down
2 changes: 1 addition & 1 deletion coinbase/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.0.1"
__version__ = "1.0.2"
13 changes: 11 additions & 2 deletions coinbase/jwt_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,17 @@


def build_jwt(key_var, secret_var, service, uri=None):
private_key_bytes = secret_var.encode("utf-8")
private_key = serialization.load_pem_private_key(private_key_bytes, password=None)
try:
private_key_bytes = secret_var.encode("utf-8")
private_key = serialization.load_pem_private_key(
private_key_bytes, password=None
)
except ValueError as e:
# This handles errors like incorrect key format
raise Exception(
f"{e}\n"
"Are you sure you generated your key at https://cloud.coinbase.com/access/api ?"
)

jwt_data = {
"sub": key_var,
Expand Down
31 changes: 26 additions & 5 deletions coinbase/rest/rest_base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import json
import os
from typing import Optional
from typing import IO, Optional, Union

import requests
from requests.exceptions import HTTPError
Expand All @@ -17,10 +17,15 @@ def handle_exception(response):
reason = response.reason

if 400 <= response.status_code < 500:
http_error_msg = (
f"{response.status_code} Client Error: {reason} {response.text}"
)

if (
response.status_code == 403
and '"error_details":"Missing required scopes"' in response.text
):
http_error_msg = f"{response.status_code} Client Error: Missing Required Scopes. Please verify your API keys include the necessary permissions."
else:
http_error_msg = (
f"{response.status_code} Client Error: {reason} {response.text}"
)
elif 500 <= response.status_code < 600:
http_error_msg = (
f"{response.status_code} Server Error: {reason} {response.text}"
Expand All @@ -35,9 +40,25 @@ def __init__(
self,
api_key: Optional[str] = os.getenv(API_ENV_KEY),
api_secret: Optional[str] = os.getenv(API_SECRET_ENV_KEY),
key_file: Optional[Union[IO, str]] = None,
base_url=BASE_URL,
timeout=None,
):
if (api_key is not None or api_secret is not None) and key_file is not None:
raise Exception(f"Cannot specify both api_key and key_file in constructor")

if key_file is not None:
try:
if isinstance(key_file, str):
with open(key_file, "r") as file:
key_json = json.load(file)
else:
key_json = json.load(key_file)
api_key = key_json["name"]
api_secret = key_json["privateKey"]
except json.JSONDecodeError as e:
raise Exception(f"Error decoding JSON: {e}")

if api_key is None:
raise Exception(
f"Must specify env var COINBASE_API_KEY or pass api_key in constructor"
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
name="coinbase-advanced-py",
version=about["__version__"],
license="Apache 2.0",
description="Coinbase Advanced Trade API Python SDK",
description="Coinbase Advanced API Python SDK",
long_description=README,
long_description_content_type="text/markdown",
author="Coinbase",
Expand Down
37 changes: 37 additions & 0 deletions tests/rest/test_rest_base.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import unittest
from io import StringIO

from requests.exceptions import HTTPError
from requests_mock import Mocker
Expand Down Expand Up @@ -176,3 +177,39 @@ def test_server_error(self):

with self.assertRaises(HTTPError):
client.get("/api/v3/brokerage/accounts")

def test_key_file_string(self):
try:
RESTClient(key_file="tests/test_api_key.json")
except Exception as e:
self.fail(f"An unexpected exception occurred: {e}")

def test_key_file_object(self):
try:
key_file_object = StringIO(
'{"name": "test-api-key-name","privateKey": "test-api-key-private-key"}'
)
RESTClient(key_file=key_file_object)
except Exception as e:
self.fail(f"An unexpected exception occurred: {e}")

def test_key_file_no_key(self):
with self.assertRaises(Exception):
key_file_object = StringIO('{"field_1": "value_1","field_2": "value_2"}')
RESTClient(key_file=key_file_object)

def test_key_file_multiple_key_inputs(self):
with self.assertRaises(Exception):
key_file_object = StringIO('{"field_1": "value_1","field_2": "value_2"}')
RESTClient(
api_key=TEST_API_KEY,
api_secret=TEST_API_SECRET,
key_file=key_file_object,
)

def test_key_file_invalid_json(self):
with self.assertRaises(Exception):
key_file_object = StringIO(
'"name": "test-api-key-name","privateKey": "test-api-key-private-key"'
)
RESTClient(key_file=key_file_object)
13 changes: 13 additions & 0 deletions tests/test_api_key.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "test-api-key-name",
"privateKey": "test-api-key-private-key",
"nickname": "TestApiKey",
"scopes": [
"rat#view",
"rat#trade",
"rat#transfer"
],
"allowedIps": [],
"keyType": "TRADING_KEY",
"enabled": true
}
5 changes: 5 additions & 0 deletions tests/test_jwt_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,8 @@ def test_build_ws_jwt(self):
self.assertEqual(decoded_data["aud"], [WS_SERVICE])
self.assertNotIn("uri", decoded_data)
self.assertEqual(decoded_header["kid"], TEST_API_KEY)

def test_build_rest_jwt_error(self):
with self.assertRaises(Exception):
uri = jwt_generator.format_jwt_uri("GET", "/api/v3/brokerage/accounts")
jwt_generator.build_rest_jwt(uri, TEST_API_KEY, "bad_secret")

0 comments on commit 8e57279

Please sign in to comment.