Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / Python

A Python wrapper for the CodeProject API

3.00/5 (3 votes)
1 Jan 2016CPOL23 min read 21.1K   87  
This article describes the working and the usage of my CodeProject API wrapper, written in Python.

Introduction

In this article, I'll explain the workings of a Python wrapper I built for the CodeProject API[^]. I made it so you can interact with the CodeProject API easily from Python using a few method calls, without having to write the API interaction code yourself. I'll tell how to use the wrapper, and how it works under the hood. The wrapper can be used for both Python 2 and 3.

The CPApiWrapper class

The wrapper is put in a CPApiWrapper class, which contains all methods to get data from the API and the access token. The class has one member variable: access_token_data. This contains the access token and some related info (see next section). The constructor sets the initial value of this member to None.

Python
def __init__(self):
    self.access_token_data = None

Usage of the class and the constructor:

Python
from CPApiWrapper.cpapiwrapper import CPApiWrapper
# ^ assumes that the Python package is in a subdirectory called "CPApiWrapper"
wrapper = CPApiWrapper()

The import statements

It's worth taking a look at the import statements at the top of CPApiWrapper.py:

Python
import requests
from .exceptions import *
from .itemsummarylistviewmodel import ItemSummaryListViewModel
from .accesstokendata import AccessTokenData
from .mynotificationsviewmodel import MyNotificationsViewModel
from .myprofileviewmodel import MyProfileViewModel
from .myreputationviewmodel import MyReputationViewModel
from .forumdisplaymode import ForumDisplayMode
from .questionlistmode import QuestionListMode
try:
    from urlparse import urljoin  # Python 2
except ImportError:
    from urllib.parse import urljoin  # Python 3

There isn't much to say about the first import, it just imports the requests package which is a dependency of the wrapper. The imports after that one, start with a dot. This kind of import is a package-relative import: it imports something relative to the current package (the CPApiWrapper package in this case).

The last import imports the urljoin method. This method is located in two different packages for Python 2 and Python 3: it's in urlparse for Python 2, and in urllib.parse for Python 3. To support both versions, the import is wrapped in a try-except statement: if the Python 2 import fails, it will do the Python 3 import. Then, you can just call urljoin anywhere in the code without having to worry about versions.

Fetching the access token

You first have to fetch the access token before you can send any requests.

The method to get the access token is called get_access_token, and it requires two helper classes: AccessTokenData and AccessTokenNotFetchedException

The AccessTokenData class

This class exists to hold the access token data returned from the API. It has three class members:

  • access_token - the access token, stored in a string.
  • expires_in - tells when the token will expire.
  • token_type - the OAuth token type.

The get_access_token class will use this class to store the API response. The constructor has a data parameter. When creating an instance of AccessTokenData, you have to pass a dictionary as this parameter, containing the required fields. When this class is used from get_access_token, it will pass the parsed JSON response to the constructor.

Python
class AccessTokenData():
    def __init__(self, data):
        self.access_token = data["access_token"]
        self.expires_in = data["expires_in"]
        self.token_type = data["token_type"]

The AccessTokenNotFetchedException

An exception of this type gets thrown when get_access_token fails to fetch the API token. It derives from Exception. It has a parsed_json member variable, where get_access_token stores the parsed API response in case you catch the exception and want to know what the API responded. At the beginning of the constructor of AccessTokenNotFetchedException, it calls the __init__ method of its super class with message as parameter, to make sure that the passed message is actually set as the exception message.

Python
class AccessTokenNotFetchedException(Exception):
    def __init__(self, message, parsed_json):
        super(AccessTokenNotFetchedException, self).__init__(message)
        self.parsed_json = parsed_json

The get_access_token method

This is the method where the actual access token fetching happens. It stores an AccessTokenData instance in the access_token_data member when it's fetched.

To get an access token from the API, you have to pass your Client ID, Client Secret, and optionally CodeProject logon email and password. To get a Client ID and a Client Secret, you have to register your application at the CodeProject Web API Client Settings. Passing email and password is optional: the API only requires it when you want to access the "My API", which contains info about your reputation, articles, questions, etc.

The method will pass the provided Client ID, Client Secret (and optionally email and password) to the API in a POST request to https://api.codeproject.com/Token. The passed data to the API is grant_type=client_credentials&client_id=ID&client_secret=secret without email/password and grant_type=password&client_id=ID&client_secret=secret&username=email&password=password with email and password. We don't have to encode the data ourselves. We use the requests library which does that for us: we just have to pass a dictionary with the data to requests.post and requests will take care of the correct encoding. The API returns JSON containing the data we'll feed to the AccessTokenData class. If the JSON response has an "error" field, it throws an AccessTokenNotFethchedException.

