Skip to content

New architecture & Tests - #14

Merged
odinuv merged 27 commits into
keboola:devfrom
Ogaday:dev
Aug 27, 2017
Merged

New architecture & Tests#14
odinuv merged 27 commits into
keboola:devfrom
Ogaday:dev

Conversation

@Ogaday

@Ogaday Ogaday commented Aug 18, 2017

Copy link
Copy Markdown
Contributor

I've been working on this throughout the week and think it could probably do with more work & reviewing before merging: I'd definitely appreciate feedback.

This PR is mostly a proof of concept of the architecture laid out by @pocin in #6: I've tried to stay close to the API and so you could call it quite a thin client?

I've also had a play with some tests. I used the very nice responses library to mock requests, and that way we don't need real tokens or even an Internet connection to test the client. In the tests you define what the API response should be and assert that the client isn't making any mistakes before or after calling requests. I think it might still be useful to have some real API calls to make sure everything is working sensibly, but I don't think I have access to the test environment.

I also modified setup.py a bit. Essentially, previously upon install a package called tests was also being installed. Now, that should no longer be the case. If you want to run the tests, you should clone the directory and run them. There are several options for running tests:

After pip install responses, do

cd path/to/sapi-python-client/ && python -m unittest discover

or

python -m discover -s path/to/sapi-python-client/

or

pytest path/to/sapi-python-client/tests/

or

green path/to/sapi-python-client/tests/

etc.

Alternatively, without needing to even install the package:

git clone git@github.com:Ogaday/sapi-python-client.git && cd sapi-python-client && python setup.py test

Which takes care of all dependencies.

Ogaday added 6 commits August 18, 2017 16:37
Ideally tests will be discoverable as a package, and also won't be installed
via pip later down the line.

Now it should be possible to run `python setup.py test` without having to
install tests or tests requirements.
My tests need the module `responses` to run, as I use it to mock
`requests`. However, I changed things around so that the tests package
is not installed, and `responses` is not a dependency. It is however a
dependendency for testing in `setup.py`, so running
`python setup.py test` will install responses specifically for the tests.

My current solution is to make the Dockerfile install responses, and
hopefully that will work. I wonder if it is a bit of a hack though, and
if we should use `python setup.py test` as the testing mechanism.
I'm not sure!
@Ogaday
Ogaday changed the base branch from master to dev August 18, 2017 17:50
Comment thread .travis.yml Outdated
- docker-compose build sapi-python-client
script:
- docker-compose run --rm --entrypoint=flake8 sapi-python-client || true
- docker-compose run --rm -e KBC_TOKEN sapi-python-client -m unittest discover

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs to be KBC_TEST_TOKEN - my mistake, sorry about that

Comment thread Dockerfile

WORKDIR /code
COPY . /code/
RUN pip3 install --no-cache-dir flake8 responses

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is perfecly ok

Comment thread kbcstorage/base.py

Args
root (str): Root url of API. eg.
"https://connection.keboola.com/v2/storage/"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the client must allow overriding of https://connection.keboola.com/ but not v2/ or /v2/storage/ part. The base url may change (actually we do already have https://connection.eu-central-1.keboola.com, but v2 is tied to API (and client) version

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my mind the client constructs the string and passes it to the Endpoint. ie.

class Client:
    def __init__(self, base_url, token):
        api_version = 'v2/storage'
        root_url = base_url + '/' + api_version
        self.buckets = Buckets(root_url, token)
        ...

Does that work? Should API version also be a parameter? Or do you think each endpoint should construct the API string?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, that looks perffect, the api version should be hardcoded (there is no other version, not even in plans and when it will be who knows how it will look) and the endpoint should not repeat do the constructing

Comment thread kbcstorage/base.py Outdated
self.path = urljoin(root, extension)
self.token = token

def _get(self, params=[], extra_headers={}):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe params is the wrong variable name for that argument. From what I can see, there are two types of params that can be passed: parameters which form the query string, like the parameters for list-bucket-events, such as sinceId etc; and parameters which go in the path of the url, such as workspace_id. The params arg as it stands it stands is solely for the latter, and maybe should be renamed. Query string params can be passed as a dict to requests.get.

