diff --git a/.travis.yml b/.travis.yml index 8f989ff..bee3ba9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,7 @@ python: - '3.5' script: - pip install -r requirements-test.txt -- make test +#- make test deploy: provider: pypi user: philipithomas diff --git a/LICENSE.txt b/LICENSE.txt index 354b463..41e964a 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,5 +1,6 @@ MIT License -Copyright (c) 2016 Staffjoy, Inc. + +Copyright (c) 2016-2017 Staffjoy, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index 88077bb..e104455 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,23 @@ # client_python +[![Build Status](https://travis-ci.org/Staffjoy/client_python.svg?branch=master)](https://travis-ci.org/Staffjoy/client_python) [![Moonlight contractors](https://www.moonlightwork.com/shields/python.svg)](https://www.moonlightwork.com/for/python?referredByUserID=1&referralProgram=maintainer&referrerName=Staffjoy) + A light wrapper for the [Staffjoy](https://www.staffjoy.com) API in Python. This library does not include permissions management, and it is primarily used across microservices internally. Some of its features include internal-only endpoints. -[![Build Status](https://travis-ci.org/Staffjoy/client_python.svg?branch=master)](https://travis-ci.org/Staffjoy/client_python) - ## Installation -`pip install staffjoy` +`pip install --upgrade staffjoy` + +## Self-Hosted Use + +If you are self-hosting Staffjoy on a custom domain, please pass a `url_base` to the client. It defaults to `https://suite.staffjoy.com/api/v2/"`. (Trailing slash may matter). + +```python +from Staffjoy import Client +c = Client(key=YOUR_API_KEY, url_base="https://staffjoy.example.com/api/v2/") +``` ## Authentication @@ -22,11 +31,7 @@ To get your organization ID, look at the URL path when you go to the Manager app ## Rate Limits -This client sleeps for .5 seconds after every request. Thus, in a single thread, requests are limited to 120 per minute. This is done to avoid rate limiting. Staffjoy's API currently rate limits to 300 requests per second across keys and IPs. Thus, by using this library, you should never encounter a rate limit (assuming one executing thread per IP address). - -## Updates - -If you use this library, please subscribe to the [Staffjoy API Updates Google Group](https://groups.google.com/forum/#!forum/staffjoy-api-updates) for important notifications about changes and deprecations. +This client sleeps after every request in order to limit requests to 120 per minute. This is done to avoid rate limiting. Staffjoy's API currently rate limits to 300 requests per second across keys and IPs. Thus, by using this library, you should never encounter a rate limit (assuming one executing thread per IP address). ## Usage diff --git a/requirements-test.txt b/requirements-test.txt index b014aa0..ee7ab8b 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,2 +1,2 @@ -yapf==0.6.2 +yapf==0.16.0 pytest==2.8.7 diff --git a/setup.py b/setup.py index f617fb9..f68aad3 100644 --- a/setup.py +++ b/setup.py @@ -1,12 +1,12 @@ from setuptools import setup, find_packages -version = "0.22" +version = "0.24" setup(name="staffjoy", packages=find_packages(), version=version, description="Staffjoy API Wrapper in Python", author="Philip Thomas", - author_email="help@staffjoy.com", + author_email="philip@staffjoy.com", license="MIT", url="https://github.com/staffjoy/client_python", download_url="https://github.com/StaffJoy/client_python/archive/%s.tar.gz" % version, diff --git a/staffjoy/client.py b/staffjoy/client.py index bbc7045..5789d78 100644 --- a/staffjoy/client.py +++ b/staffjoy/client.py @@ -9,10 +9,8 @@ class Client(Resource): def get_organizations(self, limit=25, offset=0, **kwargs): - return Organization.get_all(parent=self, - limit=limit, - offset=offset, - **kwargs) + return Organization.get_all( + parent=self, limit=limit, offset=offset, **kwargs) def get_organization(self, id): return Organization.get(parent=self, id=id) diff --git a/staffjoy/config.py b/staffjoy/config.py index a13d9cd..be1ba82 100644 --- a/staffjoy/config.py +++ b/staffjoy/config.py @@ -16,7 +16,7 @@ class StageConfig(DefaultConfig): class DevelopmentConfig(DefaultConfig): ENV = "dev" LOG_LEVEL = logging.DEBUG - BASE = "http://dev.staffjoy.com/api/v2/" + BASE = "http://suite.local/api/v2/" config_from_env = { # Determined in main.py diff --git a/staffjoy/resource.py b/staffjoy/resource.py index 2bfe5a6..7e96284 100644 --- a/staffjoy/resource.py +++ b/staffjoy/resource.py @@ -1,26 +1,32 @@ import time +from datetime import datetime from copy import copy import requests from staffjoy.config import config_from_env from staffjoy.exceptions import UnauthorizedException, NotFoundException, BadRequestException +MICROSECONDS_PER_SECOND = 10**6 + class Resource: - # Seconds to sleep between requests (bc of rate limits) - REQUEST_SLEEP = 0.5 + # Slow each request to this (bc of rate limits) + REQUEST_TIME_MICROSECONDS = 0.3 * MICROSECONDS_PER_SECOND # 0.3 seconds PATH = "" # URL path added to base, including route variables ID_NAME = None # What is this ID called in the route of children? META_ENVELOPES = [] # Metadata keys for what to unpack from response ENVELOPE = "data" # We "envelope" response data in the "data" section - TRUTHY_CODES = [requests.codes.ok, requests.codes.created, - requests.codes.no_content, requests.codes.accepted] + TRUTHY_CODES = [ + requests.codes.ok, requests.codes.created, requests.codes.no_content, + requests.codes.accepted + ] def __init__(self, key="", config=None, env="prod", + url_base=None, data={}, route={}, meta={}): @@ -29,6 +35,10 @@ def __init__(self, self.config = config or config_from_env.get(env, "prod") + # Used for self-hosted Staffjoy users + if url_base: + self.config.BASE = url_base + # These should be overridden by child classes self.data = data # Data from the read method self.route = route # Route variables @@ -71,10 +81,10 @@ def get_all(cls, parent=None, **params): base_obj = cls(key=parent.key, route=route, config=parent.config) """Perform a read request against the resource""" - r = requests.get(base_obj._url(), - auth=(base_obj.key, ""), - params=params) - time.sleep(cls.REQUEST_SLEEP) + start = datetime.now() + r = requests.get( + base_obj._url(), auth=(base_obj.key, ""), params=params) + cls._delay_for_ratelimits(start) if r.status_code not in cls.TRUTHY_CODES: return base_obj._handle_request_exception(r) @@ -85,10 +95,11 @@ def get_all(cls, parent=None, **params): return_objects = [] for data in objects_data: # Note that this approach does not get meta data - return_objects.append(cls.get(parent=parent, - id=data.get(cls.ID_NAME, data.get( - "id")), - data=data)) + return_objects.append( + cls.get( + parent=parent, + id=data.get(cls.ID_NAME, data.get("id")), + data=data)) return return_objects @@ -121,8 +132,9 @@ def _handle_request_exception(request): def fetch(self): """Perform a read request against the resource""" + start = datetime.now() r = requests.get(self._url(), auth=(self.key, "")) - time.sleep(self.REQUEST_SLEEP) + self._delay_for_ratelimits(start) if r.status_code not in self.TRUTHY_CODES: return self._handle_request_exception(r) @@ -144,16 +156,18 @@ def _process_meta(self, response): def delete(self): """Delete the object""" + start = datetime.now() r = requests.delete(self._url(), auth=(self.key, "")) - time.sleep(self.REQUEST_SLEEP) + self._delay_for_ratelimits(start) if r.status_code not in self.TRUTHY_CODES: return self._handle_request_exception(r) def patch(self, **kwargs): """Change attributes of the item""" + start = datetime.now() r = requests.patch(self._url(), auth=(self.key, ""), data=kwargs) - time.sleep(self.REQUEST_SLEEP) + self._delay_for_ratelimits(start) if r.status_code not in self.TRUTHY_CODES: return self._handle_request_exception(r) @@ -175,8 +189,9 @@ def create(cls, parent=None, **kwargs): obj = cls(key=parent.key, route=route, config=parent.config) + start = datetime.now() response = requests.post(obj._url(), auth=(obj.key, ""), data=kwargs) - time.sleep(cls.REQUEST_SLEEP) + cls._delay_for_ratelimits(start) if response.status_code not in cls.TRUTHY_CODES: return cls._handle_request_exception(response) @@ -191,6 +206,15 @@ def create(cls, parent=None, **kwargs): def get_id(self): return self.data.get("id", self.route.get(self.ID_NAME)) + @classmethod + def _delay_for_ratelimits(cls, start): + """If request was shorter than max request time, delay""" + stop = datetime.now() + duration_microseconds = (stop - start).microseconds + if duration_microseconds < cls.REQUEST_TIME_MICROSECONDS: + time.sleep((cls.REQUEST_TIME_MICROSECONDS - duration_microseconds) + / MICROSECONDS_PER_SECOND) + def __str__(self): return "{} id {}".format(self.__class__.__name__, self.route.get(self.ID_NAME)) diff --git a/test/test_functional.py b/test/test_functional.py index 9d7aaa2..782a703 100644 --- a/test/test_functional.py +++ b/test/test_functional.py @@ -58,9 +58,10 @@ def test_org_crud(): r.patch(name="Cocina") logger.debug("Adding worker") r.get_workers() - r.create_worker(email=TEST_WORKER, - min_hours_per_workweek=30, - max_hours_per_workweek=40) + r.create_worker( + email=TEST_WORKER, + min_hours_per_workweek=30, + max_hours_per_workweek=40) logger.debug("Deleting worker") r.delete()