Skip to content

Commit

Permalink
Merge pull request #33 from uber/ca/add_report_feature
Browse files Browse the repository at this point in the history
Add ability to report on unused cassette
  • Loading branch information
charlax committed Mar 12, 2015
2 parents aacf62e + 9af96b6 commit 2c9a8f6
Show file tree
Hide file tree
Showing 18 changed files with 175 additions and 102 deletions.
6 changes: 6 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
Changelog for Cassette
======================

0.3.7 (unreleased)
------------------

- Add ability to report on unused cassette.


0.3.6 (2014-10-31)
------------------

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ develop:
python setup.py develop

test: clean develop lint
nosetests
py.test

lint:
flake8 --ignore=E501,E702 .
Expand Down
22 changes: 7 additions & 15 deletions cassette/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
import contextlib
import logging

from cassette.cassette_library import CassetteLibrary
from cassette.patcher import patch, unpatch
from cassette.player import Player

cassette_library = None
player = None
logging.getLogger("cassette").addHandler(logging.NullHandler())


Expand All @@ -14,22 +13,15 @@ def insert(filename, file_format=''):
:param filename: path to where requests and responses will be stored.
"""
global cassette_library
global player

cassette_library = CassetteLibrary.create_new_cassette_library(
filename, file_format)
patch(cassette_library)
player = Player(filename, file_format)
player.__enter__()


def eject():
def eject(exc_type=None, exc_value=None, tb=None):
"""Remove cassette, unpatching HTTP requests."""

# If the cassette items have changed, save the changes to file
if cassette_library.is_dirty:
cassette_library.write_to_file()

# Remove our overrides
unpatch()
player.__exit__(exc_type, exc_value, tb)


@contextlib.contextmanager
Expand Down
143 changes: 69 additions & 74 deletions cassette/cassette_library.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from __future__ import absolute_import

import hashlib
import logging
import os
import sys
from urlparse import urlparse

from cassette.config import Config
from cassette.http_response import MockedHTTPResponse
from cassette.utils import Encoder, DEFAULT_ENCODER
from cassette.utils import Encoder

log = logging.getLogger("cassette")

Expand Down Expand Up @@ -47,17 +48,20 @@ def from_httplib_connection(cls, host, port, method, url, body,
# instantiated to 0.0.0.0 so we can parse
parsed_url = urlparse('http://0.0.0.0' + url)
url = parsed_url.path
query = hashlib.md5(parsed_url.query + '#' + parsed_url.fragment).hexdigest()
query = hashlib.md5(parsed_url.query + '#' +
parsed_url.fragment).hexdigest()
else:
# requests/urllib3 defaults to '/' while urllib2 is ''. So this
# value should be '/' to ensure compatability.
url = '/'
query = ''
name = "httplib:{method} {host}:{port}{url} {query} {headers} {body}".format(**locals())
name = ("httplib:{method} {host}:{port}{url} {query} "
"{headers} {body}").format(**locals())
else:
# note that old yaml files will not contain the correct matching
# query and body
name = "httplib:{method} {host}:{port}{url} {headers} {body}".format(**locals())
name = ("httplib:{method} {host}:{port}{url} "
"{headers} {body}").format(**locals())

name = name.strip()
return name
Expand All @@ -77,12 +81,17 @@ class CassetteLibrary(object):

cache = {}

def __init__(self, filename, encoder):
def __init__(self, filename, encoder, config=None):
self.filename = os.path.abspath(filename)
self.is_dirty = False
self.used = set()
self.config = config or self.get_default_config()

self.encoder = encoder

def get_default_config(self):
return Config()

def add_response(self, cassette_name, response):
"""Add a new response to the mocked response.
Expand Down Expand Up @@ -132,9 +141,22 @@ def _log_contains(self, cassette_name, contains):
"""Logging for checking access to cassettes."""
if contains:
self._had_response() # For testing purposes
log.info("Library has '{n}'".format(n=cassette_name))
log.info('Library has %s', cassette_name)
else:
log.warning("Library does not have '{n}'".format(n=cassette_name))
log.info('Library does not have %s', cassette_name)

def log_cassette_used(self, path):
"""Log that a path was used."""
if self.config['log_cassette_used']:
self.used.add(path)

