Compare commits

...

39 Commits

Author SHA1 Message Date
Ben Lopatin 22bf3b088e Version bump 0.7.0 2016-09-09 10:09:22 -04:00
Ben Lopatin 811453a129 Merge pull request #5 from jdorweiler/master
pass params in data
2016-09-09 10:04:24 -04:00
jddorweiler f8d628909c update test 2016-09-08 14:38:22 -04:00
jddorweiler 507e377a37 use data for posts and put 2016-09-08 14:23:38 -04:00
jddorweiler 0aac8f6628 pass params in data 2016-09-07 15:27:40 -04:00
Ben Lopatin acdcb03283 Version bump 0.6.0 2016-07-22 08:48:57 -04:00
Ben Lopatin 6bd73fdd5c Merge pull request #4 from Meal-Mentor/master
Added method to add group to the user
2016-07-22 08:46:22 -04:00
Scott Nixon c13b456b79 Added method to add group to the user 2016-07-19 11:33:27 -07:00
Ben Lopatin 977885967d Update makefile 2016-06-13 11:41:28 -04:00
Ben Lopatin b9066ca637 Version bump 0.5.0 2016-06-13 11:37:12 -04:00
Ben Lopatin 77254f441c Merge pull request #3 from msfernandes/badges_endpoint
Added badges endpoint to pydiscourse
2016-06-10 11:12:26 -04:00
Matheus Fernandes 66089011f7 Added 'user-badges' endpoint
Signed-off-by: Matheus Fernandes <matheus.souza.fernandes@gmail.com>
2016-06-10 11:46:56 -03:00
Matheus Fernandes fe317b6be8 Added badges endpoint to pydiscourse
Signed-off-by: Matheus Fernandes <matheus.souza.fernandes@gmail.com>
2016-06-10 10:59:03 -03:00
Ben Lopatin 15e82aacd1 Add specific methods for interface to groups
Hide 'verbs' from users.
2016-05-04 08:28:44 -04:00
Ben Lopatin 008f21d6fe Merge pull request #2 from citadelgrad/master
Added partial groups support
2016-05-04 07:36:59 -04:00
Scott Nixon 6baf51bbe1 Added partial groups support
Groups method returns the list of all groups.
Created group_owners which allows you to add and delete owners.
Created group_members which allows you to add and delete members.
2016-05-03 16:11:10 -07:00
Ben Lopatin 8304e7b2f5 Version bump 0.3.2 2016-04-17 11:36:09 -04:00
Ben Lopatin 5806beef34 Merge pull request #1 from danielzohar/master
Only return `nonce` from given payload
2016-04-17 11:32:41 -04:00
Daniel Zohar f905a957f4 Only return nonce from given payload 2016-04-17 16:00:35 +01:00
Ben Lopatin 06ca2c5a58 Update changelog to include in package description 2016-04-11 11:36:25 -04:00
Ben Lopatin bde4325776 Add autodoc generation 2016-04-08 18:45:57 -04:00
Ben Lopatin 6b7e570475 Update README
[ci skip]
2016-04-08 18:27:02 -04:00
Ben Lopatin 3659724f11 Remove duplicate requests dependency in tox config 2016-04-08 18:03:10 -04:00
Ben Lopatin 1e151fc51f Remove module import from setup.py
Get the version number from the file, not by importing and thus trying
to import requests
2016-04-08 17:57:19 -04:00
Ben Lopatin 63f120ddca Require requests for tests 2016-04-08 17:47:26 -04:00
Ben Lopatin 9cb96eaf76 Version bump 2016-04-08 17:38:16 -04:00
Ben Lopatin c5207759a8 Fix empty response handling 2016-04-08 17:36:18 -04:00
Ben Lopatin b14cd502ce Allow client import from top level module 2016-04-08 17:35:25 -04:00
Ben Lopatin 3a3bb843e5 Check for docstrings with flake8
[ci skip]
2016-04-08 13:59:18 -04:00
Ben Lopatin a2f961aebb Formatting cleanup and flake8 configuration
[ci skip]
2016-04-08 13:56:41 -04:00
Ben Lopatin bd508cdcee Version bump 0.3.0 2016-04-08 13:50:46 -04:00
Ben Lopatin 46051fd248 Fix unsuspend test 2016-04-08 13:47:43 -04:00
Ben Lopatin eed4df564d Add unsuspend method and un/suspend method tests 2016-04-08 13:43:05 -04:00
Ben Lopatin 6ac6a1fd2d Add client module docstring stubs 2016-04-08 13:43:05 -04:00
Ben Lopatin adf3f2ddbc Order imports 2016-04-08 13:43:05 -04:00
Ben Lopatin 2daebbfa23 Add test for external_id method 2016-04-08 13:43:05 -04:00
Ben Lopatin 9a23db7e43 Consolidate SSO tests
No good reason to separate these for now
2016-04-08 13:43:05 -04:00
Ben Lopatin 3be87f19dd Add Travis badge
[ci skip]
2016-04-07 17:50:27 -04:00
Ben Lopatin b7d4286c44 Remove requirements file from travis.yml 2016-04-07 17:46:28 -04:00
17 changed files with 907 additions and 78 deletions
-4
View File
@@ -7,8 +7,4 @@ python:
- "pypy"
- "pypy3"
install:
- "pip install -r requirements.dev.txt"
- "pip install ."
script: python setup.py test
+7
View File
@@ -1,2 +1,9 @@
(Based on original authors list and may be incomplete)
Marc Sibson
James Potter
Ben Lopatin
Daniel Zohar
Matheus Fernandes
Scott Nixon
Jason Dorweiler
+42 -5
View File
@@ -1,9 +1,46 @@
=========
Changelog
=========
.. :changelog:
Release history
===============
0.7.0
-----
* Place request parameters in the request body for POST and PUT requests.
Allows larger request sizes and solves for `URI Too Large` error.
0.6.0
-----
* Adds method to add user to group by user ID
0.5.0
-----
* Adds badges functionality
0.4.0
-----
* Adds initial groups functionality
0.3.2
-----
* SSO functionality fixes
0.3.1
-----
* Fix how empty responses are handled
0.3.0
-----
* Added method to unsuspend suspended user
0.2.0
=====
-----
* Inital fork, including gberaudo's changes
* Packaging cleanup, dropping Python 2.6 support and adding Python 3.5, PyPy,
@@ -11,7 +48,7 @@ Changelog
* Packaging on PyPI
0.1.0.dev
=========
---------
All pre-PyPI development
+6
View File
@@ -0,0 +1,6 @@
include setup.py
include README.rst
include MANIFEST.in
include HISTORY.rst
include LICENSE
recursive-include pydiscourse
+13 -6
View File
@@ -37,18 +37,25 @@ test-all: ## Run all tox test environments, parallelized
check: clean-build clean-pyc clean-test lint test-coverage
release: clean ## Uploads new source and wheel distributions (cleans first)
python setup.py sdist upload
python setup.py bdist_wheel upload
build: clean ## Create distribution files for release
python setup.py sdist bdist_wheel
dist: clean ## Creates new source and wheel distributions (cleans first)
release: build ## Create distribution files and publish to PyPI
python setup.py check -r -s
twine upload dist/*
sdist: clean ##sdist Create source distribution only
python setup.py sdist
python setup.py bdist_wheel
ls -l dist
docs: ## Builds and open docs
api-docs: ## Build autodocs from docstrings
sphinx-apidoc -f -o docs pydiscourse
manual-docs: ## Build written docs
$(MAKE) -C docs clean
$(MAKE) -C docs html
docs: api-docs manual-docs ## Builds and open docs
open docs/_build/html/index.html
help:
+17 -3
View File
@@ -2,6 +2,10 @@
pydiscourse
===========
.. image:: https://secure.travis-ci.org/bennylope/pydiscourse.svg?branch=master
:alt: Build Status
:target: http://travis-ci.org/bennylope/pydiscourse
A Python library for working with Discourse.
This is a fork of the original Tindie version. It was forked to include fixes,
@@ -10,17 +14,27 @@ additional functionality, and to distribute a package on PyPI.
Goals
=====
* Exceptional documentation
* Support all supported Python versions
* Provide functional parity with the Discourse API, for the currently supported
version of Discourse (something of a moving target)
* Support all supported Python versions
* Document API
The order here is important. The Discourse API is itself poorly documented so
the level of documentation in the Python client is critical.
Installation
============
::
pip install pydiscourse
Examples
========
Create a client connection to a Discourse server::
from pydiscourse.client import DiscourseClient
from pydiscourse import DiscourseClient
client = DiscourseClient(
'http://example.com',
api_username='username',
+3 -5
View File
@@ -28,9 +28,7 @@ import os
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
]
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon']
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
@@ -53,9 +51,9 @@ copyright = u'2014, Marc Sibson'
# built documents.
#
# The short X.Y version.
version = '0.2.0'
version = '0.7'
# The full version, including alpha/beta/rc tags.
release = '0.2.0'
release = '0.7.0'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
+7
View File
@@ -0,0 +1,7 @@
pydiscourse
===========
.. toctree::
:maxdepth: 4
pydiscourse
+46
View File
@@ -0,0 +1,46 @@
pydiscourse package
===================
Submodules
----------
pydiscourse.client module
-------------------------
.. automodule:: pydiscourse.client
:members:
:undoc-members:
:show-inheritance:
pydiscourse.exceptions module
-----------------------------
.. automodule:: pydiscourse.exceptions
:members:
:undoc-members:
:show-inheritance:
pydiscourse.main module
-----------------------
.. automodule:: pydiscourse.main
:members:
:undoc-members:
:show-inheritance:
pydiscourse.sso module
----------------------
.. automodule:: pydiscourse.sso
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: pydiscourse
:members:
:undoc-members:
:show-inheritance:
+5 -1
View File
@@ -1 +1,5 @@
__version__ = '0.2.0'
# -*- coding: utf-8 -*-
__version__ = '0.7.0'
from pydiscourse.client import DiscourseClient
Executable → Regular
+638 -31
View File
@@ -1,63 +1,205 @@
#!/usr/bin/env python
"""
Core API client module
"""
import logging
import requests
from pydiscourse.exceptions import DiscourseError, DiscourseServerError, DiscourseClientError
from pydiscourse.exceptions import (
DiscourseError, DiscourseServerError, DiscourseClientError)
from pydiscourse.sso import sso_payload
log = logging.getLogger('pydiscourse.client')
# HTTP verbs to be used as non string literals
DELETE = "DELETE"
GET = "GET"
POST = "POST"
PUT = "PUT"
class DiscourseClient(object):
""" A basic client for the Discourse API that implements the raw API
"""Discourse API client"""
This class will attempt to remain roughly similar to the discourse_api rails API
"""
def __init__(self, host, api_username, api_key, timeout=None):
"""
Initialize the client
Args:
host: full domain name including scheme for the Discourse API
api_username: username to connect with
api_key: API key to connect with
timeout: optional timeout for the request (in seconds)
Returns:
"""
self.host = host
self.api_username = api_username
self.api_key = api_key
self.timeout = timeout
def user(self, username):
"""
Get user information for a specific user
TODO: include sample data returned
TODO: what happens when no user is found?
Args:
username: username to return
Returns:
dict of user information
"""
return self._get('/users/{0}.json'.format(username))['user']
def create_user(self, name, username, email, password, **kwargs):
""" active='true', to avoid sending activation emails
"""
Create a Discourse user
Set keyword argument active='true' to avoid sending activation emails
TODO: allow optional password and generate a random one
Args:
name: the full name of the new user
username: their username (this is a key... that they can change)
email: their email, will be used for activation and summary emails
password: their initial password
**kwargs: ???? what else can be sent through?
Returns:
????
"""
r = self._get('/users/hp.json')
challenge = r['challenge'][::-1] # reverse challenge, discourse security check
confirmations = r['value']
return self._post('/users', name=name, username=username, email=email,
password=password, password_confirmation=confirmations, challenge=challenge, **kwargs)
password=password, password_confirmation=confirmations,
challenge=challenge, **kwargs)
def by_external_id(self, external_id):
def user_by_external_id(self, external_id):
"""
Args:
external_id:
Returns:
"""
response = self._get("/users/by-external/{0}".format(external_id))
return response['user']
by_external_id = user_by_external_id
def log_out(self, userid):
"""
Args:
userid:
Returns:
"""
return self._post('/admin/users/{0}/log_out'.format(userid))
def trust_level(self, userid, level):
"""
Args:
userid:
level:
Returns:
"""
return self._put('/admin/users/{0}/trust_level'.format(userid), level=level)
def suspend(self, userid, duration, reason):
return self._put('/admin/users/{0}/suspend'.format(userid), duration=duration, reason=reason)
"""
Suspend a user's account
Args:
userid: the Discourse user ID
duration: the length of time in days for which a user's account
should be suspended
reason: the reason for suspending the account
Returns:
????
"""
return self._put('/admin/users/{0}/suspend'.format(userid),
duration=duration, reason=reason)
def unsuspend(self, userid):
"""
Unsuspends a user's account
Args:
userid: the Discourse user ID
Returns:
None???
"""
return self._put('/admin/users/{0}/unsuspend'.format(userid))
def list_users(self, type, **kwargs):
""" optional user search: filter='test@example.com' or filter='scott' """
return self._get('/admin/users/list/{0}.json'.format(type), **kwargs)
"""
optional user search: filter='test@example.com' or filter='scott'
Args:
type:
**kwargs:
Returns:
"""
return self._get('/admin/users/list/{0}.json'.format(type), **kwargs)
def update_avatar_from_url(self, username, url, **kwargs):
"""
Args:
username:
url:
**kwargs:
Returns:
"""
return self._post('/users/{0}/preferences/avatar'.format(username), file=url, **kwargs)
def update_avatar_image(self, username, img, **kwargs):
"""
Args:
username:
img:
**kwargs:
Returns:
"""
files = {'file': img}
return self._post('/users/{0}/preferences/avatar'.format(username), files=files, **kwargs)
def toggle_gravatar(self, username, state=True, **kwargs):
"""
Args:
username:
state:
**kwargs:
Returns:
"""
url = '/users/{0}/preferences/avatar/toggle'.format(username)
if bool(state):
kwargs['use_uploaded_avatar'] = 'true'
@@ -66,87 +208,249 @@ class DiscourseClient(object):
return self._put(url, **kwargs)
def pick_avatar(self, username, gravatar=True, generated=False, **kwargs):
"""
Args:
username:
gravatar:
generated:
**kwargs:
Returns:
"""
url = '/users/{0}/preferences/avatar/pick'.format(username)
return self._put(url, **kwargs)
def update_email(self, username, email, **kwargs):
"""
Args:
username:
email:
**kwargs:
Returns:
"""
return self._put('/users/{0}/preferences/email'.format(username), email=email, **kwargs)
def update_user(self, username, **kwargs):
"""
Args:
username:
**kwargs:
Returns:
"""
return self._put('/users/{0}'.format(username), **kwargs)
def update_username(self, username, new_username, **kwargs):
return self._put('/users/{0}/preferences/username'.format(username), username=new_username, **kwargs)
"""
Args:
username:
new_username:
**kwargs:
Returns:
"""
return self._put('/users/{0}/preferences/username'.format(username),
username=new_username, **kwargs)
def set_preference(self, username=None, **kwargs):
"""
Args:
username:
**kwargs:
Returns:
"""
if username is None:
username = self.api_username
return self._put(u'/users/{0}'.format(username), **kwargs)
def sync_sso(self, **kwargs):
# expect sso_secret, name, username, email, external_id, avatar_url, avatar_force_update
"""
expect sso_secret, name, username, email, external_id, avatar_url,
avatar_force_update
Args:
**kwargs:
Returns:
"""
sso_secret = kwargs.pop('sso_secret')
payload = sso_payload(sso_secret, **kwargs)
return self._post('/admin/users/sync_sso?{0}'.format(payload), **kwargs)
def generate_api_key(self, userid, **kwargs):
"""
Args:
userid:
**kwargs:
Returns:
"""
return self._post('/admin/users/{0}/generate_api_key'.format(userid), **kwargs)
def delete_user(self, userid, **kwargs):
"""
block_email='true'
block_ip='false'
block_urls='false'
Args:
userid:
**kwargs:
Returns:
"""
return self._delete('/admin/users/{0}.json'.format(userid), **kwargs)
def users(self, filter=None, **kwargs):
"""
Args:
filter:
**kwargs:
Returns:
"""
if filter is None:
filter = 'active'
return self._get('/admin/users/list/{0}.json'.format(filter), **kwargs)
def private_messages(self, username=None, **kwargs):
"""
Args:
username:
**kwargs:
Returns:
"""
if username is None:
username = self.api_username
return self._get('/topics/private-messages/{0}.json'.format(username), **kwargs)
def private_messages_unread(self, username=None, **kwargs):
"""
Args:
username:
**kwargs:
Returns:
"""
if username is None:
username = self.api_username
return self._get('/topics/private-messages-unread/{0}.json'.format(username), **kwargs)
def hot_topics(self, **kwargs):
"""
Args:
**kwargs:
Returns:
"""
return self._get('/hot.json', **kwargs)
def latest_topics(self, **kwargs):
"""
Args:
**kwargs:
Returns:
"""
return self._get('/latest.json', **kwargs)
def new_topics(self, **kwargs):
"""
Args:
**kwargs:
Returns:
"""
return self._get('/new.json', **kwargs)
def topic(self, slug, topic_id, **kwargs):
"""
Args:
slug:
topic_id:
**kwargs:
Returns:
"""
return self._get('/t/{0}/{1}.json'.format(slug, topic_id), **kwargs)
def post(self, topic_id, post_id, **kwargs):
"""
Args:
topic_id:
post_id:
**kwargs:
Returns:
"""
return self._get('/t/{0}/{1}.json'.format(topic_id, post_id), **kwargs)
def posts(self, topic_id, post_ids=None, **kwargs):
""" Get a set of posts from a topic
"""
Get a set of posts from a topic
Args:
topic_id:
post_ids: a list of post ids from the topic stream
**kwargs:
Returns:
post_ids: a list of post ids from the topic stream
"""
if post_ids:
kwargs['post_ids[]'] = post_ids
return self._get('/t/{0}/posts.json'.format(topic_id), **kwargs)
def topic_timings(self, topic_id, time, timings={}, **kwargs):
""" Set time spent reading a post
time: overall time for the topic
timings = { post_number: ms }
"""
Set time spent reading a post
A side effect of this is to mark the post as read
Args:
topic_id: { post_number: ms }
time: overall time for the topic (in what unit????)
timings:
**kwargs:
Returns:
"""
kwargs['topic_id'] = topic_id
kwargs['topic_time'] = time
@@ -156,23 +460,68 @@ class DiscourseClient(object):
return self._post('/topics/timings', **kwargs)
def topic_posts(self, topic_id, **kwargs):
"""
Args:
topic_id:
**kwargs:
Returns:
"""
return self._get('/t/{0}/posts.json'.format(topic_id), **kwargs)
def create_post(self, content, **kwargs):
""" int: topic_id the topic to reply too
"""
Args:
content:
**kwargs:
Returns:
"""
return self._post('/posts', raw=content, **kwargs)
def update_post(self, post_id, content, edit_reason='', **kwargs):
"""
Args:
post_id:
content:
edit_reason:
**kwargs:
Returns:
"""
kwargs['post[raw]'] = content
kwargs['post[edit_reason]'] = edit_reason
return self._put('/posts/{0}'.format(post_id), **kwargs)
def topics_by(self, username, **kwargs):
"""
Args:
username:
**kwargs:
Returns:
"""
url = '/topics/created-by/{0}.json'.format(username)
return self._get(url, **kwargs)['topic_list']['topics']
def invite_user_to_topic(self, user_email, topic_id):
"""
Args:
user_email:
topic_id:
Returns:
"""
kwargs = {
'email': user_email,
'topic_id': topic_id,
@@ -180,13 +529,70 @@ class DiscourseClient(object):
return self._post('/t/{0}/invite.json'.format(topic_id), **kwargs)
def search(self, term, **kwargs):
"""
Args:
term:
**kwargs:
Returns:
"""
kwargs['term'] = term
return self._get('/search.json', **kwargs)
def create_category(self, name, color, text_color='FFFFFF', permissions=None, parent=None, **kwargs):
""" permissions - dict of 'everyone', 'admins', 'moderators', 'staff' with values of
def badges(self, **kwargs):
"""
Args:
**kwargs:
Returns:
"""
return self._get('/admin/badges.json', **kwargs)
def grant_badge_to(self, username, badge_id, **kwargs):
"""
Args:
username:
badge_id:
**kwargs:
Returns:
"""
return self._post('/user_badges', username=username, badge_id=badge_id, **kwargs)
def user_badges(self, username, **kwargs):
"""
Args:
username:
Returns:
"""
return self._get('/user-badges/{}.json'.format(username))
def create_category(self, name, color, text_color='FFFFFF',
permissions=None, parent=None, **kwargs):
"""
Args:
name:
color:
text_color:
permissions: dict of 'everyone', 'admins', 'moderators', 'staff' with values of ???
parent:
**kwargs:
Returns:
"""
kwargs['name'] = name
kwargs['color'] = color
kwargs['text_color'] = text_color
@@ -211,32 +617,232 @@ class DiscourseClient(object):
return self._post('/categories', **kwargs)
def categories(self, **kwargs):
"""
Args:
**kwargs:
Returns:
"""
return self._get('/categories.json', **kwargs)['category_list']['categories']
def category(self, name, parent=None, **kwargs):
"""
Args:
name:
parent:
**kwargs:
Returns:
"""
if parent:
name = u'{0}/{1}'.format(parent, name)
return self._get(u'/category/{0}.json'.format(name), **kwargs)
def site_settings(self, **kwargs):
"""
Args:
**kwargs:
Returns:
"""
for setting, value in kwargs.items():
setting = setting.replace(' ', '_')
self._request('PUT', '/admin/site_settings/{0}'.format(setting), {setting: value})
self._request(PUT, '/admin/site_settings/{0}'.format(setting), {setting: value})
def groups(self, **kwargs):
"""
Returns a list of all groups.
Returns:
List of dictionaries of groups
[
{
'alias_level': 0,
'automatic': True,
'automatic_membership_email_domains': None,
'automatic_membership_retroactive': False,
'grant_trust_level': None,
'has_messages': True,
'id': 1,
'incoming_email': None,
'mentionable': False,
'name': 'admins',
'notification_level': 2,
'primary_group': False,
'title': None,
'user_count': 9,
'visible': True
},
{
'alias_level': 0,
'automatic': True,
'automatic_membership_email_domains': None,
'automatic_membership_retroactive': False,
'grant_trust_level': None,
'has_messages': False,
'id': 0,
'incoming_email': None,
'mentionable': False,
'name': 'everyone',
'notification_level': None,
'primary_group': False,
'title': None,
'user_count': 0,
'visible': True
}
]
"""
return self._get("/admin/groups.json", **kwargs)
def add_group_owner(self, groupid, username):
"""
Add an owner to a group by username
Args:
groupid: the ID of the group
username: the new owner usernmae
Returns:
JSON API response
"""
return self._put("/admin/groups/{0}/owners.json".format(groupid), usernames=username)
def delete_group_owner(self, groupid, userid):
"""
Deletes an owner from a group by user ID
Does not delete the user from Discourse.
Args:
groupid: the ID of the group
userid: the ID of the user
Returns:
JSON API response
"""
return self._delete("/admin/groups/{0}/owners.json".format(groupid), user_id=userid)
def add_group_member(self, groupid, username):
"""
Add a member to a group by username
Args:
groupid: the ID of the group
username: the new member usernmae
Returns:
JSON API response
Raises:
DiscourseError if user is already member of group
"""
return self._put("/admin/groups/{0}/members.json".format(groupid), usernames=username)
def add_user_to_group(self, groupid, userid):
"""
Add a member to a group by with user id.
Args:
groupid: the ID of the group
userid: the member id
Returns:
JSON API response
Raises:
DiscourseError if user is already member of group
"""
return self._post("/admin/users/{0}/groups".format(userid), group_id=groupid)
def delete_group_member(self, groupid, userid):
"""
Deletes a member from a group by user ID
Does not delete the user from Discourse.
Args:
groupid: the ID of the group
userid: the ID of the user
Returns:
JSON API response
"""
return self._delete("/admin/groups/{0}/members.json".format(groupid), user_id=userid)
def _get(self, path, **kwargs):
return self._request('GET', path, kwargs)
"""
Args:
path:
**kwargs:
Returns:
"""
return self._request(GET, path, params=kwargs)
def _put(self, path, **kwargs):
return self._request('PUT', path, kwargs)
"""
Args:
path:
**kwargs:
Returns:
"""
return self._request(PUT, path, data=kwargs)
def _post(self, path, **kwargs):
return self._request('POST', path, kwargs)
"""
Args:
path:
**kwargs:
Returns:
"""
return self._request(POST, path, data=kwargs)
def _delete(self, path, **kwargs):
return self._request('DELETE', path, kwargs)
"""
def _request(self, verb, path, params):
Args:
path:
**kwargs:
Returns:
"""
return self._request(DELETE, path, params=kwargs)
def _request(self, verb, path, params={}, data={}):
"""
Executes HTTP request to API and handles response
Args:
verb: HTTP verb as string: GET, DELETE, PUT, POST
path: the path on the Discourse API
params: dictionary of parameters to include to the API
Returns:
"""
params['api_key'] = self.api_key
if 'api_username' not in params:
params['api_username'] = self.api_username
@@ -245,7 +851,7 @@ class DiscourseClient(object):
headers = {'Accept': 'application/json; charset=utf-8'}
response = requests.request(
verb, url, allow_redirects=False, params=params, headers=headers,
verb, url, allow_redirects=False, params=params, data=data, headers=headers,
timeout=self.timeout)
log.debug('response %s: %s', response.status_code, repr(response.text))
@@ -264,13 +870,14 @@ class DiscourseClient(object):
raise DiscourseServerError(msg, response=response)
if response.status_code == 302:
raise DiscourseError('Unexpected Redirect, invalid api key or host?', response=response)
raise DiscourseError(
'Unexpected Redirect, invalid api key or host?', response=response)
json_content = 'application/json; charset=utf-8'
content_type = response.headers['content-type']
if content_type != json_content:
# some calls return empty html documents
if response.content == ' ':
if not response.content.strip():
return None
raise DiscourseError('Invalid Response, expecting "{0}" got "{1}"'.format(
+3 -3
View File
@@ -2,11 +2,11 @@
import cmd
import json
import logging
import optparse
import os
import pydoc
import sys
import os
import logging
from pydiscourse.client import DiscourseClient, DiscourseError
@@ -31,7 +31,7 @@ class DiscourseCmd(cmd.Cmd):
try:
return method(*args, **kwargs)
except DiscourseError as e:
print (e, e.response.text)
print(e, e.response.text)
return e.response
return wrapper
+11 -7
View File
@@ -1,9 +1,11 @@
"""
Utilities to implement Single Sign On for Discourse with a Python managed authentication DB
Utilities to implement Single Sign On for Discourse with a Python managed
authentication DB
https://meta.discourse.org/t/official-single-sign-on-for-discourse/13045
Thanks to James Potter for the heavy lifting, detailed at https://meta.discourse.org/t/sso-example-for-django/14258
Thanks to James Potter for the heavy lifting, detailed at
https://meta.discourse.org/t/sso-example-for-django/14258
A SSO request handler might look something like
@@ -16,7 +18,8 @@ A SSO request handler might look something like
except DiscourseError as e:
return HTTP400(e.args[0])
url = sso_redirect_url(nonce, SECRET, request.user.email, request.user.id, request.user.username)
url = sso_redirect_url(nonce, SECRET, request.user.email,
request.user.id, request.user.username)
return redirect('http://discuss.example.com' + url)
"""
from base64 import b64encode, b64decode
@@ -24,9 +27,10 @@ import hmac
import hashlib
try: # py3
from urllib.parse import unquote, urlencode
from urllib.parse import unquote, urlencode, parse_qs
except ImportError:
from urllib import unquote, urlencode
from urlparse import parse_qs
from pydiscourse.exceptions import DiscourseError
@@ -60,9 +64,9 @@ def sso_validate(payload, signature, secret):
if this_signature != signature:
raise DiscourseError('Payload does not match signature.')
nonce = decoded.split('=')[1]
return nonce
# Discourse returns querystring encoded value. We only need `nonce`
qs = parse_qs(decoded)
return qs['nonce'][0]
def sso_payload(secret, **kwargs):
+14 -4
View File
@@ -2,21 +2,31 @@ from setuptools import setup, find_packages
README = open('README.rst').read()
VERSION = __import__("pydiscourse").__version__
HISTORY = open('HISTORY.rst').read().replace('.. :changelog:', '')
with open("pydiscourse/__init__.py", "r") as module_file:
for line in module_file:
if line.startswith("__version__"):
version_string = line.split("=")[1]
VERSION = version_string.strip().replace("'", "")
setup(
name="pydiscourse",
version=VERSION,
description="A Python library for the Discourse API",
long_description=README,
long_description=README + '\n\n' + HISTORY,
author="Marc Sibson and contributors",
author_email="ben+pydiscourse@benlopatin.com",
license="BSD",
url="https://github.com/bennylope/pydiscourse",
packages=find_packages(exclude=["tests.*", "tests"]),
install_requires=['requests>=2.0.0'],
tests_require=['mock'],
install_requires=[
'requests>=2.0.0',
],
tests_require=[
'mock',
],
test_suite='tests',
entry_points={
'console_scripts': [
+81 -5
View File
@@ -1,8 +1,18 @@
import sys
import unittest
import mock
from pydiscourse import client
import sys
if sys.version_info < (3,):
def b(x):
return x
else:
import codecs
def b(x):
return codecs.latin_1_encode(x)[0]
def prepare_response(request):
# we need to mocked response to look a little more real
@@ -10,8 +20,12 @@ def prepare_response(request):
class ClientBaseTestCase(unittest.TestCase):
"""
"""
def setUp(self):
self.host = 'testhost'
self.host = 'http://testhost'
self.api_username = 'testuser'
self.api_key = 'testkey'
@@ -28,7 +42,35 @@ class ClientBaseTestCase(unittest.TestCase):
kwargs = kwargs['params']
self.assertEqual(kwargs.pop('api_username'), self.api_username)
self.assertEqual(kwargs.pop('api_key'), self.api_key)
self.assertEqual(kwargs, params)
if verb == 'GET':
self.assertEqual(kwargs, params)
class TestClientRequests(ClientBaseTestCase):
"""
Tests for common request handling
"""
@mock.patch('pydiscourse.client.requests')
def test_empty_content_http_ok(self, mocked_requests):
"""Empty content should not raise error
Critical to test against *bytestrings* rather than unicode
"""
mocked_response = mock.MagicMock()
mocked_response.content = b(' ')
mocked_response.status_code = 200
mocked_response.headers = {"content-type": "text/plain; charset=utf-8"}
assert "content-type" in mocked_response.headers
mocked_requests.request = mock.MagicMock()
mocked_requests.request.return_value = mocked_response
resp = self.client._request('GET', '/users/admin/1/unsuspend', {})
self.assertIsNone(resp)
@mock.patch('requests.request')
@@ -59,7 +101,31 @@ class TestUser(ClientBaseTestCase):
def test_update_username(self, request):
prepare_response(request)
self.client.update_username('someuser', 'newname')
self.assertRequestCalled(request, 'PUT', '/users/someuser/preferences/username', username='newname')
self.assertRequestCalled(request, 'PUT',
'/users/someuser/preferences/username',
username='newname')
def test_by_external_id(self, request):
prepare_response(request)
self.client.by_external_id(123)
self.assertRequestCalled(request, 'GET',
'/users/by-external/123')
def test_suspend_user(self, request):
prepare_response(request)
self.client.suspend(123, 1, "Testing")
self.assertRequestCalled(request, 'PUT', '/admin/users/123/suspend',
duration=1, reason="Testing")
def test_unsuspend_user(self, request):
prepare_response(request)
self.client.unsuspend(123)
self.assertRequestCalled(request, 'PUT', '/admin/users/123/unsuspend')
def test_user_bagdes(self, request):
prepare_response(request)
self.client.user_badges('username')
self.assertRequestCalled(request, 'GET', '/user-badges/{}.json'.format('username'))
@mock.patch('requests.request')
@@ -111,8 +177,18 @@ class MiscellaneousTests(ClientBaseTestCase):
r = self.client.categories()
self.assertRequestCalled(request, 'GET', '/categories.json')
self.assertEqual(r, request().json()['category_list']['categories'])
def test_users(self, request):
prepare_response(request)
r = self.client.users()
self.client.users()
self.assertRequestCalled(request, 'GET', '/admin/users/list/active.json')
def test_badges(self, request):
prepare_response(request)
self.client.badges()
self.assertRequestCalled(request, 'GET', '/admin/badges.json')
def test_grant_badge_to(self, request):
prepare_response(request)
self.client.grant_badge_to('username', 1)
self.assertRequestCalled(request, 'POST', '/user_badges', username='username', badge_id=1)
-4
View File
@@ -32,8 +32,6 @@ class SSOTestCase(unittest.TestCase):
self.email = u'test@test.com'
self.redirect_url = u'/session/sso_login?sso=bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGImbmFtZT1z%0AYW0mdXNlcm5hbWU9c2Ftc2FtJmVtYWlsPXRlc3QlNDB0ZXN0LmNvbSZleHRl%0Acm5hbF9pZD1oZWxsbzEyMw%3D%3D%0A&sig=1c884222282f3feacd76802a9dd94e8bc8deba5d619b292bed75d63eb3152c0b'
class Test_sso_validate(SSOTestCase):
def test_missing_args(self):
with self.assertRaises(DiscourseError):
sso.sso_validate(None, self.signature, self.secret)
@@ -52,8 +50,6 @@ class Test_sso_validate(SSOTestCase):
nonce = sso.sso_validate(self.payload, self.signature, self.secret)
self.assertEqual(nonce, self.nonce)
class Test_sso_redirect_url(SSOTestCase):
def test_valid_redirect_url(self):
url = sso.sso_redirect_url(self.nonce, self.secret, self.email, self.external_id, self.username, name='sam')
+14
View File
@@ -5,3 +5,17 @@ envlist = py27, py34, py35, pypy, pypy3
setenv =
PYTHONPATH = {toxinidir}:{toxinidir}/pydiscourse
commands = python setup.py test
[testenv:flake8]
basepython=python
deps=
flake8
flake8_docstrings
commands=
flake8 pydiscourse
[flake8]
ignore = E126,E128
max-line-length = 99
exclude = .ropeproject
max-complexity = 10