Python
def get_access_token(self, client_id, client_secret,
                     email=None, password=None):
    if email is None or password is None:
        data = {"grant_type": "client_credentials",
                "client_id": client_id,
                "client_secret": client_secret}
    else:
        data = {"grant_type": "password",
                "client_id": client_id,
                "client_secret": client_secret,
                "username": email,
                "password": password}
    resp = requests.post("https://api.codeproject.com/Token", data).json()
    if "error" in resp:
        exc = AccessTokenNotFetchedException(
            "Could not fetch API Access Token. Error: " + resp["error"],
            resp
        )
        raise exc
    self.access_token_data = AccessTokenData(resp)

Sending an API request

The CPApiWrapper class has many methods to get specific API data with just one method call -- getting page 1 of the latest questions for example. All of those methods make use of a helper method that sends the actual API request for a given URL, api_request. It takes a url parameter and a params parameter, which defaults to None, and is used to pass query string parameters as a dictionary. The url parameter has to be a URL relative to https://api.codeproject.com/v1. api_request concatenates the URL together using the urljoin method, and we use the requests package to send a GET request with the provided parameters. If the API returns a JSON response with the "error" or "message" field, something went wrong and the method will throw an ApiRequestFailedException.

The ApiRequestFailedException class looks and works exactly the same as the AccessTokenNotFetchedException class, which is discussed above. It only has a different name.

Python
class ApiRequestFailedException(Exception):
    def __init__(self, message, parsed_json):
        super(ApiRequestFailedException, self).__init__(message)
        self.parsed_json = parsed_json

This is the api_request method:

Python
def api_request(self, url, params=None):
    if self.access_token_data is None:
        exc = AccessTokenNotFetchedException(
            "Fetch the API key before sending a request.",
            None
        )
        raise exc
    url = url.lstrip("/")
    url = urljoin("https://api.codeproject.com/v1/", url)
    headers = {'Pragma': 'No-cache',
               'Accept': 'application/json',
               'Authorization': '{0} {1}'.format(
                   self.access_token_data.token_type,
                   self.access_token_data.access_token
               )}
    if params is not None:
        passed_params = {k: v for (k, v) in params.items() if v is not None}
    else:
        passed_params = None
    resp = requests.get(url, headers=headers, params=passed_params)
    data = resp.json()
    if "message" in data:
        exc = ApiRequestFailedException(
            "API Request Failed. Message: " + data["message"],
            data
        )
        raise exc
    if "error" in data:
        exc = ApiRequestFailedException(
            "API Request Failed. Message: " + data["error"],
            data
        )
        raise exc
    return data

As you can see in the above code block, we remove leading slashes from the passed url parameter using lstrip. This has to be done because the wrapper would find it perfectly valid if the relative API url started with slashes, but if the leading slash is kept, urljoin would make the relative URL relative to the root, https://api.codeproject.com/, not relative to https://api.codeproject.com/v1/.

params doesn't directly get passed to requests.get: some of the values in this dict could be None, so these shouldn't be passed. We filter them out using dict comprehension. All key-value pairs whose value is not None are kept. .items() has a different return type in Python 2 and Python 3, but both work for dict comprehension.

View models

If you intend to use the wrapper, you probably won't need the api_request method as described above. There are many methods in the CPApiWrapper class which use the api_request method to send a request, and store the response in a view model.

This is a short overview of the view models:

  • NameIdPair - container of a name and an ID.
  • ItemSummaryListViewModel - container of a PaginationInfo object and a list of ItemSummarys. Returned by several API pages.
  • ItemSummary - contains data about an 'item', which can be a question, forum message, article, ...
  • PaginationInfo - contains information about the pagination: current page number, total pages, ...
  • MyNotificationsViewModel - container of a NotificationViewModel object.
  • NotificationViewModel - container of data about notifications.
  • MyProfileViewModel - contains data of your profile, such as reputation, display name, ...
  • MyReputationViewModel - contains your total reputation and how much reputation you have per category.
  • ReputationTypeViewModel - contains data about a specific reputation category: the name, the amount of points you have in it, ...

NameIdPair

This view model is very frequently used in the other view models. It has two members: name and id. Like all view models, NameIdPair has a constructor that takes a dictionary, which has to be a part of the parsed API response (or for some view models, the entire parsed response).

Python
def __init__(self, data):
    self.name = data['name']
    self.id = data['id']

Sometimes, a part of the API response is not just one NameIdPair, but multiple, in a list. This happens quite frequently, so NameIdPair provides a static method, data_to_pairs, which transforms a list of dicts from the parsed API response into a list of instances of the NameIdPair class. This is put in a separate method because, if it would need to be changed for some reason, it wouldn't have to be changed everywhere where this conversion happens, but just at one place: the method.

Python
@staticmethod
def data_to_pairs(data):
    return [NameIdPair(x) for x in data]

This method uses list comprehension to transform the list of dicts into a list of NameIdPairs: for each x in data, it takes NameIdPair(x).

ItemSummaryListViewModel

