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
.
def __init__(self):
self.access_token_data = None
Usage of the class and the constructor:
from CPApiWrapper.cpapiwrapper import CPApiWrapper
wrapper = CPApiWrapper()
The import statements
It's worth taking a look at the import statements at the top of CPApiWrapper.py:
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
except ImportError:
from urllib.parse import urljoin
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.
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.
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
.
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.
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:
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 ItemSummary
s. 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).
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.
@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 ItemSummary
s.
Like all view models, ItemSumamryListViewModel
has a constructor that takes a dictionary, which has to be the parsed API response.
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 ItemSummary
s (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 NameIdPair
s 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 NameIdPair
s for the item's categories. tags
- a list of NameIdPair
s 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:
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 ItemSummary
s 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
.
@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.
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 NotificationViewModel
s.
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 NotificationViewModel
s.
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.
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.
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 ReputationTypeViewModel
s (see next section) to split the total reputation points in different parts. graph_url
- the URL to your reputation graph.
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 ReputationTypeViewModel
s. It works exactly like data_to_pairs
of NameIdPair
.
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:
from CPApiWrapper.cpapiwrapper import CPApiWrapper
wrapper = CPApiWrapper()
wrapper.get_access_token("Client ID here", "Client Secret here")
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
.
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.
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
.
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
.
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.
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
:
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.
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.
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.
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 QuestionListMode
s 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.
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.
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.
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
.
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 ForumDisplayMode
s, loads the latest messages from the forum with ID 1159 (the Lounge[^]) with the current mode and asserts that the returned ItemSummaryViewModel
is complete.
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.
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.
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.
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
except NameError:
input_ = input
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.
wrapper1 = CPApiWrapper()
wrapper2 = CPApiWrapper()
assert wrapper1.access_token_data is None
assert wrapper2.access_token_data is None
try:
wrapper1.get_articles()
assert False
except AccessTokenNotFetchedException:
pass
Thereupon, the access tokens get fetched. wrapper1
does not pass an email and a password, but wrapper2
does.
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.
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.
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:
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!