def report_unused_cassettes(self, output=sys.stdout):
"""Report unused path to a file."""
if not self.config['log_cassette_used']:
raise ValueError('Need to activate log_cassette_used first.')
available = set(self.get_all_available())
unused = available.difference(self.used)
output.write('\n'.join(unused))

# Methods that need to be implemented by subclasses
def write_to_file(self):
Expand All @@ -147,87 +169,58 @@ def __contains__(self, cassette_name):
def __getitem__(self, cassette_name):
raise NotImplementedError('CassetteLibrary not implemented.')

# Static methods
@staticmethod
def create_new_cassette_library(filename, file_format):
@classmethod
def create_new_cassette_library(cls, path, file_format, config=None):
"""Return an instantiated CassetteLibrary.
Use this method to create new a CassetteLibrary. It will automatically
determine if it should use a file or directory to back the cassette
based on the filename. The method assumes that all file names with an
extension (e.g. /file.json) are files, and all file names without
extensions are directories (e.g. /testdir).
Use this method to create new a CassetteLibrary. It will
automatically determine if it should use a file or directory to
back the cassette based on the filename. The method assumes that
all file names with an extension (e.g. ``/file.json``) are files,
and all file names without extensions are directories (e.g.
``/requests``).
:param str filename: filename of file or directory for storing requests
:param str path: filename of file or directory for storing requests
:param str file_format: the file_format to use for storing requests
:param dict config: configuration
"""
if not Encoder.is_supported_format(file_format):
raise KeyError("'{e}' is not a supported file_format.\
".format(e=file_format))
raise KeyError('%r is not a supported file_format.' % file_format)

_, extension = os.path.splitext(filename)

# Check if file has extension
if extension:
# If it has an extension, we assume filename is a file
return CassetteLibrary._process_file(filename, file_format, extension)
_, extension = os.path.splitext(path)
if file_format:
encoder = Encoder.get_encoder_from_file_format(file_format)
else:
# Otherwise, we assume filename is a directory
return CassetteLibrary._process_directory(filename, file_format)

@staticmethod
def _process_file(filename, file_format, extension):
"""Return an instantiated FileCassetteLibrary with the correct encoder.
:param str file_format:
:param str extension:
"""
if os.path.isdir(filename):
raise IOError("Expected a file, but found a directory at \
'{f}'".format(f=filename))

# Parse encoding type
if not file_format:
encoder = Encoder.get_encoder_from_extension(extension)
else:
encoder = Encoder.get_encoder_from_file_format(file_format)

return FileCassetteLibrary(filename, encoder)

@staticmethod
def _process_directory(filename, file_format):
"""Return an instantiated DirectoryCassetteLibrary with the correct
encoder.

:param str file_format:
"""
if os.path.isfile(filename):
raise IOError("Expected a directory, but found a file at \
'{f}'".format(f=filename))

if not file_format:
encoder = DEFAULT_ENCODER # default new directories to json
# Check if file has extension
if extension:
if os.path.isdir(path):
raise IOError('Expected a file, but found a directory at %s'
% path)
klass = FileCassetteLibrary
else:
encoder = Encoder.get_encoder_from_file_format(file_format)
if os.path.isfile(path):
raise IOError('Expected a directory, but found a file at %r' %
path)
klass = DirectoryCassetteLibrary

return DirectoryCassetteLibrary(filename, encoder)
return klass(path, encoder, config)


class FileCassetteLibrary(CassetteLibrary):
"""A CassetteLibrary that stores and manages requests with a single file."""
"""Store and manage requests with a single file."""

@property
def data(self):
"""Lazily loaded data."""

if not hasattr(self, "_data"):
self._data = self.load_file()

return self._data

def write_to_file(self):
"""Write mocked responses to file."""

# Serialize the items via YAML
data = {k: v.to_dict() for k, v in self.data.items()}
encoded_str = self.encoder.dump(data)
Expand All @@ -243,7 +236,6 @@ def write_to_file(self):

def load_file(self):
"""Load MockedResponses from YAML file."""

data = {}
filename = self.filename

Expand Down Expand Up @@ -291,6 +283,10 @@ def __getitem__(self, cassette_name):
req.rewind()
return req

def get_all_available(self):
"""Return all available cassette."""
return self.data.keys()


