# ECOM SDK
Source: https://speakeasy.com/blog/how-to-create-a-custom-python-sdk

import { Callout } from "@/mdx/components";

This tutorial demonstrates one way to code and package a Python SDK.

## Why Are We Building an SDK?

Since the 2000s, HTTP APIs have enabled businesses to be more connected than ever and, as a result, produce higher utility and profit — no wonder APIs have exploded in popularity.

So, your business has created an API? Great! At first, developers might use `curl` or create a Postman collection to experiment with your API. However, the true power of an API is only unleashed when developers can interact with it in an automated and programmatic way. To get some real work done, you'll want to wrap the HTTP API in your language of choice, so that it can be testable and have the most important aspects abstracted for your convenience and productivity.

## What Are We Building?

We'll build a Python SDK that wraps an HTTP API.

Fictional e-commerce startup ECOM enables the selling of products through the creation of online stores and associated products on its website. The ECOM HTTP API enables developers to create stores and manage products in an automated way. We want to give Python programmers easy access to the ECOM API with a robust, secure, and user-friendly SDK that handles the underlying HTTP API service in a type-safe, programmatic way.

## Example SDK Repository

You can follow along without downloading our complete example repository, but if you get stuck or want to see the final result, you can find the code on GitHub at [speakeasy-api/ecom-sdk](https://github.com/speakeasy-api/ecom-sdk).

## Initialize the Project

Before we begin coding the SDK, let's set up our project.

### Install pyenv

While not strictly necessary in all cases, we prefer to manage Python versions with pyenv. If you're building the SDK with us, [install `pyenv`](https://github.com/pyenv/pyenv?tab=readme-ov-file#installation) for your operating system.

On macOS, you can install `pyenv` with Homebrew:

```bash
brew install pyenv
```

### Decide on a Python Version

<Callout title="Python Versions" type="warning">
  Deciding on a [Python version](https://devguide.python.org/versions/) that
  your SDK will support is important. At the time of writing, Python 3.8 is the
  minimum version supported by many popular libraries and frameworks. Python
  3.12 is the latest stable version. Even though Python 2 is no longer
  supported, some older projects still use it, so you may need to support Python
  2.7 if your SDK is to be used in internal legacy systems that have not yet
  been updated.
</Callout>

For this tutorial, we'll use Python 3.8, because it is the oldest version that still receives security updates.

### Install Python 3.8

Install Python 3.8 with `pyenv`:

```bash
pyenv install 3.8.19
```

### Set Up a Python Virtual Environment

We'll set up a Python virtual environment to isolate our project dependencies from the system Python installation.

Create a new Python 3.8 virtual environment:

```bash
pyenv virtualenv 3.8.19 ecom-sdk
```

Activate the virtual environment:

```bash
pyenv activate ecom-sdk
```

After activating the virtual environment, depending on the shell you're using, your shell prompt might change to indicate that you are now using the virtual environment. Any Python packages you install will be installed in the virtual environment and won't affect the system Python installation.

### Upgrade pip

When you create a new virtual environment, it's good practice to upgrade `pip` to the latest version:

```bash
python3.8 -m pip install --upgrade pip
```

### Decide on a Build Backend

<Callout title="Python Packaging" type="warning">
  The [complexities of Python
  packaging](https://packaging.python.org/en/latest/) can make navigating your
  options challenging. We will outline the options briefly before selecting an
  option for this particular example.
</Callout>

In the past, Python used a `setup.py` or `setup.cfg` configuration file to prepare Python packages for distribution, but new standards (like PEPs 517, 518, 621, and 660) and build tools are modernizing the Python packaging landscape. We want to configure a modern build backend for our SDK and we have many options to choose from:

- [Hatchling](https://hatch.pypa.io/latest/history/hatchling/)
- [Poetry](https://python-poetry.org/)
- [PDM](https://pdm-project.org/)
- [Setuptools](https://pypi.org/project/setuptools/)
- [Flit](https://flit.pypa.io/en/stable/)
- [Maturin](https://github.com/PyO3/maturin)

Some factors to consider when selecting a build backend include:

- **Python version support:** Some build backends only support a subset of Python versions, so ensure your chosen build backend supports the correct versions.
- **Features:** Determine the features you need and choose a backend that meets your requirements. For example, do you need features like project management tools, or package uploading and installation capabilities?
- **Extension support:** Do you need support for extension modules in other languages?

We'll use [Hatchling](https://hatch.pypa.io/latest/history/hatchling/) in this tutorial for its convenient defaults and ease of configurability.

### Set Up a Project With Hatchling

First, install Hatch:

```bash
pip install hatch
```

Now, create a new project with Hatch:

```bash
hatch new -i
```

The `-i` flag tells Hatch to ask for project information interactively. Enter the following information when prompted:

```bash
Project name: ECOM SDK
Description: Python SDK for the ECOM HTTP API
```

Hatch will create a new project directory with the name `ecom-sdk` and initialize it with a basic `pyproject.toml` file.

Change into the project directory:

```bash
cd ecom-sdk
```

### See the Project Structure

Run `tree` to see the project structure:

```bash
tree .
```

The project directory should now contain the following files:

```bash
.
├── LICENSE.txt
├── README.md
├── pyproject.toml
├── src
│   └── ecom_sdk
│       ├── __about__.py
│       └── __init__.py
└── tests
    └── __init__.py

4 directories, 6 files
```

The `LICENSE.txt` file contains the MIT license, the `README.md` file has a short installation hint, and the `pyproject.toml` file contains the project metadata.

### Update the README File

If you continue to support and expand this SDK, you'll want to keep the `README.md` file up to date with the latest documentation, providing installation and usage instructions, and any other information relevant to your users. Without a good README, developers won't know where to start and won't use the SDK.

For now, let's add a short description of what the SDK does:

```markdown


Python SDK for the ECOM HTTP API
```

### Create a Basic SDK

Add a `./src/ecom_sdk/sdk.py` file containing Python code, for example:

```python
def sdkFunction():
    return 1
```

We'll use the `./src/ecom_sdk/sdk.py` file to ensure our testing setup works.

### Create a Test

As we add functionality to the SDK, we will populate the `./tests` directory with tests.

For now, create a `./src/ecom_sdk/test_sdk.py` file containing test code:

```python
import src.ecom_sdk.sdk

def test_sdk():
    assert src.ecom_sdk.sdk.sdkFunction() == 1
```

Later in this guide, we'll run the test with scripts provided by Hatch.

### Inspect the Build Backend Configuration

The `pyproject.toml` file contains the project metadata and build backend configuration.

Be sure to take a peek at the `pyproject.toml` [guide](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#writing-pyproject-toml) and [specification](https://packaging.python.org/en/latest/specifications/pyproject-toml/#pyproject-toml-specification) for details on all the possible metadata fields available for the `[project]` table. For example, you may want to enhance the discoverability of your SDK on PyPI by specifying keyword metadata or additional classifiers.

### Test Hatch

Here's the Hatch test script that we'll use to run tests:

```bash
hatch run test
```

The first time you run the test script, Hatch will install the necessary dependencies and run the tests. Subsequent runs will be faster because the dependencies are already installed.

After running the test script, you should see output similar to the following:

```bash
========================= test session starts ==========================
platform darwin -- Python 3.12.3, pytest-8.1.1, pluggy-1.5.0
rootdir: /speakeasy/ecom-sdk
configfile: pyproject.toml
collected 1 item

tests/test_sdk.py .                                              [100%]

========================== 1 passed in 0.00s ===========================
```

## Code the SDK

Now that we have the project set up, let's start coding the SDK.

### Add an SDK Method To Fetch a List of Stores

In the `./src/ecom_sdk/sdk.py` file, define a class, constructor, and `list_stores()` method for fetching a list of stores from our API:

```python
import requests

HTTP_TIMEOUT_SECONDS = 10


class EComSDK:
    def __init__(self, api_url, api_key):
        self.api_url = api_url
        self.api_key = api_key

    def list_stores(self):
        r = requests.get(
            self.api_url + "/store",
            headers={"X-API-KEY": self.api_key},
            timeout=HTTP_TIMEOUT_SECONDS,
        )
        if r.status_code == 200:
            return r.json()
        else:
            raise Exception("Invalid response status code: " +
                            str(r.status_code))
```

Now we can begin writing tests in the `./tests/test_sdk.py` file to test that the newly added `list_stores()` method works as intended.

In `requests.get()`, we use `HTTP_TIMEOUT_SECONDS` to set a maximum bound for waiting for the request to finish. If we don't set a timeout, the `requests` library will wait for a response forever.

Add the following to `./tests/test_sdk.py`:

```python
from src.ecom_sdk.sdk import EComSDK
import responses
from responses import matchers

api_url = "https://example.com"
api_key = "hunter2"


def test_sdk_class():
    sdk = EComSDK(api_url, api_key)
    assert sdk.api_url == api_url
    assert sdk.api_key == api_key


@responses.activate
def test_sdk_list_stores():
    responses.add(
        responses.GET,
        api_url + "/store",
        json=[
            {"id": 1, "name": "Lidl", "products": 10},
            {"id": 2, "name": "Walmart", "products": 15},
        ],
        status=200,
        match=[matchers.header_matcher({"X-API-KEY": api_key})],
    )

    sdk = EComSDK(api_url, api_key)
    stores = sdk.list_stores()

    assert len(stores) == 2
    assert stores[0]["id"] == 1
    assert stores[0]["name"] == "Lidl"
    assert stores[1]["id"] == 2
    assert stores[1]["name"] == "Walmart"
```

Here we use the `responses` library to mock API responses since we don't have a test or staging version of the ECOM HTTP API. However, even with the ECOM API, we might choose to have some part of the testing strategy mock the API responses as this approach can be faster and tightly tests code without external dependencies.

The `test_sdk_class()` test function checks that we can instantiate the class and the correct values are set internally.

The `test_sdk_list_stores()` test function makes a call to `sdk.list_stores()` to test that it receives the expected response, which is a JSON array of stores associated with the user's account.

### Add Dependencies

We need to add the `requests` and `responses` libraries to the project dependencies. Add the following to the `pyproject.toml` file:

```toml
dependencies = [
  "requests",
  "responses",
  "pydantic",
]
```

### Run the Tests

Let's run the Hatch `test` script we configured earlier to check that everything is working correctly:

```bash
hatch run test
```

Once Hatch has installed our new dependencies, you should see output similar to the following:

```bash
=============================== test session starts ================================
platform darwin -- Python 3.12.3, pytest-8.1.1, pluggy-1.5.0
rootdir: /speakeasy/ecom-sdk
configfile: pyproject.toml
collected 2 items

tests/test_sdk.py ..                                                         [100%]

================================ 2 passed in 0.09s =================================
```

## Exception Handling

To create a pleasant surface for our SDK users, we'll hide details of HTTP implementation behind exception handling and provide helpful error messages should things go wrong.

Let's modify the `list_stores()` function to catch some more common errors and print helpful information for the user:

```python filename="src/ecom_sdk/sdk.py"
import requests

HTTP_TIMEOUT_SECONDS = 10


class EComSDK:
    def __init__(self, api_url, api_key):
        self.api_url = api_url
        self.api_key = api_key

    def list_stores(self):
        try:
            r = requests.get(
                self.api_url + "/store",
                headers={"X-API-KEY": self.api_key},
                timeout=HTTP_TIMEOUT_SECONDS,
            )
            r.raise_for_status()
        except requests.exceptions.ConnectionError as err:
            raise ValueError("Connection error, check `EComSDK.api_url` is set correctly") from err
        except requests.exceptions.HTTPError as err:
            if err.response.status_code == 403:
                raise ValueError("Authentication error, check `EComSDK.api_key` is set correctly") from err
            else:
                raise

        return r.json()
```

When an exception is raised from the call to `requests.get()`, we catch the exception, add useful information, and then re-raise the exception for the SDK user to handle in their code.

The call to `r.raise_for_status()` modifies the `requests` library behavior to raise an `HTTPError` if the HTTP response status code is unsuccessful. Read more about `raise_for_status()` in the [Requests library documentation](https://requests.readthedocs.io/en/latest/api/#requests.Response.raise_for_status).

Now we'll add tests for a 403 response and a connection error to the `test_sdk.py` file. We'll also import the `requests` library into the `test_sdk.py` file:

```python filename="tests/test_sdk.py"
import requests