pulp_smash.api

Location: Pulp SmashAPI Documentationpulp_smash.api

A client for working with Pulp’s API.

Working with an API can require repetitive calls to perform actions like check HTTP status codes. In addition, Pulp’s API has specific quirks surrounding its handling of href paths and HTTP 202 status codes. This module provides a customizable client that makes it easier to work with the API in a safe and concise manner.

class pulp_smash.api.Client(cfg, response_handler=None, request_kwargs=None, pulp_host=None)

Bases: object

A convenience object for working with an API.

This class is a wrapper around the requests.api module provided by Requests. Each of the functions from that module are exposed as methods here, and each of the arguments accepted by Requests’ functions are also accepted by these methods. The difference between this class and the Requests functions lies in its configurable request and response handling mechanisms.

This class is flexible enough that it should be usable with any API, but certain defaults have been set to work well with Pulp.

As an example of basic usage, let’s say that you’d like to create a user, then read that user’s information back from the server. This is one way to do it:

>>> from pulp_smash.api import Client
>>> from pulp_smash.config import get_config
>>> client = Client(get_config())
>>> response = client.post('/pulp/api/v2/users/', {'login': 'Alice'})
>>> response = client.get(response.json()['_href'])
>>> print(response.json())

Notice how we never call response.raise_for_status()? We don’t need to because, by default, Client instances do this. Handy!

How does this work? Each Client object has a callback function, response_handler, that is given a chance to munge each server response. How else might this callback be useful? Well, notice how we call json() on each server response? That’s kludgy. Let’s write our own callback that takes care of this for us:

>>> from pulp_smash.api import Client
>>> from pulp_smash.config import get_config
>>> def response_handler(client, response):
...     response.raise_for_status()
...     return response.json()
>>> client = Client(get_config(), response_handler=response_handler)
>>> response = client.post('/pulp/api/v2/users/', {'login': 'Alice'})
>>> response = client.get(response['_href'])
>>> print(response)

Pulp Smash ships with several response handlers. In order of increasing complexity, see:

As mentioned, this class has configurable request and response handling mechanisms. We’ve covered response handling mechanisms — let’s move on to request handling mechanisms.

When a client is instantiated, a pulp_smash.config.PulpSmashConfig must be passed to the constructor, and configuration options are copied from the PulpSmashConfig to the client. These options can be overridden on a per-object or per-request basis. Here’s an example:

>>> from pulp_smash.api import Client
>>> from pulp_smash.config import PulpSmashConfig
>>> cfg = config.PulpSmashConfig(
...     pulp_auth=('username', 'password'),
...     pulp_version='1!0',
...     pulp_selinux_enabled=True,
...     hosts=[
...         config.PulpHost(
...             hostname='example.com',
...             roles={'api': {
...                'scheme': 'https',
...                'verify': '~/Documents/my.crt',
...             }}
...         )
...     ]
... )
>>> client = api.Client(cfg)
>>> client.request_kwargs['url'] == 'https://example.com'
True
>>> client.request_kwargs['verify'] == '~/Documents/my.crt'
True
>>> response = client.get('/index.html')  # Use my.crt for SSL verification
>>> response = client.get('/index.html', verify=False)  # Disable SSL
>>> response = client.get('/index.html')  # Use my.crt for SSL verification
>>> client.request_kwargs['verify'] = None
>>> response = client.get('/index.html')  # Do default SSL verification

As shown above, an argument that’s passed to one of this class’ methods is passed to the corresponding Requests method. And an argument that’s set in requests_kwargs is passed to Requests during every call.

The url argument is special. When making an HTTP request with Requests, an absolute URL is required. But when making an HTTP request with one of this class’ methods, either an absolute or a relative URL may be passed. If a relative URL is passed, it’s joined to this class’ default URL like so:

>>> urljoin(self.request_kwargs['url'], passed_in_url)

This allows one to easily use the hrefs returned by Pulp in constructing new requests.

Parameters:
  • cfg (pulp_smash.config.PulpSmashConfig) – Information about a Pulp app.
  • response_handler – A callback function, invoked after each request is made. Must accept two arguments: a pulp_smash.config.PulpSmashConfig object, and a requests.Response object. Defaults to smart_handler().
  • request_kwargs – A dict of parameters to send with each request. This dict is merged into the default dict of parameters that’s sent with each request.
  • pulp_host (pulp_smash.config.PulpHost) – The host with which to communicate. Defaults to the first host that fulfills the “api” role.