class DirectoryCassetteLibrary(CassetteLibrary):
"""A CassetteLibrary that stores and manages requests with directory."""
Expand Down Expand Up @@ -336,11 +332,9 @@ def __contains__(self, cassette_name):
will check if a file supporting the cassette name exists.
"""
contains = cassette_name in self.data

if not contains:
# Check file directory if it exists
filename = self.generate_path_from_cassette_name(cassette_name)

contains = os.path.exists(filename)

self._log_contains(cassette_name, contains)
Expand All @@ -356,8 +350,8 @@ def __getitem__(self, cassette_name):
req = self._load_request_from_file(cassette_name)

if not req:
raise KeyError("Cassette '{c}' does not exist in \
library.".format(c=cassette_name))
raise KeyError('Cassette %s does not exist in library.' %
cassette_name)

req.rewind()
return req
Expand All @@ -368,15 +362,12 @@ def _load_request_from_file(self, cassette_name):
If the cassette file is in the cache, then use it. Otherwise, read
from the disk to fetch the particular request.
"""

filename = self.generate_path_from_cassette_name(cassette_name)

try:
with open(filename) as f:
encoded_str = f.read()
except:
return None
with open(filename) as f:
encoded_str = f.read()

self.log_cassette_used(self.generate_filename(cassette_name))
encoded_hash = _hash(encoded_str)

# If the contents are cached, return them
Expand All @@ -401,3 +392,7 @@ def cassette_name_for_httplib_connection(self, host, port, method,
"""Create a cassette name from an httplib request."""
return CassetteName.from_httplib_connection(
host, port, method, url, body, headers, will_hash_body=True)

def get_all_available(self):
"""Return all available cassette."""
return os.listdir(self.filename)
5 changes: 5 additions & 0 deletions cassette/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class Config(dict):

def __init__(self):
# Defaults
self['log_cassette_used'] = False
29 changes: 29 additions & 0 deletions cassette/player.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import sys

from cassette.cassette_library import CassetteLibrary
from cassette.patcher import patch, unpatch


class Player(object):

def __init__(self, path, file_format='', config=None):
self.library = CassetteLibrary.create_new_cassette_library(
path, file_format, config)

def play(self):
"""Return contextenv."""
return self

def __enter__(self):
patch(self.library)

def __exit__(self, exc_type, exc_value, tb):
# If the cassette items have changed, save the changes to file
if self.library.is_dirty:
self.library.write_to_file()
# Remove our overrides
unpatch()

def report_unused_cassettes(self, output=sys.stdout):
"""Report unused cassettes to file."""
self.library.report_unused_cassettes(output)
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"status": 200, "raw_headers": ["Server: nginx\r\n", "Date: Thu, 12 Mar 2015 18:43:47 GMT\r\n", "Content-Type: application/json\r\n", "Content-Length: 205\r\n", "Connection: close\r\n", "Access-Control-Allow-Origin: *\r\n", "Access-Control-Allow-Credentials: true\r\n"], "content": "{\n \"args\": {}, \n \"headers\": {\n \"Accept-Encoding\": \"identity\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"Python-urllib/2.7\"\n }, \n \"origin\": \"8.26.157.128\", \n \"url\": \"http://httpbin.org/get\"\n}\n", "headers": {"content-length": "205", "server": "nginx", "connection": "close", "access-control-allow-credentials": "true", "date": "Thu, 12 Mar 2015 18:43:47 GMT", "access-control-allow-origin": "*", "content-type": "application/json"}, "reason": "OK", "version": 11, "length": 0}
Empty file.
18 changes: 18 additions & 0 deletions cassette/tests/use_cases/test_report_unused.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import urllib2
from cStringIO import StringIO

from cassette.player import Player


def test_report_unused():
config = {'log_cassette_used': True}
player = Player('./cassette/tests/data/requests/', config=config)
with player.play():
urllib2.urlopen('http://httpbin.org/get')

content = StringIO()
player.report_unused_cassettes(content)
content.seek(0)
expected = ('httplib_GET_httpbin.org_80__unused.json\n'
'httplib_GET_httpbin.org_80__unused2.json')
assert content.read() == expected
4 changes: 4 additions & 0 deletions cassette/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ def get_encoder_from_extension(extension):
:param str extension:
"""
if not extension:
# It's a dir.
return DEFAULT_ENCODER

file_format = extension.replace('.', '')
return Encoder.get_encoder_from_file_format(file_format)

Expand Down

0 comments on commit 2c9a8f6

Please sign in to comment.