ItemSummaryListViewModel is returned by the API page for latest articles, questions, and messages. It has the following class members:

  • pagination - instance of PaginationInfo, as the name says it contains info about the pagination.
  • items - an array of ItemSummarys.

Like all view models, ItemSumamryListViewModel has a constructor that takes a dictionary, which has to be the parsed API response.

Python
from .paginationinfo import PaginationInfo
from .itemsummary import ItemSummary


class ItemSummaryListViewModel():
    def __init__(self, data):
        self.pagination = PaginationInfo(data['pagination'])
        self.items = ItemSummary.data_to_items(data['items'])

data['pagination'] is also a dictionary, so we pass it to the PaginationInfo constructor. data['items'] is a list of dicts. The data_to_items method of ItemSummary iterates over all items in this list and returns a list of ItemSummarys (see next section).

ItemSummary

This view model contains a summary of an 'item': an article/blog/tip, a question, an answer, or a message. It has the following members (Some of them are optional. If they are and if they do not exist in the API response, their value will be None):

  • id - the ID of the item.
  • title - the title of the item.
  • authors - a list of NameIdPairs for the author (and potential co-authors for articles).
  • summary - Optional. A summary of the item. In case of an article/blog/tip, it contains the description (which can actually be empty, so the value of this member can be None, but this won't happen a lot), otherwise (question/answer/message) it contains the first few lines.
  • content_type - Similar to doc_type (see below), but it returns Article also for questions and answers, because CodeProject's system uses Articles for Questions and Answers[^]
  • doc_type - a NameIdPair with the name and ID of the "document type", which can be any of these:
    Name/ID pairs for doc_type
    Note: these document types are case-insensitive.
    ID Name
    1 article
    2 technical blog
    3 tip/trick
    4 question
    5 answer
    6 forum message
    13 reference
  • categories - a list of NameIdPairs for the item's categories.
  • tags - a list of NameIdPairs for the item's tags.
  • license - Optional. A NameIdPair for the license of the item.
  • created_date - shows the UTC date of creation of the item
  • modified_date - shows the last modification UTC date of the item. If it hasn't been modified yet, it shows the creation date.
  • thread_editor - Optional. Contains a NameIdPair of the latest editor of the discussion's thread, if applicable.
  • thread_modification_date - Optional. Contains the latest modification date of the discussion's thread, if applicable.
  • rating - the rating of the item
  • votes - the amount of votes on the item
  • popularity - the popularity of the item. From the Top Ranked Articles page: "Popularity is a measure of how much interest an article generates once it's been read. It's calculated as Rating x Log<sub>10</sub>(# Votes), where Rating is the filtered rating. See the FAQ for details."
  • website_link - a link to the item on the website
  • api_link - Not implemented yet[^]
  • parent_id - the ID of the item's parent. 0 if it doesn't have a parent.
  • thread_id - the ID of the discussion's thread. If the item is not a forum message, this value is 0.
  • indent_level - the "indent level" of a forum message: 0 for the thread, 1 for a reply, 2 for a reply to a reply, etc. If the item is not a forum message, this value is 0.

This is the constructor of the ItemSummary class:

Python
def __init__(self, data):
    self.id = data['id']
    self.title = data['title']
    self.authors = NameIdPair.data_to_pairs(data['authors'])
    self.summary = data['summary']
    self.content_type = data['contentType']
    self.doc_type = NameIdPair(data['docType'])
    self.categories = NameIdPair.data_to_pairs(data['categories'])
    self.tags = NameIdPair.data_to_pairs(data['tags'])
    if 'license' in data and data['license'] is not None and \
            data['license']['name'] is not None:
        self.license = NameIdPair(data['license'])
    else:
        self.license = None
    self.created_date = data['createdDate']
    self.modified_date = data['modifiedDate']
    if 'threadEditor' in data and data['threadEditor'] is not None\
            and data['threadEditor']['name'] is not None:
        self.thread_editor = NameIdPair(data['threadEditor'])
    else:
        self.thread_editor = None
    if 'threadModifiedDate' in data:
        self.thread_modified_date = data['threadModifiedDate']
    else:
        self.thread_modified_date = None
    self.rating = data['rating']
    self.votes = data['votes']
    self.popularity = data['popularity']
    self.website_link = data['websiteLink']
    self.api_link = data['apiLink']
    self.parent_id = data['parentId']
    self.thread_id = data['threadId']
    self.indent_level = data['indentLevel']

The constructor fills all members with the response from the API and fills the following fields with None if they don't exist: license, thread_editor and thread_modified_date. Note that some of these fields might be in the API response, but still indicate that it doesn't exist. For example, license in the API response is {"id": 0, "name": null} if there is no license. My API wrapper still marks it as None.

The class also has a static data_to_items method, which is meant to transform a list of ItemSummarys from the API response into instances of the ItemSummary class. This method is used in the ItemSummaryListViewModel constructor. It works like the data_to_pairs method of NameIdPair.

C#
@staticmethod
def data_to_items(data):
    return [ItemSummary(x) for x in data]

PaginationInfo

PaginationInfo is used by ItemSummaryListViewModel and it indicates the pagination. It has the following members:

  • page - the current page
  • page_size - the size of the page
  • total_pages - the total amount of pages
  • total_items - the total amount of items

There is nothing special about the constructor, it just fills the members of the instance with data from the parsed API response.

Python
class PaginationInfo():
    def __init__(self, data):
        self.page = data['page']
        self.page_size = data['pageSize']
        self.total_pages = data['totalPages']
        self.total_items = data['totalItems']

MyNotificationsViewModel

A MyNotificationsViewModel is returned by the notifications page of the My API. It has one member: notifications, which is a list of NotificationViewModels.

Python
from .notificationviewmodel import NotificationViewModel


class MyNotificationsViewModel():
    def __init__(self, data):
        self.notifications = NotificationViewModel.data_to_notifications(
            data['notifications']
        )

NotificationViewModel.data_to_notifications is a method which turns a parsed JSON list of dicts to a list of NotificationViewModels.

NotificationViewModel

This view model stores information about a notification, which you get when for example someone answered your question, replied to your message, commented on your article...

NotificationViewModel has the following members:

  • id - the ID of the notification.
  • object_type_name, <code>object_id, topic - I am not entirely sure what these represent.[^]
  • subject - the subject of the notification.
  • notification_date - the date on which you got the notification.
  • unread - boolean indicating whether the notification is unread or not.
  • content - the content of the notification.
  • link - the URL to the notification.

The NotificationViewModel class has a static data_to_notifications method, as mentioned in the previous section. It works like data_to_pairs in NameIdPair, only the class differs.

Python
class NotificationViewModel():
    def __init__(self, data):
        self.id = data['id']
        self.object_type_name = data['objectTypeName']
        self.object_id = data['objectId']
        self.subject = data['subject']
        self.topic = data['topic']
        self.notification_date = data['notificationDate']
        self.unread = data['unRead']
        self.content = data['content']
        self.link = data['link']

    @staticmethod
    def data_to_notifications(data):
        return [NotificationViewModel(x) for x in data]

MyProfileViewModel

This view model contains all information about your profile, both public and private. Note that you don't have to worry that somebody could use the API to steal your private data: the My API is only accessible if you're logged in, and you can only use it to fetch your information, not someone else's. If you didn't fill in an optional value on the site, the API will return an empty string for this item. The view model has the following members:

  • id - contains the user ID. Note: this is not the same as codeproject_member_id (see below). id is a value that gets used for identification across sites[^].
  • user_name - your user name. The one used in http://www.codeproject.com/Members/User-Name
  • display_name - your display name, which is shown at the bottom of your QA posts, next to your messages, ...
  • avatar - URL to your profile picture.
  • email - your email address.
  • html_emails - boolean value indicating whether you accept HTML emails.
  • country - your country.
  • home_page - the URL to your home page.
  • codeproject_member_id - your member ID on CodeProject, as you can find in http://www.codeproject.com/script/Membership/View.aspx?mid=<member ID>
  • member_profile_page_url - URL to your profile page.
  • twitter_name - your name on Twitter.
  • google_plus_profile - ID of your Google+ profile.
  • linkedin_profile - ID of your LinkedIn profile.
  • biography - the biography you provided.
  • company - the company you work for.
  • job_title - your job title.

The constructor of MyProfileViewModel just looks like the constructor of any other view model: it fills the above members with data from a parsed JSON dictionary.

Python
class MyProfileViewModel():
    def __init__(self, data):
        self.id = data['id']
        self.user_name = data['userName']
        self.display_name = data['displayName']
        self.avatar = data['avatar']
        self.email = data['email']
        self.html_emails = data['htmlEmails']
        self.country = data['country']
        self.home_page = data['homePage']
        self.codeproject_member_id = data['codeProjectMemberId']
        self.member_profile_page_url = data['memberProfilePageUrl']
        self.twitter_name = data['twitterName']
        self.google_plus_profile = data['googlePlusProfile']
        self.linkedin_profile = data['linkedInProfile']
        self.biography = data['biography']
        self.company = data['company']
        self.job_title = data['jobTitle']

MyReputationViewModel

This view model contains information about your reputation on CodeProject. It has the following members:

  • total_points - your total amount of reputation points.
  • reputation_types - a list of ReputationTypeViewModels (see next section) to split the total reputation points in different parts.
  • graph_url - the URL to your reputation graph.
Python
from .reputationtypeviewmodel import ReputationTypeViewModel


class MyReputationViewModel():
    def __init__(self, data):
        self.total_points = data['totalPoints']
        self.reputation_types = ReputationTypeViewModel.data_to_types(
            data['reputationTypes']
        )
        self.graph_url = data['graphUrl']

ReputationTypeViewModel

This view model contains the amount of reputation points for one reputation type. It's used in MyReputationViewModel. It has the following members:

  • name - the name of the reputation type.
  • points - the amont of points you have for this reputation type.
  • designation - the name of the reputation level that accompanies the name with the level (see below). For example, Legend if you're a platinum Author.
  • level - your designation for the reputation type, for example silver.

ReputationTypeViewModel also has a static data_to_types method to transform a parsed JSON list of dicts into an list of ReputationTypeViewModels. It works exactly like data_to_pairs of NameIdPair.

Python
class ReputationTypeViewModel():
    def __init__(self, data):
        self.name = data['name']
        self.points = data['points']
        self.level = data['level']
        self.designation = data['designation']

    @staticmethod
    def data_to_types(data):
        return [ReputationTypeViewModel(x) for x in data]

Class methods to fetch data from API and store it in a view model

We've seen all the view models now. But what are they used for? In the CPApiWrapper class, there are many methods which use the api_request method and store its response in a view model. Those methods are there to make your life easier because you don't have to use the api_request method yourself.

All of those methods are instance methods. You can call them after you created a wrapper instance and fetched the access token. Example:

Python
from CPApiWrapper.cpapiwrapper import CPApiWrapper
# ^ assumes that the Python package is in a subdirectory called "CPApiWrapper"
wrapper = CPApiWrapper()
wrapper.get_access_token("Client ID here", "Client Secret here")  # optionally email and password
latest_articles = wrapper.get_articles()

Latest articles: get_articles

This method uses the Articles API to get a list of the most recent articles. It returns an ItemSummaryListViewModel and has the following optional parameters:

  • tags - expects a comma-separated list of tags. Only articles with these tags will get returned. The default value is None (i.e. all tags).
  • min_rating - the minimum rating of the articles that get returned. The default value is 3.0.
  • page - The page to display. The default is 1.
Python
def get_articles(self, tags=None, min_rating=3.0, page=1):
    data = self.api_request("/Articles", {"tags": tags,
                                          "minRating": min_rating,
                                          "page": page})
    return ItemSummaryListViewModel(data)

Getting forum messages: get_messages_from_forum

The get_messages_from_forum method calls /Forum/{forum ID}/{mode} to get the latest messages in a forum. The method has the following parameters:

  • forum_id - the ID of the forum you want to get messages from.
  • mode - the display mode of the forum, either messages or threads. See more information below. The default is threads.
  • page - the page to display. The default is 1.

There are two valid values for mode: "Messages" and "Threads". You can put any of these string literals as parameter to the get_messages_from_forum method, but you can also use the members of the ForumDisplayMode class. This class has two members: members and threads. I recommend to use the members of this class: if the valid values for mode ever change, they only have to be changed at one place.

Python
def get_messages_from_forum(self, forum_id, mode=ForumDisplayMode.threads,
                            page=1):
    data = self.api_request("/Forum/{0}/{1}".format(forum_id, mode),
                            {"page": page})
    return ItemSummaryListViewModel(data)

Getting messages in a thread: get_messages_from_thread

You can also get the forum messages in a specific thread, using the get_messages_from_thread method. It calls /MessageThread/{id}/messages to do this, and it stores the response in an ItemSummaryViewModel. The method has the following parameters:

  • thread_id - the ID of the message thread
  • page - the page to display. The default is 1.
Python
def get_messages_from_thread(self, thread_id, page=1):
    data = self.api_request(
        "/MessageThread/{0}/messages".format(thread_id),
        {"page": page}
    )
    return ItemSummaryListViewModel(data)

Latest questions: get_questions

The get_questions method takes the latest active/new/unanswered questions and returns an ItemSummaryViewModel. It takes the following parameters:

  • mode - specifies whether you want the newest questions, the latest active questions, or the newest unanswered questions. The default is QuestionListMode.active.
  • include - comma-separated tags that the questions must include. The default is None (i.e. all tags)
  • ignore - comma-separated tags that the questions must not have. The default is None (i.e. no ignored tags)
  • page - the page to display. The default is 1.

QuestionListMode is a class like ForumDisplayMode. It has three members: active, new and unanswered.

Python
def get_questions(self, mode=QuestionListMode.active, include=None,
                  ignore=None, page=1):
    data = self.api_request("/Questions/{0}".format(mode),
                            {"include": include,
                             "ignore": ignore,
                             "page": page})
    return ItemSummaryListViewModel(data)

The My API

The methods to access the My API are the simplest methods and the most similar. They only differ in return type, and some of them have a page argument, others don't. This argument is always optional and the default is 1.

  • get_my_answers - uses /my/answers to get your latest answers. Returns an ItemSummaryViewModel. Has an optional page argument.
  • get_my_articles - uses /my/articles to get your articles. Returns an ItemSummaryViewModel. Has an optional page argument.
  • get_my_blog_posts - uses /my/blogposts to get your blog posts. Returns an ItemSummaryViewModel. Has an optional page argument.
  • get_my_bookmarks - uses /my/bookmarks to get the items you bookmarked. Returns an ItemSummaryViewModel. Has an optional page argument.
  • get_my_messages - uses /my/messages to get your latest forum messages. Returns an ItemSummaryViewModel. Has an optional page argument.
  • get_my_notifications - uses /my/notifications to get your unread notifications. Returns a MyNotificationsViewModel.
  • get_my_profile - uses /my/profile to get your profile information. Returns a MyProfileViewModel.
  • get_my_questions - uses /my/questions to get your latest questions. Returns an ItemSummaryViewModel. Has an optional page argument.
  • get_my_reputation - uses /my/reputation to get your reputation points. Returns a MyReputationViewModel.
  • get_my_tips - uses /my/tips to get your tips. Returns an ItemSummaryViewModel. Has an optional page argument.
Python
def get_my_answers(self, page=1):
    data = self.api_request("/my/answers", {"page": page})
    return ItemSummaryListViewModel(data)

def get_my_articles(self, page=1):
    data = self.api_request("/my/articles", {"page": page})
    return ItemSummaryListViewModel(data)

def get_my_blog_posts(self, page=1):
    data = self.api_request("/my/blogposts", {"page": page})
    return ItemSummaryListViewModel(data)

def get_my_bookmarks(self, page=1):
    data = self.api_request("/my/bookmarks", {"page": page})
    return ItemSummaryListViewModel(data)

def get_my_messages(self, page=1):
    data = self.api_request("/my/messages", {"page": page})
    return ItemSummaryListViewModel(data)

def get_my_notifications(self):
    data = self.api_request("/my/notifications")
    return MyNotificationsViewModel(data)

def get_my_profile(self):
    data = self.api_request("/my/profile")
    return MyProfileViewModel(data)

def get_my_questions(self, page=1):
    data = self.api_request("/my/questions", {"page": page})
    return ItemSummaryListViewModel(data)

def get_my_reputation(self):
    data = self.api_request("/my/reputation")
    return MyReputationViewModel(data)

def get_my_tips(self, page=1):
    data = self.api_request("/my/tips", {"page": page})
    return ItemSummaryListViewModel(data)

Test cases

All methods and view models are created now. To make sure that there are no errors, we need to have test cases. Because the API responses are dynamic, we cannot have test cases that check for static output. Instead, the tests look that we get complete data from the API (i.e the view models get filled correctly), that there are no exceptions at places where they shouldn't be thrown, and that they do get thrown when we expect it.

The test cases use two wrappers: one authenticated without email/password, one authenticated with those. Both should work fine for non-My APIs, and only the second should work for the My API.

The test cases can be found in test_apiwrapper.py. The file starts with a few methods that check whether the view models are filled completely (aside from the optional members, if any). All of them follow the same pattern: first check if the view model instance is not None, then check that all non-viewmodel properties are None and that all viewmodel properties are complete. This means that those assertion methods might call other assertion methods.

An example of an assertion method, assert_nameidpair_is_complete:

Python
def assert_nameidpair_is_complete(value):
    assert value is not None
    assert value.name is not None
    assert value.id is not None

It first asserts that value, the passed NameIdPair instance, is not None, and then it does the same for all members. If any of the assertions fail, an AssertionError will get thrown.

I won't copy the other assertion methods into the article: they all look the same and their only difference is that some view models have optional members.

Article wrapper methods testing: test_get_articles

After the assertion methods, you can find the methods to verify that specific parts of the wrapper are working. These methods all have the same method signature: all of them take one parameter, which should be an instance of CPApiWrapper.

The test_get_articles method starts by verifying that the get_articles method of CPApiWrapper returns correct data when there are no parameters passed (i.e. all default parameter values are used). It first checks that the returned ItemSummaryViewModel is complete, then it checks that the returned page is actually page 1, and that all articles have a rating of 3.0 or above.

Python
articles = w.get_articles()
assert_itemsummarylistviewmodel_is_complete(articles)
assert articles.pagination.page == 1
for article in articles.items:
    assert article.rating >= 3.0

The next test of test_get_articles passes a list of tags, a minimum rating, and a page number to get_articles. Upon receiving the response, it checks that the returned ItemSummaryViewModel is complete and that all articles comply to the criteria as specified.

Python
articles = w.get_articles("C#,C++", min_rating=4.0, page=3)
assert_itemsummarylistviewmodel_is_complete(articles)
assert articles.pagination.page == 3
for article in articles.items:
    assert article.rating >= 4.0
    tags_upper = [tag.name.upper() for tag in article.tags]
    assert "C#" in tags_upper or "C++" in tags_upper

The last test of test_get_articles verifies that min_rating also works for floating-point numbers. 4.5 gets passed as min_rating argument to get-articles, the other parameters are the defaults.

Python
articles = w.get_articles(min_rating=4.5)
assert_itemsummarylistviewmodel_is_complete(articles)
assert articles.pagination.page == 1
for article in articles.items:
    assert article.rating >= 4.5

Questions wrapper testing: test_get_questions

The next test method is test_get_questions, to test the get_questions method of CPApiWrapper. It starts by iterating over all QuestionListModes and sending an API request for questions on page 1 with the current mode and all tags. It asserts that the returned ItemSummaryViewModel is complete and that it really got page 1.

Python
for m in [QuestionListMode.unanswered, QuestionListMode.active,
          QuestionListMode.new]:
    questions = w.get_questions(m)
    assert_itemsummarylistviewmodel_is_complete(questions)
    assert questions.pagination.page == 1

Thereupon, the method tests that the include parameter of get_questions works. After receiving the ItemSummaryViewModel, it checks that all questions have one of the required tags. The test cases used HTML and CSS as included tags.

Python
questions = w.get_questions(include="HTML,CSS")
assert_itemsummarylistviewmodel_is_complete(questions)
for question in questions.items:
    tags_upper = [tag.name.upper() for tag in question.tags]
    assert "HTML" in tags_upper or "CSS" in tags_upper

After checking the included tags, checking the ignored tags follows. The code for this is pretty similar to the included-tag checking, except we now check that the questions are tagged with neither of the ignored tags.

Python
questions = w.get_questions(ignore="C#,SQL")
assert_itemsummarylistviewmodel_is_complete(questions)
for question in questions.items:
    tags_upper = [tag.name.upper() for tag in question.tags]
    assert "C#" not in tags_upper and "SQL" not in tags_upper

The latest part of the method is checking that the page argument works fine. After get_questions returns an ItemSummaryViewModel, the method asserts that it is complete and that the current page is 2, as passed to get_questions.

Python
questions = w.get_questions(page=2)
assert_itemsummarylistviewmodel_is_complete(questions)
assert questions.pagination.page == 2

Testing forum messages wrapper method: test_get_forum_messages

The method to test get_forum_messages is quite simple: it iterates over all ForumDisplayModes, loads the latest messages from the forum with ID 1159 (the Lounge[^]) with the current mode and asserts that the returned ItemSummaryViewModel is complete.

Python
def test_get_forum_messages(w):
    for m in [ForumDisplayMode.messages, ForumDisplayMode.threads]:
        messages = w.get_messages_from_forum(1159, m)
        assert_itemsummarylistviewmodel_is_complete(messages)

Testing the message thread wrapper method: test_get_thread_messages

This method tests the get_messages_from_thread method of CPApiWrapper. It calls the method with message 5058566[^] as thread and asserts that the returned ItemSummaryViewModel is complete.

Python
def test_get_thread_messages(w):
    messages = w.get_messages_from_thread(5058566)
    assert_itemsummarylistviewmodel_is_complete(messages)

Testing the My API wrapper methods: test_my

All different wrapper methods for the My API get tested in the same way: call the method, and assert that the returned view model is complete. For the methods that have an optional page argument, we make two calls: one with page = 1 (the default), and one with page = 2.

However, for most view models, before we assert that they are complete, we first have to check that the length of <view model instance>.items is greater than zero, and if it is not, don't try to assert that the view models are complete because we will get an assertion error. We did not have to do this for the previous test methods, but we have to do it here because there is no certainity that you have articles, answers, questions, etc. posted on the site, and if you don't, .items will be empty, and that's a perfectly valid case here.

Python
def test_my(w):
    answers = w.get_my_answers()
    if len(answers.items) > 0:
        assert_itemsummarylistviewmodel_is_complete(answers)
        assert answers.pagination.page == 1

    answers = w.get_my_answers(page=2)
    if len(answers.items) > 0:
        assert_itemsummarylistviewmodel_is_complete(answers)
        assert answers.pagination.page == 2

    articles = w.get_my_articles()
    if len(articles.items) > 0:
        assert_itemsummarylistviewmodel_is_complete(articles)
        assert articles.pagination.page == 1

    articles = w.get_my_articles(page=2)
    if len(articles.items) > 0:
        assert_itemsummarylistviewmodel_is_complete(articles)
        assert articles.pagination.page == 2

    blog = w.get_my_blog_posts()
    if len(blog.items) > 0:
        assert_itemsummarylistviewmodel_is_complete(blog)
        assert blog.pagination.page == 1

    blog = w.get_my_blog_posts(page=2)
    if len(blog.items) > 0:
        assert_itemsummarylistviewmodel_is_complete(blog)
        assert blog.pagination.page == 2

    bookmarks = w.get_my_bookmarks()
    if len(bookmarks.items) > 0:
        assert_itemsummarylistviewmodel_is_complete(bookmarks)
        assert bookmarks.pagination.page == 1

    bookmarks = w.get_my_bookmarks(page=2)
    if len(bookmarks.items) > 0:
        assert_itemsummarylistviewmodel_is_complete(bookmarks)
        assert bookmarks.pagination.page == 2

    messages = w.get_my_messages()
    if len(messages.items) > 0:
        assert_itemsummarylistviewmodel_is_complete(messages)
        assert messages.pagination.page == 1

    messages = w.get_my_messages(page=2)
    if len(messages.items) > 0:
        assert_itemsummarylistviewmodel_is_complete(messages)
        assert messages.pagination.page == 2

    notifications = w.get_my_notifications()
    assert_mynotificationsviewmodel_is_complete(notifications)

    profile = w.get_my_profile()
    assert_myprofileviewmodel_is_complete(profile)

    questions = w.get_my_questions()
    if len(questions.items) > 0:
        assert_itemsummarylistviewmodel_is_complete(questions)
        assert questions.pagination.page == 1

    questions = w.get_my_questions(page=2)
    if len(questions.items) > 0:
        assert_itemsummarylistviewmodel_is_complete(questions)
        assert questions.pagination.page == 2

    rep = w.get_my_reputation()
    assert_myrepviewmodel_is_complete(rep)

    tips = w.get_my_tips()
    if len(tips.items) > 0:
        assert_itemsummarylistviewmodel_is_complete(tips)
        assert tips.pagination.page == 1

    tips = w.get_my_tips(page=2)
    if len(tips.items) > 0:
        assert_itemsummarylistviewmodel_is_complete(tips)
        assert tips.pagination.page == 2

Executing the test cases

We have seen all the test methods now, but there should also be code to execute those tests, and that code can be found after the test_my method. The first thing that has to happen, is asking the user for a client ID, client secret, CodeProject logon email, and password. There are two options for this: provide everything as a command-line argument, or provide everything through standard input. The arguments can be found in the sys.argv variable (requires import sys). If the length of this list is 4, use the command-line arguments, otherwise, ask the user for input.

Python
if len(sys.argv) == 4:
    client_id = sys.argv[0]
    client_secret = sys.argv[1]
    email = sys.argv[2]
    password = sys.argv[3]
else:
    try:
        input_ = raw_input  # Python 2
    except NameError:
        input_ = input  # Python 3
    client_id = input_("Client ID: ")
    client_secret = input_("Client Secret: ")
    email = input_("Email: ")
    password = getpass("Password: ")

The wrapper works for both Python 2 and Python 3, so the test cases should work for both versions too. Here we see a difference: in Python 2, raw_input should be used to prompt for the client ID/secret and the email (Python 2's input is basically eval(raw_input())), but in Python 3, raw_input doesn't exist anymore and is called input. We store the correct input method as input_. Then we call this function for the client ID, client secret, and the logon email. But for the password, we use getpass (requires from getpass import getpass), which hides the user input and is suitable for passwords.

Then, the wrappers get initiated. After the initiation, we assert that the access_token_data member of both wrappers is None. Then we try to get the latest articles with one of the wrappers. This must fail and raise an AccessTokenNotFetchedException because the wrapper didn't fetch the access token yet.

Python
wrapper1 = CPApiWrapper()
wrapper2 = CPApiWrapper()
assert wrapper1.access_token_data is None
assert wrapper2.access_token_data is None
try:
    wrapper1.get_articles()
    assert False  # If the API key is not fetched, it should raise an exception
except AccessTokenNotFetchedException:
    pass  # Correct behavior

Thereupon, the access tokens get fetched. wrapper1 does not pass an email and a password, but wrapper2 does.

Python
print("Fetching access token")
wrapper1.get_access_token(client_id, client_secret)
wrapper2.get_access_token(client_id, client_secret, email, password)

Then it's time to run the test_... methods for the two wrappers. We iterate over them in a for loop and pass the current one as argument to the test methods. Note that test_my gets called outside the for loop: it should only be tested for wrapper 2.

Python
i = 0
for wr in [wrapper1, wrapper2]:
    i += 1
    print("Testing for wrapper {0}".format(i))
    print("Testing /Articles")
    test_get_articles(wr)
    print("Testing /Questions")
    test_get_questions(wr)
    print("Testing /Forum")
    test_get_forum_messages(wr)
    print("Testing /MessageThread")
    test_get_thread_messages(wr)
print("Testing /my")
test_my(wrapper2)

We also have to test that the wrapper throws an exception if we try to access the My API with wrapper 1: we didn't provide an email and a password there.

Python
print("Testing that /my throws an error without email/password")
try:
    wrapper1.api_request("/my/articles")
    assert False
except ApiRequestFailedException:
    pass

At the end of the test file, there is a print statement to print Test passed! if all tests passed successfully:

Python
print("Tests passed!")

Now we've seen the workings of this CodeProject API wrapper. I hope the article and the wrapper will be helpful for you. Thanks for reading!

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)