Supplementary information on writing response handlers.

This class accepts a pulp_smash.config.PulpSmashConfig parameter. This object may be accessed via the _cfg attribute. This attribute should be used sparingly, as careless accesses can be an easy way to inadverdently create bugs. For example, if given the choice between calling self._cfg.get_request_kwargs() or referencing self.request_kwargs, reference the latter. To explain why, consider this scenario:

>>> from pulp_smash import api, config
>>> client = api.Client(config.get_config())
>>> client.request_kwargs['verify'] == '~/Documents/my.crt'
>>> client.get('https://example.com')

The API client has been told to use an SSL certificate for verification. Yet if the client uses self._cfg.get_requests_kwargs() when making an HTTP GET call, the SSL certificate won’t be used.

If this attribute is so problematic, why does it exist? It exists so that each API client may share context with its response handler. For example, a response handler might need to know which version of Pulp it is communicating with:

>>> def example_handler(client, response):
...     if client._cfg.pulp_version < Version('3'):
...         return pulp_2_procedure(response)
...     else:
...         return pulp_3_procedure(response)

However, this same logic could also be implemented by calling pulp_smash.config.get_config():

>>> def example_handler(client, response):
...     if config.get_config().pulp_version < Version('3'):
...         return pulp_2_procedure(response)
...     else:
...         return pulp_3_procedure(response)

Given this, why lug around a pulp_smash.config.PulpSmashConfig object? This is done because it is fundamentally correct for a response handler to learn about its calling API client’s state by accessing the calling API client, and it is fundamentally incorrect for a response handler to learn about its calling API client’s state by accessing a global cache. To illustrate, consider one possible failure scenario:

  1. No settings file exists at any of the default load paths, e.g. ~/.config/pulp_smash/settings.json.
  2. An API client is created by reading a non-default configuration file.
  3. The API client makes a request, and a response handler is invoked to handle the response.
  4. The response handler needs to learn which version of Pulp is being targeted.
    • If it invokes pulp_smash.config.get_config(), no configuration file will be found, and an exception will be raised.
    • If it accesses the calling API client, it will find what it needs.

Letting a response handler access its calling API client prevents incorrect behaviour in other scenarios too, such as when working with multi-threaded code.

Supplementary information on method signatures.

requests.post has the following signature:

requests.post(url, data=None, json=None, **kwargs)

However, post() has a different signature. Why? Pulp supports only JSON for most of its API endpoints, so it makes sense for us to demote data to being a regular kwarg and list json as the one and only positional argument.

We make json a positional argument for post(), put(), and patch(), but not the other methods. Why? Because HTTP OPTIONS, GET, HEAD and DELETE must not have bodies. This is stated by the HTTP/1.1 specification, and network intermediaries such as caches are at liberty to drop such bodies.

Why is a sentinel object used in several function signatures? Imagine the following scenario: a user provides a default JSON payload in self.request_kwargs, but they want to skip sending that payload for just one request. How can they do that? With client.post(url, json=None).

http://docs.python-requests.org/en/master/api/#requests.post

delete(url, **kwargs)

Send an HTTP DELETE request.

get(url, **kwargs)

Send an HTTP GET request.

head(url, **kwargs)

Send an HTTP HEAD request.

options(url, **kwargs)

Send an HTTP OPTIONS request.

patch(url, json=<object object>, **kwargs)

Send an HTTP PATCH request.

post(url, json=<object object>, **kwargs)

Send an HTTP POST request.

put(url, json=<object object>, **kwargs)

Send an HTTP PUT request.

request(method, url, **kwargs)

Send an HTTP request.

Arguments passed directly in to this method override (but do not overwrite!) arguments specified in self.request_kwargs.

using_handler(response_handler)

Return a copy this same client changing specific handler dependency.

This method clones and injects a new handler dependency in to the existing client instance and then returns it.

This method is offered just as a ‘syntax-sugar’ for:

from pulp_smash import api, config