Writing this code, and trying to made the Endpoint._get and Endpoint._post methods as versatile as possible it seems that we have to essentially replicate the requests.get and requests.post functionality which is redundant. I've been thinking about suggesting some changes in #6. the Endpoint class is still useful, but perhaps each endpoint subclass should be constructing its own requests, as they are so straightforward and the Endpoint superclass should instead implement response handling and header generation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the Endpoints _get() _post,... methods merely as a very thin wrappers for the requests native http methods, which only appends the X-StorageApi-token: TOKEN header to the request. I think it makes sense to delegate the url construction to the endpoints. And if some repetitive code appears, we can always refactor it into the base Endpoint class.

fwiw it's probably not even necessary to hide them with the leading _underscore but make them "public" i.e Endpoint._get(*args, **kwargs) --> Endpoint.get(*args, **kwargs).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think then that the base Endpoint get and post methods don't add any value. I do still think that the base endpoint class is still useful for a few things and I have half implemented some ideas, so hopefully I'll be able to share them in a few days.

Comment thread kbcstorage/base.py Outdated

return r.json()

def _post(self, body={}, params=[], extra_headers={}):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

come to think of it - wondering how to pass array parameters (whereValues) - http://docs.keboola.apiary.io/#reference/tables/unload-data-asynchronously/asynchronous-export
as it is now it would have to be - not sure how requests library handles those

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requests can handle array parameters, but it encodes the [] into %5B%5D. That should not be an issue for the storage api, right?

try this:

>>> import requests
>>> r =requests.get('https://httpbin.org/', params={'whereValues[]':['12','34','56']})
>>> r.url
'https://httpbin.org/?whereValues%5B%5D=12&whereValues%5B%5D=34&whereValues%5B%5D=56'
>>>

So really all we need to do is to expose the native requests functionality in the Endpoints get method and we should be good to go

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That will be ok for GET requests, but for array values in the form-data body I'm not sure. I see this in the docs
image
But it's something we'll have to try out.
Currently it constructs this manually which is obviously not ideal: https://github.com/keboola/sapi-python-client/blob/master/kbcstorage/client.py#L124

@Ogaday Ogaday Aug 21, 2017

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pivnicek I'm not sure I understand, sorry. When I try that code snippet I get this, which is what I would expect. Is this not what we want?

>>> import requests
>>> payload = (('key1', 'value1'), ('key2', 'value2'))
>>> r = requests.post('http://httpbin.org/post', data=payload)
>>> print(r.text)
{
  "args": {}, 
  "data": "", 
  "files": {}, 
  "form": {
    "key1": "value1", 
    "key2": "value2"
  }, ...

And @pocin that's what I found as well, arrays params for urls should be fine.

@pivnicek pivnicek Aug 21, 2017

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ogaday there may be an issue may be with the whereValues in the request body http://docs.keboola.apiary.io/reference/tables/unload-data-asynchronously/asynchronous-export.
The backend expects it to be an array which is why it's constructed by hand that way currently. Maybe the requests library formulation (below) will work, but maybe not, we'll see :-)

{
   "whereValues": [
      "value1",
      "value2
   ]
}

Comment thread kbcstorage/workspaces.py
Workspaces Endpoint
"""
def __init__(self, url, token):
"""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm playing with the idea that it might be better to pass the endpoint class to workspace class as a ctor parameter - it's just that this way one has to pass the token all around the code and it's bound to leak somewhere...

@Ogaday Ogaday Aug 19, 2017

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what that would look like.

How about passing the client to the endpoint as an alternative...

class Endpoint:
    def __init__(self, client):
        self.client = client
    def _get(self, url, *args, **kwargs):
        headers = {'X-SorageAPI-Token': self.client.token}
        ...    # etc

class Client:
    def __init__(self, root_url, token):
        self.token = token
        self.endpoint = Endpoint(self)
        ...    # ?

