Skip to content

Commit

Permalink
Merge branch 'master' of github.com:cityofaustin/pypgrest
Browse files Browse the repository at this point in the history
  • Loading branch information
johnclary committed Nov 14, 2021
2 parents a98baef + ba8008d commit 5cd7203
Show file tree
Hide file tree
Showing 5 changed files with 274 additions and 122 deletions.
31 changes: 31 additions & 0 deletions .github/workflows/pypi-dev.yml
@@ -0,0 +1,31 @@
# This workflows will upload a Python Package using Twine when a release is created
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries

name: Publish dev package to PyPI

on:
push:
branches: [ "dev" ]

jobs:
deploy:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
- name: Build and publish
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
# note the use of `pypi-dev` to flag our dev package deployment
python setup.py pypi-dev sdist bdist_wheel
twine upload dist/*
31 changes: 31 additions & 0 deletions .github/workflows/pypi-prod.yml
@@ -0,0 +1,31 @@
# This workflows will upload a Python Package using Twine when a release is created
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries

name: Publish prod package to PyPI

on:
release:
types: [created]

jobs:
deploy:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
- name: Build and publish
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
python setup.py sdist bdist_wheel
twine upload dist/*
63 changes: 49 additions & 14 deletions README.md
@@ -1,42 +1,77 @@
# Pypgrest
A python client for interacting with PostgREST APIs.

A Python client for interacting with PostgREST APIs.

## Installation

```
$ pip install pypgrest
```

## Usage
## Quick start

```python
>>> from pypgrest import Postgrest

>>> pgrest = Postgrest("https://api.tacos.com", auth="secretsalsa")
>>> client = Postgrest("https://api.tacos.com", token="secretsalsa")

# See postgrest docs for supported query params
>>> params = {
"select" : "name,tortilla,cost",
"tortilla" : "is.corn",
"limit" : 100
"limit" : 100,
"order": "name"
}

# Supported methods are select, insert, update, and upsert
>>> pgrest.select(params=params)
[{ "name" : "al pastor", "tortilla" : "corn", "cost" : "2.01" }, ... ]
# Supported methods are select, insert, update, upsert, and delete
>>> client.select(resource="menu", params=params, pagination=True, headers=None)
# [{ "name" : "al pastor", "tortilla" : "corn", "cost" : "2.01" }, ... ]

>>> payload = [{ "id" : 23, "cost" : "2.25" }, { "id" : 26, "cost" : "1.25" }]

>>> pgrest.upsert(payload)
[{ "id" : 23, "cost" : "2.25", "name" : "al pastor", ... }, ... ]
# [{ "id" : 23, "cost" : "2.25", "name" : "al pastor", ... }, ... ]

# You can inspect the response object at `self.res`:
>>> client.res.status_code
# 201
```

## Headers

The client is initialized with `Content-Type=application/json` and (if you supply a token) `Authorization` headers. You can supply additional headers on construction, or per request.

```python
>>> client = Postgrest(
"https://api.tacos.com",
token="secretsalsa",
headers={"Prefer": "return=representation"}
)

# You can inspect the `Requests` response object like so:
>>> pgrest.res.status_code
201
>>> client.headers
# {"Content-Type": 'application/json', 'Authorization': 'Bearer secretsalsa', 'Prefer': 'return=representation'}

>>> client.insert(
resource="menu",
data={"id": 5, "name": "barbacoa"},
headers={"Prefer": "return=headers-only"}
)

# If results are paginated the client will continue to request until the specified
# limit (default 1000) is met.
>>> pgrest.select(params={"limit" : 1000})
```

## Limits and pagination

By default, the client will paginate requests until all records have been retrieved. You can supply a `limit` param to limit the number of results returned by `select`.

You can disable pagination with `pagination=False`, in which case the record limit will be capped by your API's [`max-rows`](https://postgrest.org/en/v8.0/configuration.html#max-rows) setting.

## Development

We use Github Actions to publish to PyPI. The workflows are defined in .github/workflows.

Any commit/merge to the dev branch will trigger a PyPI publication to the knackpy-dev package. Any *release* on the master branch will trigger publication to the knackpy package on PyPI. Note that PyPI publications will fail if don’t bump the version number in setup.py.


## License

As a work of the City of Austin, this project is in the public domain within the United States.
Expand Down
199 changes: 111 additions & 88 deletions pypgrest/pypgrest.py
@@ -1,109 +1,132 @@
"""
A Python client for interacting with PostgREST APIs.
"""
from copy import deepcopy
import json
import math

import requests


class Postgrest(object):
"""
Class to interact with PostgREST.
"""
def __init__(self, url, auth=None):
"""Class to interact with PostgREST"""

self.auth = auth
def __init__(self, url, token=None, headers=None):
self.token = token
self.url = url

self.headers = {
"Content-Type": "application/json",
"Prefer": "return=representation", # return entire record json in response
}

if self.auth:
self.headers["Authorization"] = f"Bearer {self.auth}"


def insert(self, data=None):
self.res = requests.post(self.url, headers=self.headers, json=data)
self.res.raise_for_status()
return self.res.json()


def update(self, params=None, data=None):
"""
This method is dangerous! It is possible to delete and modify records
en masse. Read the PostgREST docs.
"""
self.res = requests.patch(self.url, headers=self.headers, params=params, json=data)
self.default_headers = {"Content-Type": "application/json"}
if self.token:
self.default_headers["Authorization"] = f"Bearer {self.token}"
if headers:
self.default_headers.update(headers)

def _make_request(self, *, resource, method, headers, params=None, data=None):
self.res = None
url = f"{self.url}/{resource}"
req = requests.Request(
method,
url,
headers=headers,
params=params,
json=data,
)
prepped = req.prepare()
session = requests.Session()
self.res = session.send(prepped)
self.res.raise_for_status()
return self.res.json()


def upsert(self, data=None):
"""
This method is dangerous! It is possible to delete and modify records
en masse. Read the PostgREST docs.
"""
headers = deepcopy(self.headers)
headers["Prefer"] += ", resolution=merge-duplicates"
self.res = requests.post(self.url, headers=headers, json=data)
self.res.raise_for_status()
return self.res.json()


def delete(self, params=None):
"""
This method is dangerous! It is possible to delete and modify records
en masse. Read the PostgREST docs.
"""
try:
return self.res.json()
except json.JSONDecodeError:
return self.res.text

def _get_request_headers(self, headers):
"""Update the instance's default request headers with any provided by the user
when making a request"""
request_headers = deepcopy(self.default_headers)
if headers:
request_headers.update(headers)
return request_headers

def insert(self, resource, data=None, headers=None):
headers = self._get_request_headers(headers)
return self._make_request(
resource=resource, method="post", headers=headers, data=data
)

def update(self, resource, params=None, data=None, headers=None):
"""This method is dangerous! It is possible to delete and modify records
en masse. Read the PostgREST docs."""
headers = self._get_request_headers(headers)
return self._make_request(
resource=resource, method="patch", headers=headers, params=params, data=data
)

def upsert(self, resource, data=None, headers=None):
"""This method is dangerous! It is possible to delete and modify records
en masse. Read the PostgREST docs."""
headers = self._get_request_headers(headers)
if headers.get("Prefer"):
headers["Prefer"] += ", resolution=merge-duplicates"
else:
headers["Prefer"] = "resolution=merge-duplicates"
return self._make_request(
resource=resource, method="post", headers=headers, data=data
)

def delete(self, resource, params=None, headers=None):
"""This method is dangerous! It is possible to delete and modify records
en masse. Read the PostgREST docs."""
if not params:
raise Exception("You must supply parameters with delete requests. This is for your own protection.")

url = f"{self.url}"
self.res = requests.delete(self.url, params=params, headers=self.headers)
self.res.raise_for_status()
return self.res.json()


def select(self, params=None, increment=1000, pagination=True):
"""Select records from PostgREST DB. See documentation for horizontal
raise Exception(
"You must supply parameters with delete requests. This is for your own protection." # noqa E501
)
headers = self._get_request_headers(headers)
return self._make_request(
resource=resource, method="delete", headers=headers, params=params
)

def select(
self, *, resource, params=None, pagination=True, headers=None,
):
"""Fetch selected records from PostgREST. See documentation for horizontal
and vertical filtering at http://postgrest.org/.
Args:
params (string): PostgREST-compliant request parametrs.
increment (int, optional): The maximum number of records to
return request per request. This is applied as a "limit" to
each API request, until the user-specified limit is reached.
Note that the PosgrREST DB itself will likely have limiting
configured that cannot be exceeded. For example, our
instances have a limit of 10000 records per request.
pagination:
If the client make multipel requets, returning multiple pages of
results, buy using the `offest` param
resource (str): Required. The postgrest's endpoint's table or view name to
query.
headers (dict): Custom PostgREST headers which will be passed to the
request. Defaults to None.
order_by (str): Field name to use a sort field when querying records. This
must be provided when pagniation=True to ensure that the DB returns
consistent results across all pages/offsets.
pagination (bool): If the client should make repeated requests until etiher:
- the limit param (if present) is met
- if no limit param is included, until no more records are returned from
the API.
Defaults to True.
params (dict): PostgREST-compliant request parameters. Defaults to None.
Returns:
TYPE: List
List: A list of dicts of data returned from the host
"""
limit = params.setdefault('limit', 1000)

params['limit'] = increment
params = {} if not params else params
limit = params.get("limit", math.inf)
params.setdefault("offset", 0)

params.setdefault('offset', 0)
if pagination and not params.get("order"):
raise ValueError(
"It's unsafe to paginate requests without specifying an 'order' param"
)

records = []
headers = self._get_request_headers(headers)

while True:
self.res = requests.get(self.url, params=params, headers=self.headers)

self.res.raise_for_status()

records += self.res.json()

if not self.res.json() or len(records) >= limit or not pagination:
data = self._make_request(
resource=resource, method="get", headers=headers, params=params
)
records += data

if not data or len(records) >= limit or not pagination:
# Postgrest has a max-rows configuration setting which limits the total
# number of rows that can be returned from a request. when the the
# client specifies a limit higher than max-rows, the the max-rows # of
# rows are returned
return records
else:
params['offset'] += len(self.res.json())
params["offset"] += len(data)

0 comments on commit 5cd7203

Please sign in to comment.