def function(client):
    # This function needs to use a different handler
    other_client = api.Client(config.get_config(), other_handler)
    other_client.get(url)

with this method the above can be done in fewer lines:

def function(client):  # already receives a client here
    client.using_handler(other_handler).get(url)
pulp_smash.api.check_pulp3_restriction(client)

Check if running system is running on Pulp3 otherwise raise error.

pulp_smash.api.code_handler(client, response)

Check the response status code, and return the response.

Unlike safe_handler(), this method doesn’t wait for asynchronous tasks to complete if response has an HTTP 202 status code.

Raises:requests.exceptions.HTTPError if the response status code is in the 4XX or 5XX range.
pulp_smash.api.echo_handler(client, response)

Immediately return response.

pulp_smash.api.json_handler(client, response)

Like safe_handler, but also return a JSON-decoded response body.

Do what pulp_smash.api.safe_handler() does. In addition, decode the response body as JSON and return the result.

pulp_smash.api.page_handler(client, response)

Call json_handler(), optionally collect results, and return.

Do the following:

  1. If response has an HTTP No Content (204) status code, return response.
  2. Call json_handler().
  3. If the response appears to be paginated, walk through each page of results, and collect them into a single list. Otherwise, do nothing. Return either the list of results or the single decoded response.
Raises:ValueError if the target Pulp application under test is older than version 3 or at least version 4.
pulp_smash.api.poll_spawned_tasks(cfg, call_report, pulp_host=None)

Recursively wait for spawned tasks to complete. Yield response bodies.

Recursively wait for each of the spawned tasks listed in the given call report to complete. For each task that completes, yield a response body representing that task’s final state.

Parameters:
Returns:

A generator yielding task bodies.

Raises:

Same as poll_task().

pulp_smash.api.poll_task(cfg, href, pulp_host=None)

Wait for a task and its children to complete. Yield response bodies.

Poll the task at href, waiting for the task to complete. When a response is received indicating that the task is complete, yield that response body and recursively poll each child task.

Parameters:
  • cfg – A pulp_smash.config.PulpSmashConfig object.
  • href – The path to a task you’d like to monitor recursively.
  • pulp_host – The host to poll. If None, a host will automatically be selected by Client.
Returns:

An generator yielding response bodies.

Raises:

pulp_smash.exceptions.TaskTimedOutError – If a task takes too long to complete.

pulp_smash.api.safe_handler(client, response)

Check status code, wait for tasks to complete, and check tasks.

Inspect the response’s HTTP status code. If the response has an HTTP Accepted status code, inspect the returned call report, wait for each task to complete, and inspect each completed task.

Raises:

requests.exceptions.HTTPError if the response status code is in the 4XX or 5XX range.

Raises:
pulp_smash.api.smart_handler(client, response)

Decides which handler to call based on response content.

Do the following:

  1. Pass response through safe_handler to handle 202 and raise_for_status.
  2. Return the response if it is not Pulp 3.
  3. Return the response if it is not application/json type.
  4. Pass response through task_handler if is JSON 202 with ‘task’.
  5. Pass response through page_handler if is JSON but not 202 with ‘task’.
pulp_smash.api.task_handler(client, response)

Wait for tasks to complete and then collect resources.

Do the following:

  1. Call json_handler() to handle 202 and get call_report.
  2. Raise error if response is not a task.
  3. Re-read the task by its _href to get the final state and metadata.
  4. Return the task’s created or updated resource or task final state.
Raises:ValueError if the target Pulp application under test is older than version 3 or at least version 4.

Usage examples:

Create a distribution using meth:json_handler:

client = Client(cfg, api.json_handler)
spawned_task = client.post(DISTRIBUTION_PATH, body)
# json_handler returns the task call report not the created entity
spawned_task == {'task': ...}
# to have the distribution it is needed to get the task's resources

Create a distribution using meth:task_handler:

client = Client(cfg, api.task_handler)
distribution = client.post(DISTRIBUTION_PATH, body)
# task_handler resolves the created entity and returns its data
distribution == {'_href': ..., 'base_path': ...}

Having an existent client it is possible to use the shortcut:

client.using_handler(api.task_handler).post(DISTRIBUTION_PATH, body)