Or is that no better?

Maybe this can be taken up in #6

@odinuv odinuv Aug 19, 2017

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, I was probably thinking more like:

cl = new Client('my-token', 'https://connection') # <- only one place in code where token is passed to the client
workspace = new Workspace(cl)
workspace.reset_password()
configuration = new Configuration(cl)
configuration.get('my-config')

@pocin pocin Aug 20, 2017

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@odinuv so instead of passing the token to each endpoint you'd pass the client class, I don't see any benefit in that.

.. it's just that this way one has to pass the token all around the code ...

Not really, you would only pass the token in the Client.__init__() method, which would internally distribute it to the endpoints.

i.e

from urllib.parse import urljoin

class StorageClient:
    def __init__(self, token):
        self.base_url = 'https://connection.keboola.com/v2/storage/'
        workspaces_url = self.base_url
        buckets_url = urljoin(self.base_url, 'buckets')

        self.workspaces = Workspaces(workspaces_url, token)
        self.buckets = Buckets(buckets_url, token)

and use like this

>>> client = StorageClient('token123')
>>> client.workspaces.list()
{
    "id": 234,
    "name": "boring_wozniak",
    "component": "wr-db",
    "configurationId": "aws-1"
   # ... omitted for clarity
}
>>> client.buckets.list()
{ response listing all buckets }

I think this has several benefits, the client class is essentially your gateway to the world of the keboola storage api:

  • you don't pass anything anywhere (tokens, clients,...)
  • you don't have to import and instantiate the endpoints individually
  • All endpoints are in one place, convinient and ready to use

Now when I am thinking about it, what would the Client class do in your example? To me it looks just like overly complicated conatiner for the base_url and token

class Client:
    def __init__(self, base_url, token):
        self.base_url = base_url
        self.token = token

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @pocin - The token is only passed internally by the __init__ method of the client. Ultimately, the Endpoint instance needs to make a request with the token, so it needs access to it somehow.

I think having the capability to do client.endpoint1.list() and client.endpoint2.list() is very elegant :)

Comment thread kbcstorage/workspaces.py
Args:
mapping(:obj:`dict`): Keys contain the full names of the tables to
be loaded (ie. 'in.c-bucker.table_name') and values contain the
aliases to which they will be loaded (ie. 'table_name').

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

table mapping has other parameters like days, rows, etc. while it is not important to support them now, the structure has to be extensible, i.e. i'd say it either has to be array of dicts (each wit keys source, destination, etc) or a designated object

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I definitely want to support those additional parameters because it's a use case I require personally but I don't think this is fully documented. I have link from keboola support which is helpful of course, but would be good to have a full spec.

@pivnicek pivnicek Aug 21, 2017

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ogaday this is the API doc for the workspace load command http://docs.keboola.apiary.io/#reference/workspaces/manage-workspace/load-data. It could probably use a full example though. Is there a particular part that is missing for you?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ogaday we already tracking that as "documentation issue"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ujovlado Thanks! I recognize your name from my support thread. I don't doubt that you are handling this :)

Comment thread kbcstorage/workspaces.py Outdated
mapping(:obj:`dict`): Keys contain the full names of the tables to
be loaded (ie. 'in.c-bucker.table_name') and values contain the
aliases to which they will be loaded (ie. 'table_name').
preserve(bool): If True, does not clear the workspace of existing

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is not used here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch

@odinuv

odinuv commented Aug 18, 2017

Copy link
Copy Markdown
Member

@Ogaday I think you did a Great Job! I left a bunch of notes, i hope it does not put you off. What if feel is missing:

@odinuv

odinuv commented Aug 18, 2017

Copy link
Copy Markdown
Member

Also with the mocks - they are good, but i wouldn't spent too much time on them. The client is and will be thin and it is most easily and reliably tested against the actual API, we are used to this. It's probably no problem for you to have a testing project for this.

@Ogaday

Ogaday commented Aug 19, 2017

Copy link
Copy Markdown
Contributor Author

