Compare commits

..

1 Commits

Author SHA1 Message Date
Marc Sibson 9f53443188 first pass at something like an object API 2014-05-26 14:26:50 -07:00
29 changed files with 418 additions and 2191 deletions
-8
View File
@@ -1,8 +0,0 @@
### Summary of changes
## Checklist
- [ ] Changes represent a *discrete update*
- [ ] Commit messages are meaningful and descriptive
- [ ] Changeset does not include any extraneous changes unrelated to the discrete change
-23
View File
@@ -1,23 +0,0 @@
name: Tests
on: [push, pull_request]
jobs:
test:
name: Test on Python ${{ matrix.py_version }}
runs-on: ubuntu-latest
strategy:
matrix:
py_version: [2.7, 3.5, 3.6, 3.7]
steps:
- uses: actions/checkout@v1
- name: Set up Python ${{ matrix.py_version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.py_version }}
- name: Install mock for Python 2.7
run: pip install mock
- name: Run tests
run: python setup.py test
+7 -8
View File
@@ -1,11 +1,10 @@
sudo: false
language: python
python:
- "2.7"
- "3.4"
- "3.5"
- "3.6"
- "pypy"
- "pypy3"
- "2.7"
script: python setup.py test
install:
- "pip install -r requirements.dev.txt"
- "pip install ."
script: nosetests
-12
View File
@@ -1,12 +0,0 @@
(Based on original authors list and may be incomplete)
Marc Sibson
James Potter
Ben Lopatin
Daniel Zohar
Matheus Fernandes
Scott Nixon
Jason Dorweiler
Pierre-Alain Dupont
Karl Goetz
Alex Kerney
-76
View File
@@ -1,76 +0,0 @@
============
Contributing
============
For patches, please ensure that all existing tests pass, that you have adequate
tests added as necessary, and that all code is documented! The latter is
critical. If you add or update an existing function, class, or module, please
ensure you add a docstring or ensure the existing docstring is up-to-date.
Please use `Google docstring format
<http://sphinxcontrib-napoleon.readthedocs.org/en/latest/example_google.html>`_.
This *will* be enforced.
Pull requests
=============
Reviewing and merging pull requests is work, so whatever you can do to make this
easier for the package maintainer not only speed up the process of getting your
changes merged but also ensure they are. These few guidelines help significantly.
If they are confusing or you need help understanding how to accomplish them,
please ask for help in an issue.
- Please do make sure your chnageset represents a *discrete update*. If you would like to fix formatting, by all means, but don't mix that up with a bug fix. Those are separate PRs.
- Please do make sure that both your pull request description and your commits are meaningful and descriptive. Rebase first, if need be.
- Please do make sure your changeset does not include more commits than necessary. Rebase first, if need be.
- Please do make sure the changeset is not very big. If you have a large change propose it in an issue first.
- Please do make sure your changeset is based on a branch from the current HEAD of the fork you wish to merge against. This is a general best practice. Rebase first, if need be.
Testing
=======
The best way to run the tests is with `tox <http://tox.readthedocs.org/en/latest/>`_::
pip install tox
detox
Or it's slightly faster cousin `detox
<https://pypi.python.org/pypi/detox>`_ which will parallelize test runs::
pip install detox
detox
Alternatively, you can run the self test with the following commands::
pip install -r requirements.dev.txt
pip install -e .
python setup.py test
Live Testing
============
You can test against a Discourse instance by following the [Official Discourse developement instructions][discoursedev].
For the impatient here is the quick and dirty version::
git clone git@github.com:discourse/discourse.git
cd discourse
vagrant up
vagrant ssh
cd /vagrant
bundle install
bundle exec rake db:migrate
bundle exec rails s
Once running you can access the Discourse install at http://localhost:4000.
[discoursedev]: https://github.com/discourse/discourse/blob/master/docs/VAGRANT.md "Discourse Vagrant"
TODO
====
For a list of all operations:
you can just run rake routes inside of the discourse repo to get an up to date list
Or check the old [`routes.txt`](https://github.com/discourse/discourse_api/blob/aa75df6cd851f0666f9e8071c4ef9dfdd39fc8f8/routes.txt) file, though this is certainly outdated.
+30
View File
@@ -0,0 +1,30 @@
Development
------------
Refer to, https://github.com/discourse/discourse_api/blob/master/routes.txt for a list of all operations available in Discourse.
Unit tests
--------------
You can run the self test with the following commands::
pip install -r requirements.dev.txt
pip install -e .
nosetests
Live Testing
-----------------
You can test against a Discourse instance by following the [Official Discourse developement instructions][discoursedev].
For the impatient here is the quick and dirty version::
git clone git@github.com:discourse/discourse.git
cd discourse
vagrant up
vagrant ssh
cd /vagrant
bundle install
bundle exec rake db:migrate
bundle exec rails s
Once running you can access the Discourse install at http://localhost:4000.
[discoursedev]: https://github.com/discourse/discourse/blob/master/docs/VAGRANT.md "Discourse Vagrant"
-100
View File
@@ -1,100 +0,0 @@
.. :changelog:
Release history
===============
1.1.0
-----
- Added ability to follow redirects in requests
1.0.0
-----
- Authenticate with headers
0.9.0
-----
- Added rate limiting support
- Added some support for user activation
0.8.0
-----
- Add some PR guidance
- Add support for files in the core request methods
- Adds numerous new API controls, including:
- tag_group
- user_actions
- upload_image
- block
- trust_level_lock
- create_site_customization (theme)
- create_color_scheme
- color_schemes
- add_group_members
- group_members
- group_owners
- delete_group
- create_group
- group
- customize_site_texts
- delete_category
- user_emails
- update_topic_status
- create_post
- update_topic
- update_avatar
- user_all
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,
PyPy3
* Packaging on PyPI
0.1.0.dev
---------
All pre-PyPI development
-6
View File
@@ -1,6 +0,0 @@
include setup.py
include README.rst
include MANIFEST.in
include HISTORY.rst
include LICENSE
recursive-include pydiscourse
-62
View File
@@ -1,62 +0,0 @@
.PHONY: clean-pyc clean-build docs clean
clean: clean-build clean-pyc clean-test-all
clean-build:
@rm -rf build/
@rm -rf dist/
@rm -rf *.egg-info
clean-pyc:
-@find . -name '*.pyc' -follow -print0 | xargs -0 rm -f &> /dev/null
-@find . -name '*.pyo' -follow -print0 | xargs -0 rm -f &> /dev/null
-@find . -name '__pycache__' -type d -follow -print0 | xargs -0 rm -rf &> /dev/null
clean-test:
rm -rf .coverage coverage*
rm -rf tests/.coverage test/coverage*
rm -rf htmlcov/
clean-test-all: clean-test
rm -rf .tox/
lint:
flake8 pydiscourse
test: ## Run test suite against current Python path
python setup.py test
test-coverage: clean-test
-py.test ${COVER_FLAGS} ${TEST_FLAGS}
@exit_code=$?
@-coverage html
@exit ${exit_code}
test-all: ## Run all tox test environments, parallelized
detox
check: clean-build clean-pyc clean-test lint test-coverage
build: clean ## Create distribution files for release
python setup.py sdist bdist_wheel
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
ls -l dist
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:
@perl -nle'print $& if m{^[a-zA-Z_-]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
+33
View File
@@ -0,0 +1,33 @@
pydiscourse
------------
A Python library for the Discourse API.
Its pretty basic right now but you need to start somewhere.
Examples
-----------
Create a client connection to a Discourse server::
from pydiscourse.client import DiscourseClient
client = DiscourseClient('http://example.com', api_username='username', api_key='areallylongstringfromdiscourse')
Get info about a user::
user = client.user('eviltrout')
print user
user_topics = client.topics_by('johnsmith')
print user_topics
Create a new user::
user = client.create_user('The Black Knight', 'blacknight', 'knight@python.org', 'justafleshwound')
Command line
----------------
To help experiment with the Discourse API, pydiscourse provides a simple command line client::
export DISCOURSE_API_KEY=your_master_key
pydiscoursecli --host=http://yourhost --api-username=system latest_topics
pydiscoursecli --host=http://yourhost --api-username=system topics_by johnsmith
pydiscoursecli --host=http://yourhost --api-username=system user eviltrout
-78
View File
@@ -1,78 +0,0 @@
===========
pydiscourse
===========
.. image:: https://github.com/bennylope/pydiscourse/workflows/Tests/badge.svg
:alt: Build Status
:target: https://github.com/bennylope/pydiscourse/actions
.. image:: https://img.shields.io/badge/Check%20out%20the-Docs-blue.svg
:alt: Check out the Docs
:target: https://discourse.readthedocs.io/en/latest/
A Python library for working with Discourse.
This is a fork of the original Tindie version. It was forked to include fixes,
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)
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 import DiscourseClient
client = DiscourseClient(
'http://example.com',
api_username='username',
api_key='areallylongstringfromdiscourse')
Get info about a user::
user = client.user('eviltrout')
print user
user_topics = client.topics_by('johnsmith')
print user_topics
Create a new user::
user = client.create_user('The Black Knight', 'blacknight', 'knight@python.org', 'justafleshwound')
Implement SSO for Discourse with your Python server::
@login_required
def discourse_sso_view(request):
payload = request.GET.get('sso')
signature = request.GET.get('sig')
nonce = sso_validate(payload, signature, SECRET)
url = sso_redirect_url(nonce, SECRET, request.user.email, request.user.id, request.user.username)
return redirect('http://discuss.example.com' + url)
Command line
============
To help experiment with the Discourse API, pydiscourse provides a simple command line client::
export DISCOURSE_API_KEY=your_master_key
pydiscoursecli --host-http://yourhost --api-user-system latest_topics
pydiscoursecli --host-http://yourhost --api-user-system topics_by johnsmith
pydiscoursecli --host-http://yourhost --api-user-system user eviltrout
+5 -3
View File
@@ -28,7 +28,9 @@ 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', 'sphinx.ext.napoleon']
extensions = [
'sphinx.ext.autodoc',
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
@@ -51,9 +53,9 @@ copyright = u'2014, Marc Sibson'
# built documents.
#
# The short X.Y version.
version = '1.1'
version = '0.1'
# The full version, including alpha/beta/rc tags.
release = '1.1.0'
release = '0.1'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
+2 -3
View File
@@ -1,5 +1,4 @@
Development
===============
.. include:: ../CONTRIBUTING.rst
:start-line: 3
.. include:: ../DEVELOP.md
:start-line: 2
+2 -3
View File
@@ -1,5 +1,4 @@
Introduction
==============
.. include:: ../README.rst
:start-line: 3
.. include:: ../README.md
:start-line: 2
-7
View File
@@ -1,7 +0,0 @@
pydiscourse
===========
.. toctree::
:maxdepth: 4
pydiscourse
-46
View File
@@ -1,46 +0,0 @@
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:
+1 -5
View File
@@ -1,5 +1 @@
# -*- coding: utf-8 -*-
__version__ = "1.1.0"
from pydiscourse.client import DiscourseClient
__version__ = '0.1.0.dev'
+160
View File
@@ -0,0 +1,160 @@
""" A higher level API wrapper for communicating with a Discourse instance
EXPERIMENTAL, subject to complete and radical change
Goal
------
A pythonic wrapper around the discourse API that minimizes requests by lazy loading of data.
"""
from datetime import datetime
def datetime_from(date):
DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%f'
# XXX not handling timezone, but we're not using it yet
return datetime.strptime(date[:-6], DATE_FORMAT)
class DiscourseUser(object):
username = None
userid = None
avatar_template = None
default_avatar_size = 40
def avatar(self, size=None):
if size is None:
size = self.default_avatar_size
return self.avatar_template.replace(u'{size}', unicode(size))
def __repr__(self):
return '<DiscourseUser {0} {1}>'.format(self.userid, self.username)
@classmethod
def from_summary(cls, summary):
instance = cls()
instance.username = summary['username']
instance.userid = summary['id']
instance.avatar_template = summary['avatar_template']
return instance
class DiscourseUserSet(object):
def __init__(self, users):
self.users = users
self.byname = {u.username: u for u in users}
self.byid = {u.userid: u for u in users}
def __iter__(self):
return iter(self.users)
def __getitem__(self, item):
try:
return self.byname[item]
except KeyError:
return self.byid[item]
@classmethod
def from_response(cls, response):
users = [DiscourseUser.from_summary(u) for u in response.get('users', [])]
return cls(users)
class DiscoursePost(object):
default_avatar_size = 40
def avatar(self, size=None):
if size is None:
size = self.default_avatar_size
return self.avatar_template.replace(u'{size}', unicode(size))
def date_created(self):
return datetime_from(self.data['created_at'])
@classmethod
def from_dict(cls, data):
instance = cls()
instance.data = data
return instance
def __getattr__(self, attr):
return self.data[attr]
class DiscourseTopic(object):
def __init__(self):
self.posts = None
self.data = None
self.raw_response = None
def created_by(self):
return DiscourseUser.from_summary(self.data['details']['created_by'])
def created_at(self):
return datetime_from(self.data['created_at'])
def last_posted_at(self):
return datetime_from(self.data['last_posted_at'])
def num_unread(self):
if self.data['unseen']:
return 1
return self.data['new_posts']
def participants(self):
return [DiscourseUser.from_summary(u) for u in self.data['participants']]
@classmethod
def from_response(cls, response):
instance = DiscourseTopic.from_dict(response)
instance.raw_response = response
instance.posts = []
for post in response['post_stream']['posts']:
instance.posts.append(DiscoursePost.from_dict(post))
return instance
@classmethod
def from_dict(cls, data):
instance = cls()
instance.data = data
return instance
def fetch_remaining_posts(self, discourse):
""" The initial topic response is paginated, this makes another request to get additional posts
"""
if self.data['posts_count'] > len(self.posts):
missing = self.data['post_stream']['stream'][len(self.posts):]
response = discourse.posts(self.id, missing)
for post in response['post_stream']['posts']:
self.posts.append(DiscoursePost.from_dict(post))
def __getattr__(self, attr):
return self.data[attr]
def __repr__(self):
return u'<Topic {0}>'.format(self.title)
class DiscourseTopicSet(object):
def __init__(self, topics):
self.topics = topics
def all(self):
return self.topics
def __iter__(self):
return iter(self.topics)
def __len__(self):
return len(self.topics)
@classmethod
def from_response(cls, response):
topics = response.get('topic_list', {}).get('topics', [])
topics = [DiscourseTopic.from_dict(t) for t in topics]
return cls(topics)
Regular → Executable
+94 -1348
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -11,7 +11,3 @@ class DiscourseServerError(DiscourseError):
class DiscourseClientError(DiscourseError):
""" An invalid request has been made """
class DiscourseRateLimitedError(DiscourseError):
""" Request required more than the permissible number of retries """
+16 -27
View File
@@ -1,43 +1,40 @@
#!/usr/bin/env python
import cmd
import json
import logging
import optparse
import os
import pydoc
import sys
import os
import logging
from pydiscourse.client import DiscourseClient, DiscourseError
class DiscourseCmd(cmd.Cmd):
prompt = "discourse>"
prompt = 'discourse>'
output = sys.stdout
def __init__(self, client):
cmd.Cmd.__init__(self)
self.client = client
self.prompt = "%s>" % self.client.host
self.prompt = '%s>' % self.client.host
def __getattr__(self, attr):
if attr.startswith("do_"):
if attr.startswith('do_'):
method = getattr(self.client, attr[3:])
def wrapper(arg):
args = arg.split()
kwargs = dict(a.split("=") for a in args if "=" in a)
args = [a for a in args if "=" not in a]
kwargs = dict(a.split('=') for a in args if '=' in a)
args = [a for a in args if '=' not in a]
try:
return method(*args, **kwargs)
except DiscourseError as e:
print(e, e.response.text)
print e, e.response.text
return e.response
return wrapper
elif attr.startswith("help_"):
elif attr.startswith('help_'):
method = getattr(self.client, attr[5:])
def wrapper():
@@ -49,27 +46,19 @@ class DiscourseCmd(cmd.Cmd):
def postcmd(self, result, line):
try:
json.dump(
result, self.output, sort_keys=True, indent=4, separators=(",", ": ")
)
json.dump(result, self.output, sort_keys=True, indent=4, separators=(',', ': '))
except TypeError:
self.output.write(result.text)
def main():
op = optparse.OptionParser()
op.add_option("--host", default="http://localhost:4000")
op.add_option("--api-user", default="system")
op.add_option("-v", "--verbose", action="store_true")
op.add_option('--host', default='http://localhost:4000')
op.add_option('--api-user', default='system')
op.add_option('-v', '--verbose', action='store_true')
api_key = os.environ['DISCOURSE_API_KEY']
options, args = op.parse_args()
if not options.host.startswith("http"):
op.error("host must include protocol, eg http://")
api_key = os.environ.get("DISCOURSE_API_KEY")
if not api_key:
op.error("please set DISCOURSE_API_KEY")
client = DiscourseClient(options.host, options.api_user, api_key)
if options.verbose:
@@ -78,12 +67,12 @@ def main():
c = DiscourseCmd(client)
if args:
line = " ".join(args)
line = ' '.join(args)
result = c.onecmd(line)
c.postcmd(result, line)
else:
c.cmdloop()
if __name__ == "__main__":
if __name__ == '__main__':
main()
-100
View File
@@ -1,100 +0,0 @@
"""
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
A SSO request handler might look something like
@login_required
def discourse_sso_view(request):
payload = request.GET.get('sso')
signature = request.GET.get('sig')
try:
nonce = sso_validate(payload, signature, SECRET)
except DiscourseError as e:
return HTTP400(e.args[0])
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
import hmac
import hashlib
try: # py3
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
def sso_validate(payload, signature, secret):
"""
payload: provided by Discourse HTTP call to your SSO endpoint as sso GET param
signature: provided by Discourse HTTP call to your SSO endpoint as sig GET param
secret: the secret key you entered into Discourse sso secret
return value: The nonce used by discourse to validate the redirect URL
"""
if None in [payload, signature]:
raise DiscourseError("No SSO payload or signature.")
if not secret:
raise DiscourseError("Invalid secret..")
payload = unquote(payload)
if not payload:
raise DiscourseError("Invalid payload..")
decoded = b64decode(payload.encode("utf-8")).decode("utf-8")
if "nonce" not in decoded:
raise DiscourseError("Invalid payload..")
h = hmac.new(
secret.encode("utf-8"), payload.encode("utf-8"), digestmod=hashlib.sha256
)
this_signature = h.hexdigest()
if this_signature != signature:
raise DiscourseError("Payload does not match signature.")
# Discourse returns querystring encoded value. We only need `nonce`
qs = parse_qs(decoded)
return qs["nonce"][0]
def sso_payload(secret, **kwargs):
return_payload = b64encode(urlencode(kwargs).encode("utf-8"))
h = hmac.new(secret.encode("utf-8"), return_payload, digestmod=hashlib.sha256)
query_string = urlencode({"sso": return_payload, "sig": h.hexdigest()})
return query_string
def sso_redirect_url(nonce, secret, email, external_id, username, **kwargs):
"""
nonce: returned by sso_validate()
secret: the secret key you entered into Discourse sso secret
user_email: email address of the user who logged in
user_id: the internal id of the logged in user
user_username: username of the logged in user
return value: URL to redirect users back to discourse, now logged in as user_username
"""
kwargs.update(
{
"nonce": nonce,
"email": email,
"external_id": external_id,
"username": username,
}
)
return "/session/sso_login?%s" % sso_payload(secret, **kwargs)
+3
View File
@@ -0,0 +1,3 @@
requests
nose
mock
-2
View File
@@ -1,2 +0,0 @@
[wheel]
universal = 1
+28 -26
View File
@@ -1,50 +1,52 @@
import codecs
import os
from setuptools import setup, find_packages
README = open('README.rst').read()
HISTORY = open('HISTORY.rst').read().replace('.. :changelog:', '')
def read(fname):
return codecs.open(os.path.join(os.path.dirname(__file__), fname)).read()
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("\"", "")
# Provided as an attribute, so you can append to these instead
# of replicating them:
standard_exclude = ["*.py", "*.pyc", "*$py.class", "*~", ".*", "*.bak"]
standard_exclude_directories = [
".*", "CVS", "_darcs", "./build", "./dist", "EGG-INFO", "*.egg-info"
]
NAME = "pydiscourse"
DESCRIPTION = "A Python library for the Discourse API"
AUTHOR = "Marc Sibson"
AUTHOR_EMAIL = "sibson@gmail.com"
URL = "https://github.com/tindie/pydiscourse"
PACKAGE = "pydiscourse"
VERSION = __import__(PACKAGE).__version__
setup(
name="pydiscourse",
name=NAME,
version=VERSION,
description="A Python library for the Discourse API",
long_description=README + '\n\n' + HISTORY,
author="Marc Sibson and contributors",
author_email="ben+pydiscourse@benlopatin.com",
description=DESCRIPTION,
long_description=read("README.md"),
author=AUTHOR,
author_email=AUTHOR_EMAIL,
license="BSD",
url="https://github.com/bennylope/pydiscourse",
url=URL,
packages=find_packages(exclude=["tests.*", "tests"]),
install_requires=[
'requests>=2.4.2',
],
tests_require=[
'mock',
],
test_suite='tests',
entry_points={
'console_scripts': [
'pydiscoursecli = pydiscourse.main:main'
]
},
classifiers=[
"Development Status :: 5 - Production/Stable",
"Development Status :: 3 - Alpha",
"Environment :: Web Environment",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
'Programming Language :: Python :: Implementation :: PyPy',
],
zip_safe=False,
)
View File
+37 -138
View File
@@ -1,43 +1,19 @@
import sys
import unittest
try:
from unittest import mock
except ImportError:
import mock
import mock
from pydiscourse import client
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
request.return_value = mock.MagicMock(
headers={"content-type": "application/json; charset=utf-8"}
)
request.return_value = mock.MagicMock(headers={'content-type': 'application/json; charset=utf-8'})
class ClientBaseTestCase(unittest.TestCase):
"""
"""
def setUp(self):
self.host = "http://testhost"
self.api_username = "testuser"
self.api_key = "testkey"
self.host = 'testhost'
self.api_username = 'testuser'
self.api_key = 'testkey'
self.client = client.DiscourseClient(self.host, self.api_username, self.api_key)
@@ -49,166 +25,89 @@ class ClientBaseTestCase(unittest.TestCase):
self.assertEqual(args[0], verb)
self.assertEqual(args[1], self.host + url)
headers = kwargs["headers"]
self.assertEqual(headers.pop("Api-Username"), self.api_username)
self.assertEqual(headers.pop("Api-Key"), self.api_key)
if verb == "GET":
self.assertEqual(kwargs["params"], params)
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)
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")
@mock.patch('requests.request')
class TestUser(ClientBaseTestCase):
def test_user(self, request):
prepare_response(request)
self.client.user("someuser")
self.assertRequestCalled(request, "GET", "/users/someuser.json")
self.client.user('someuser')
self.assertRequestCalled(request, 'GET', '/users/someuser.json')
def test_create_user(self, request):
prepare_response(request)
self.client.create_user(
"Test User", "testuser", "test@example.com", "notapassword"
)
self.client.create_user('Test User', 'testuser', 'test@example.com', 'notapassword')
self.assertEqual(request.call_count, 2)
# XXX incomplete
# XXX incomplete
def test_update_email(self, request):
prepare_response(request)
email = "test@example.com"
self.client.update_email("someuser", email)
self.assertRequestCalled(
request, "PUT", "/users/someuser/preferences/email", email=email
)
email = 'test@example.com'
self.client.update_email('someuser', email)
self.assertRequestCalled(request, 'PUT', '/users/someuser/preferences/email', email=email)
def test_update_user(self, request):
prepare_response(request)
self.client.update_user("someuser", a="a", b="b")
self.assertRequestCalled(request, "PUT", "/users/someuser", a="a", b="b")
self.client.update_user('someuser', a='a', b='b')
self.assertRequestCalled(request, 'PUT', '/users/someuser', a='a', b='b')
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"
)
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")
)
self.client.update_username('someuser', 'newname')
self.assertRequestCalled(request, 'PUT', '/users/someuser/preferences/username', username='newname')
@mock.patch("requests.request")
@mock.patch('requests.request')
class TestTopics(ClientBaseTestCase):
def test_hot_topics(self, request):
prepare_response(request)
self.client.hot_topics()
self.assertRequestCalled(request, "GET", "/hot.json")
self.assertRequestCalled(request, 'GET', '/hot.json')
def test_latest_topics(self, request):
prepare_response(request)
self.client.latest_topics()
self.assertRequestCalled(request, "GET", "/latest.json")
self.assertRequestCalled(request, 'GET', '/latest.json')
def test_new_topics(self, request):
prepare_response(request)
self.client.new_topics()
self.assertRequestCalled(request, "GET", "/new.json")
self.assertRequestCalled(request, 'GET', '/new.json')
def test_topic(self, request):
prepare_response(request)
self.client.topic("some-test-slug", 22)
self.assertRequestCalled(request, "GET", "/t/some-test-slug/22.json")
self.client.topic(22)
self.assertRequestCalled(request, 'GET', '/t/22.json')
def test_topics_by(self, request):
prepare_response(request)
r = self.client.topics_by("someuser")
self.assertRequestCalled(request, "GET", "/topics/created-by/someuser.json")
self.assertEqual(r, request().json()["topic_list"]["topics"])
r = self.client.topics_by('someuser')
self.assertRequestCalled(request, 'GET', '/topics/created-by/someuser.json')
self.assertEqual(r, request().json()['topic_list']['topics'])
def invite_user_to_topic(self, request):
prepare_response(request)
email = "test@example.com"
email = 'test@example.com'
self.client.invite_user_to_topic(email, 22)
self.assertRequestCalled(
request, "POST", "/t/22/invite.json", email=email, topic_id=22
)
self.assertRequestCalled(request, 'POST', '/t/22/invite.json', email=email, topic_id=22)
@mock.patch("pydiscourse.client.requests.request")
@mock.patch('requests.request')
class MiscellaneousTests(ClientBaseTestCase):
def test_search(self, request):
prepare_response(request)
self.client.search("needle")
self.assertRequestCalled(request, "GET", "/search.json", term="needle")
self.client.search('needle')
self.assertRequestCalled(request, 'GET', '/search.json', term='needle')
def test_categories(self, request):
prepare_response(request)
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)
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
)
self.assertRequestCalled(request, 'GET', '/categories.json')
self.assertEqual(r, request().json()['category_list']['categories'])
-85
View File
@@ -1,85 +0,0 @@
from base64 import b64decode
try: # py26
import unittest2 as unittest
except ImportError:
import unittest
try: # py3
from urllib.parse import unquote
from urllib.parse import urlparse, parse_qs
except ImportError:
from urlparse import urlparse, parse_qs
from urllib import unquote
from pydiscourse import sso
from pydiscourse.exceptions import DiscourseError
class SSOTestCase(unittest.TestCase):
def setUp(self):
# values from https://meta.discourse.org/t/official-single-sign-on-for-discourse/13045
self.secret = "d836444a9e4084d5b224a60c208dce14"
self.nonce = "cb68251eefb5211e58c00ff1395f0c0b"
self.payload = "bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGI%3D%0A"
self.signature = "2828aa29899722b35a2f191d34ef9b3ce695e0e6eeec47deb46d588d70c7cb56"
self.name = u"sam"
self.username = u"samsam"
self.external_id = u"hello123"
self.email = u"test@test.com"
self.redirect_url = u"/session/sso_login?sso=bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGImbmFtZT1z%0AYW0mdXNlcm5hbWU9c2Ftc2FtJmVtYWlsPXRlc3QlNDB0ZXN0LmNvbSZleHRl%0Acm5hbF9pZD1oZWxsbzEyMw%3D%3D%0A&sig=1c884222282f3feacd76802a9dd94e8bc8deba5d619b292bed75d63eb3152c0b"
def test_missing_args(self):
with self.assertRaises(DiscourseError):
sso.sso_validate(None, self.signature, self.secret)
with self.assertRaises(DiscourseError):
sso.sso_validate("", self.signature, self.secret)
with self.assertRaises(DiscourseError):
sso.sso_validate(self.payload, None, self.secret)
def test_invalid_signature(self):
with self.assertRaises(DiscourseError):
sso.sso_validate(self.payload, "notavalidsignature", self.secret)
def test_valid_nonce(self):
nonce = sso.sso_validate(self.payload, self.signature, self.secret)
self.assertEqual(nonce, self.nonce)
def test_valid_redirect_url(self):
url = sso.sso_redirect_url(
self.nonce,
self.secret,
self.email,
self.external_id,
self.username,
name="sam",
)
self.assertIn("/session/sso_login", url[:20])
# check its valid, using our own handy validator
params = parse_qs(urlparse(url).query)
payload = params["sso"][0]
sso.sso_validate(payload, params["sig"][0], self.secret)
# check the params have all the data we expect
payload = b64decode(payload.encode("utf-8")).decode("utf-8")
payload = unquote(payload)
payload = dict((p.split("=") for p in payload.split("&")))
self.assertEqual(
payload,
{
"username": self.username,
"nonce": self.nonce,
"external_id": self.external_id,
"name": self.name,
"email": self.email,
},
)
-21
View File
@@ -1,21 +0,0 @@
[tox]
envlist = py27, py34, py35, py36, py37, pypy, pypy3
[testenv]
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