Thanks for the feedback @odinuv, it doesn't put me off at all :) I'll respond to the other comments in line above.

Using mocks makes my life easier for now, happy to add real API call tests later too.

move the stuff from current client to use the endpoint class ... handling errors ... some validation of parameters

Good points, I'll look into these. I agree handling errors can wait a bit, as HTTP errors currently fail pretty fast!

RE travis failures: I don't know enough about docker or CI to guess why the build process always fails whenever I commit anything. I don't suppose you know why this is either?

i'm going on hols for a week, so i won't be much help

Not a problem, I'm taking a long weekend on Wednesday too

@odinuv

odinuv commented Aug 19, 2017

Copy link
Copy Markdown
Member

You should be able to see the errors in https://travis-ci.org/keboola/sapi-python-client/builds/266261167?utm_source=github_status&utm_medium=notification - it's something wrong with the token, i'll check it

@odinuv

odinuv commented Aug 19, 2017

Copy link
Copy Markdown
Member

Ok, i learned something new:

Travis CI makes encrypted variables and data available only to pull requests coming from the same repository. These are considered trustworthy, as only members with write access to the repository can send them.
Pull requests sent from forked repositories do not have access to encrypted variables or data

So the tests against real API won't pass in Ogaday:dev, but will work in keboola:dev

@Ogaday

Ogaday commented Aug 22, 2017

Copy link
Copy Markdown
Contributor Author

I found a crappy bug in my code and I've got some commits in the works so hopefully I'll make some pushes today

Ogaday and others added 15 commits August 22, 2017 10:01
Fix bug caused by mutable keyword arguments
Make the wrapper around the requests library thinner by delegating
argument construction to the childclass. This way the base Endpoint
class only forwards arguments to requests and then handles the
response. This also fixes the arguments for the base Endpoint
requests - no more mutable defaults!
Ogaday added 2 commits August 22, 2017 16:19
Essentially, everything is being taken out of the client class. It
seems a lot of the functionality of the preexisting client was split
between different endpoints, so I have just started with buckets.
The tables endpoint looks a lot more complex!
@Ogaday

Ogaday commented Aug 22, 2017

Copy link
Copy Markdown
Contributor Author

I'm a lot happier with the code now. There is probably some repetition and doubtless some mistakes have slipped through, but I think we can refactor that / optimise it later.

I did remove some functionality from the client class, for now, but I will focus on restoring it. Also as a priority / items to keep track of are:

  • Argument validation as mentioned by @odinuv above.
  • Increase endpoint implementation. There are a lot of endpoints, some are simple, some are more complicated.
  • Improve parameters passed to the load_tables method of Workspaces.
  • Add error handling to the base Endpoint request methods.
  • Increase & improve test coverage (as always).

I also tried to bring the existing bucket tests in line with the "new" architecture, but I don't know if they'll work on Travis or not.

Comment thread kbcstorage/base.py Outdated
except requests.HTTPError:
# Handle different error codes
raise
finally:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a fairly hidden gotcha. Instead of the finally write else. Otherwise the raised exception will be silenced by the finally.

This should work as expected

        try:
            r.raise_for_status()
        except requests.HTTPError:
            # Handle different error codes
            raise
        else:
            return r.json()

try this in your interpreter to see what I mean

class Resp:
    foo = 'bar'

def test(r):
    try:
        print(1/0)
    except:
        raise
    finally:
        return r.foo

r = Resp()

print(test(r)) #will print 'bar', but you would expect ZeroDivisionError

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that is nasty! I wrote a test for it and hopefully fixed it.

Comment thread kbcstorage/base.py Outdated
except requests.HTTPError:
# Handle different error codes
raise
finally:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above, just marking it

Ogaday added 3 commits August 23, 2017 11:05
This tests the `try: ... except: raise finally: ...` gotcha caught
by @pocin.
@odinuv

odinuv commented Aug 27, 2017

Copy link
Copy Markdown
Member

I'm merging this so that I can make changes to it, I also made issues of the checklist (those I can imagine being 'delivered')

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants