Compare commits

..

1 Commits

Author SHA1 Message Date
Marc Sibson
dbc417207f add post update 2014-06-23 22:25:19 -07:00
40 changed files with 682 additions and 3087 deletions

3
.coveragerc Normal file
View File

@ -0,0 +1,3 @@
[run]
include: pydiscourse/*

View File

@ -1,11 +0,0 @@
c0db7215c95dbd31770ade1fc6ea65aa426d4590
0177c46356b9d0fc4b93f09aab7a224643a3685e
f6b4c02fc0f144dffc88cdd48b8261a69228d2f0
2a3036f0395a810b0941522bfb1ca80b159525ce
c49d29620dfb867f73ebb6be84b5e1ba922fadc9
dc498679cc6769acafe19cf0083f40154ffdcff8
7ab58533b759d1ff879476a5703051b201afd835
fe4f67c04160a76948d810848ae082713ea6b5ed
2aac9a20beb19a6a052286f73f5d0f5bf76ed758
2be1a46c1da497e136818b5ef77708b8c5b69e57
31db8017bc90978b879c5caa7f1cd4777d19a27e

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

View File

@ -1,33 +0,0 @@
name: Publish package to PyPI
on:
push:
branches: [master]
release:
types: [created]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
- name: Build and publish distribution
if: (github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags')) || github.event_name == 'release'
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
python setup.py sdist bdist_wheel
twine upload dist/*

View File

@ -1,47 +0,0 @@
name: Tests
on: [ push, pull_request ]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff
- name: Lint with Ruff
run: ruff .
test:
needs: lint
name: Test on Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
steps:
- uses: actions/checkout@v1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install tox tox-gh-actions
- name: Test with tox
run: tox

6
.gitignore vendored
View File

@ -36,9 +36,3 @@ coverage.xml
# Sphinx documentation
docs/_build/
# Pyenv
.python-version
# PyCharm
.idea

View File

@ -1,19 +0,0 @@
exclude: "docs|.git|.tox"
default_stages: [ commit ]
fail_fast: true
repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: "v0.0.286"
hooks:
- id: ruff
- repo: https://github.com/psf/black
rev: 23.7.0
hooks:
- id: black
args:
- --config=pyproject.toml
- src/pydiscourse
- tests

11
.travis.yml Normal file
View File

@ -0,0 +1,11 @@
language: python
python:
- "2.6"
- "2.7"
install:
- "pip install -r requirements.dev.txt"
- "[[ $TRAVIS_PYTHON_VERSION = '2.6' ]] && pip install unittest2 || echo"
- "pip install ."
script: nosetests

13
AUTHORS
View File

@ -1,15 +1,2 @@
(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
Gustav <https://github.com/dkgv>
Sebastian2023 <https://github.com/Sebastian2023>
Dominik George <https://github.com/Natureshadow>

View File

@ -1,106 +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 changeset 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
=======
Running tests
-------------
The simplest way to quickly and repeatedly run tests while developing a feature or fix
is to use `pytest` in your current Python environment.
After installing the test dependencies::
pip install -r requirements.txt
pip install -e .
Your can run the tests with `pytest`::
pytest --cov=src/pydiscourse
This will ensure you get coverage reporting.
The most comprehensive way to run the tests is with `tox <http://tox.readthedocs.org/en/latest/>`_::
pip install tox
tox
Or it's slightly faster cousin `detox
<https://pypi.python.org/pypi/detox>`_ which will parallelize test runs::
pip install detox
detox
Writing tests
-------------
The primary modules of the library have coverage requirements, so you should
write a test or tests when you add a new feature.
**At a bare minimum a test should show which Discourse API endpoint is called,
using which HTTP method, and returning any necessary data for the new function/method.**
In most cases this can be accomplished quite simply by using the `discourse_request`
fixture, which allows for mocking the HTTP request in the `requests` library. In some cases
this may be insufficient, and you may want to directly use the `requests_mock` mocking
fixture.
If in the course of writing your test you see a `requests_mock.NoMockAddress` exception
raised then either the *method* or the *path* (including querystring) - or both! - in
either your mock OR your new API client method is incorrect.
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
DEVELOP.md Normal file
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"

View File

@ -1,164 +0,0 @@
.. :changelog:
Release history
===============
1.7.0
-----
- Possible breaking change: Change `search()` term paramater from `term` to `q`,
fixes search. Thanks @weber-s
- Add support for Python 3.12
1.6.1
-----
- Adds `posts_by_number` endpoint from @Dettorer
1.6.0
-----
- Breaking: `toggle_gravatar`, `pick_avatar`, `create_group` now *require*
keyword arguments where keyword arguments were used. This *may* break existing
code if you have not referenced these by keyword!
- Introduced `ruff` and `black` into pre-commit hook
- Added `lint` job to GitHub Actions, tests will run if and only if lint job
passes.
- Sundry code cleanup
1.5.0
-----
- Owner creation endpoint update from @akhmerov
- Python 3.11 support from @Dettorer
- Group membership fixes from @inducer
- Rate limiting fixes from @inducer
- Latest posts endpoint from @max-lancaster
1.4.0
-----
- Documented here as skipped release
1.3.0
-----
- Add fix for handling global Discourse timeouts
- Add group owners
- Update API for add_group_owner
1.2.0
-----
- BREAKING? Dropped support for Python 2.7, 3.4, 3.5
- Added numerous new endpoint queries
- Updated category querying
1.1.2
-----
- Fix for Discourse users API change
1.1.1
-----
- Fix for empty dictionary and 413 API response
- Fix for getting member groups
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

View File

@ -1,6 +0,0 @@
include setup.py
include README.rst
include MANIFEST.in
include HISTORY.rst
include LICENSE
recursive-include pydiscourse

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}'

44
README.md Normal file
View File

@ -0,0 +1,44 @@
pydiscourse
------------
A Python library for working with Discourse.
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')
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-username=system latest_topics
pydiscoursecli --host=http://yourhost --api-username=system topics_by johnsmith
pydiscoursecli --host=http://yourhost --api-username=system user eviltrout

View File

@ -1,92 +0,0 @@
===========
pydiscourse
===========
.. image:: https://img.shields.io/pypi/v/pydiscourse?color=blue
:alt: PyPI
:target: https://pypi.org/project/pydiscourse/
.. image:: https://github.com/pydiscourse/pydiscourse/workflows/Tests/badge.svg
:alt: Build Status
:target: https://github.com/pydiscourse/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:
.. code:: python
from pydiscourse import DiscourseClient
client = DiscourseClient(
'http://example.com',
api_username='username',
api_key='areallylongstringfromdiscourse')
Get info about a user:
.. code:: python
user = client.user('eviltrout')
print user
user_topics = client.topics_by('johnsmith')
print user_topics
Create a new user:
.. code:: python
user = client.create_user('The Black Knight', 'blacknight', 'knight@python.org', 'justafleshwound')
Implement SSO for Discourse with your Python server:
.. code:: python
@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:
.. code:: bash
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

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.7'
version = '0.1'
# The full version, including alpha/beta/rc tags.
release = '1.7.0'
release = '0.1'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.

View File

@ -1,5 +1,4 @@
Development
===============
.. include:: ../CONTRIBUTING.rst
:start-line: 3
.. include:: ../DEVELOP.md
:start-line: 2

View File

@ -1,5 +1,4 @@
Introduction
==============
.. include:: ../README.rst
:start-line: 3
.. include:: ../README.md
:start-line: 2

View File

@ -1,7 +0,0 @@
pydiscourse
===========
.. toctree::
:maxdepth: 4
pydiscourse

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
pydiscourse/__init__.py Normal file
View File

@ -0,0 +1 @@
__version__ = '0.1.0.dev'

252
pydiscourse/client.py Executable file
View File

@ -0,0 +1,252 @@
#!/usr/bin/env python
import logging
import requests
from pydiscourse.exceptions import DiscourseError, DiscourseServerError, DiscourseClientError
log = logging.getLogger('pydiscourse.client')
class DiscourseClient(object):
""" A basic client for the Discourse API that implements the raw API
This class will attempt to remain roughly similar to the discourse_api rails API
"""
def __init__(self, host, api_username, api_key, timeout=None):
self.host = host
self.api_username = api_username
self.api_key = api_key
self.timeout = timeout
def user(self, username):
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
"""
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)
def trust_level(self, userid, level):
return self._put('/admin/users/{0}/trust_level'.format(userid), level=level)
def update_avatar_from_url(self, username, url, **kwargs):
return self._post('/users/{0}/preferences/avatar'.format(username), file=url, **kwargs)
def update_avatar_image(self, username, img, **kwargs):
files = {'file': img}
return self._post('/users/{0}/preferences/avatar'.format(username), files=files, **kwargs)
def toggle_gravatar(self, username, state=True, **kwargs):
url = '/users/{0}/preferences/avatar/toggle'.format(username)
if bool(state):
kwargs['use_uploaded_avatar'] = 'true'
else:
kwargs['use_uploaded_avatar'] = 'false'
return self._put(url, **kwargs)
def update_email(self, username, email, **kwargs):
return self._put('/users/{0}/preferences/email'.format(username), email=email, **kwargs)
def update_user(self, username, **kwargs):
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)
def set_preference(self, username=None, **kwargs):
if username is None:
username = self.api_username
return self._put(u'/users/{0}'.format(username), **kwargs)
def generate_api_key(self, userid, **kwargs):
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'
"""
return self._delete('/admin/users/{0}.json'.format(userid), **kwargs)
def private_messages(self, username=None, **kwargs):
if username is None:
username = self.api_username
return self._get('/topics/private-messages/{0}.json'.format(username), **kwargs)
def hot_topics(self, **kwargs):
return self._get('/hot.json', **kwargs)
def latest_topics(self, **kwargs):
return self._get('/latest.json', **kwargs)
def new_topics(self, **kwargs):
return self._get('/new.json', **kwargs)
def topic(self, topic_id, **kwargs):
return self._get('/t/{0}.json'.format(topic_id), **kwargs)
def post(self, topic_id, post_id, **kwargs):
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
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 }
A side effect of this is to mark the post as read
"""
kwargs['topic_id'] = topic_id
kwargs['topic_time'] = time
for post_num, timing in timings.items():
kwargs['timings[{0}]'.format(post_num)] = timing
return self._post('/topics/timings', **kwargs)
def topic_posts(self, topic_id, **kwargs):
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
"""
return self._post('/posts', raw=content, **kwargs)
def update_post(self, post_id, content, edit_reason='', **kwargs):
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):
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):
kwargs = {
'email': user_email,
'topic_id': topic_id,
}
return self._post('/t/{0}/invite.json'.format(topic_id), **kwargs)
def search(self, term, **kwargs):
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
"""
kwargs['name'] = name
kwargs['color'] = color
kwargs['text_color'] = text_color
if permissions is None and 'permissions' not in kwargs:
permissions = {'everyone': '1'}
for key, value in permissions.items():
kwargs['permissions[{0}]'.format(key)] = value
if parent:
parent_id = None
for category in self.categories():
if category['name'] == parent:
parent_id = category['id']
continue
if not parent_id:
raise DiscourseClientError(u'{0} not found'.format(parent))
kwargs['parent_category_id'] = parent_id
return self._post('/categories', **kwargs)
def categories(self, **kwargs):
return self._get('/categories.json', **kwargs)['category_list']['categories']
def category(self, name, parent=None, **kwargs):
if parent:
name = u'{0}/{1}'.format(parent, name)
return self._get(u'/category/{0}.json'.format(name), **kwargs)
def site_settings(self, **kwargs):
for setting, value in kwargs.items():
setting = setting.replace(' ', '_')
self._request('PUT', '/admin/site_settings/{0}'.format(setting), {setting: value})
def _get(self, path, **kwargs):
return self._request('GET', path, kwargs)
def _put(self, path, **kwargs):
return self._request('PUT', path, kwargs)
def _post(self, path, **kwargs):
return self._request('POST', path, kwargs)
def _delete(self, path, **kwargs):
return self._request('DELETE', path, kwargs)
def _request(self, verb, path, params):
params['api_key'] = self.api_key
if 'api_username' not in params:
params['api_username'] = self.api_username
url = self.host + path
response = requests.request(verb, url, allow_redirects=False, params=params, timeout=self.timeout)
log.debug('response %s: %s', response.status_code, repr(response.text))
if not response.ok:
if response.reason:
msg = response.reason
else:
try:
msg = u','.join(response.json()['errors'])
except (ValueError, TypeError, KeyError):
msg = u'{0}: {1}'.format(response.status_code, response.text)
if 400 <= response.status_code < 500:
raise DiscourseClientError(msg, response=response)
raise DiscourseServerError(msg, response=response)
if response.status_code == 302:
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 == ' ':
return None
raise DiscourseError('Invalid Response, expecting "{0}" got "{1}"'.format(
json_content, content_type), response=response)
try:
decoded = response.json()
except ValueError:
raise DiscourseError('failed to decode response', response=response)
if 'errors' in decoded:
message = decoded.get('message')
if not message:
message = u','.join(decoded['errors'])
raise DiscourseError(message, response=response)
return decoded

13
pydiscourse/exceptions.py Normal file
View File

@ -0,0 +1,13 @@
from requests.exceptions import HTTPError
class DiscourseError(HTTPError):
""" A generic error while attempting to communicate with Discourse """
class DiscourseServerError(DiscourseError):
""" The Discourse Server encountered an error while processing the request """
class DiscourseClientError(DiscourseError):
""" An invalid request has been made """

View File

@ -1,49 +1,40 @@
#!/usr/bin/env python
"""Simple command line interface for making Discourse API queries."""
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):
"""Handles CLI commands"""
prompt = "discourse>"
prompt = 'discourse>'
output = sys.stdout
def __init__(self, client):
"""Initialize command"""
cmd.Cmd.__init__(self)
self.client = client
self.prompt = "%s>" % self.client.host
self.prompt = '%s>' % self.client.host
def __getattr__(self, attr):
"""Gets attributes with dynamic name handling"""
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:
sys.stderr.write(f"{e}, {e.response.text}\n")
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():
@ -54,34 +45,20 @@ class DiscourseCmd(cmd.Cmd):
raise AttributeError
def postcmd(self, result, line):
"""Writes output of the command to console"""
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():
"""Runs the CLI application"""
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:
@ -90,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()

89
pydiscourse/sso.py Normal file
View File

@ -0,0 +1,89 @@
"""
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)
"""
import base64
import hmac
import hashlib
try: # py3
from urllib.parse import unquote, urlencode
except ImportError:
from urllib import unquote, urlencode
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 = base64.decodestring(payload)
if 'nonce' not in decoded:
raise DiscourseError('Invalid payload..')
h = hmac.new(secret, payload, digestmod=hashlib.sha256)
this_signature = h.hexdigest()
if this_signature != signature:
raise DiscourseError('Payload does not match signature.')
nonce = decoded.split('=')[1]
return nonce
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_payload = base64.encodestring(urlencode(kwargs))
h = hmac.new(secret, return_payload, digestmod=hashlib.sha256)
query_string = urlencode({'sso': return_payload, 'sig': h.hexdigest()})
return '/session/sso_login?%s' % query_string

View File

@ -1,49 +0,0 @@
[tool.black]
line-length=120
target-version = ["py311"]
[tool.ruff]
exclude = [
".tox",
".git",
"build",
"dist",
"docs",
".ropeproject",
]
ignore = [
"S101", # Assertions good, actually
"TRY003", # For now not worth back tracking
]
line-length = 240
select = [
"S", # Security (formerly B when using Bandit directly)
"E",
"F",
"N",
"W",
"COM", # commas
"PT", # pytest
"UP", # Upgrade Python syntax
"T",
"A", # built-in shadowing
"FBT", # Boolean traps
"BLE", # Blind exceptions
"PIE",
"TRY",
"ERA", # eradicate commented out code
]
[tool.ruff.flake8-pytest-style]
fixture-parentheses = false
mark-parentheses = true
parametrize-names-type = "tuple"
parametrize-values-row-type = "tuple"
[tool.ruff.mccabe]
# Unlike Flake8, default to a complexity level of 10.
max-complexity = 10
[tool.coverage.run]
include = ["src/pydiscourse/*"]

3
requirements.dev.txt Normal file
View File

@ -0,0 +1,3 @@
-r requirements.txt
nose
mock

View File

@ -1,9 +1 @@
pre-commit==3.3.3
ruff==0.0.286
pytest==7.4.0
pytest-cov==4.1.0
pytest-mock==3.11.1 # https://github.com/pytest-dev/pytest-mock/
pytest-socket==0.6.0 # https://github.com/miketheman/pytest-socket
requests-mock==1.11.0 # https://github.com/jamielennox/requests-mock
pytest-subtests==0.11.0 # https://github.com/pytest-dev/pytest-subtests
pytest-icdiff==0.6 # https://pypi.org/project/pytest-icdiff/
requests

View File

@ -1,47 +0,0 @@
[metadata]
name = pydiscourse
version = attr: pydiscourse.__version__
author = "Marc Sibson and contributors"
author_email = "ben@benlopatin.com"
license = "MIT"
url = https://github.com/bennylope/pydiscourse
description = "A Python library for the Discourse API"
long_description = file: README.rst, HISTORY.rst
platforms =
OS Independent
[options]
zip_safe = False
include_package_data = True
packages = find:
package_dir =
=src
install_requires =
requests>=2.4.2
classifiers =
Development Status :: 5 - Production/Stable
Environment :: Web Environment
Intended Audience :: Developers
License :: OSI Approved :: MIT License
Operating System :: OS Independent
Programming Language :: Python
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Programming Language :: Python :: 3.12
[options.packages.find]
where=src
[options.entry_points]
console_scripts =
pydiscoursecli = pydiscourse.main:main
[bdist_wheel]
universal = 1
[build-system]
requires =
setuptools >= "40.9.0"
wheel

View File

@ -1,6 +1,54 @@
"""
See setup.cfg for packaging settings
"""
import codecs
import os
from setuptools import setup
setup()
from setuptools import setup, find_packages
def read(fname):
return codecs.open(os.path.join(os.path.dirname(__file__), fname), 'rt').read()
# 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=NAME,
version=VERSION,
description=DESCRIPTION,
long_description=read("README.md"),
author=AUTHOR,
author_email=AUTHOR_EMAIL,
license="BSD",
url=URL,
packages=find_packages(exclude=["tests.*", "tests"]),
entry_points={
'console_scripts': [
'pydiscoursecli = pydiscourse.main:main'
]
},
classifiers=[
"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.6",
"Programming Language :: Python :: 2.7",
],
zip_safe=False,
)

View File

@ -1,8 +0,0 @@
"""Python client for the Discourse API."""
__version__ = "1.7.0"
from pydiscourse.client import DiscourseClient
__all__ = ["DiscourseClient"]

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +0,0 @@
"""API exceptions."""
from requests.exceptions import HTTPError
class DiscourseError(HTTPError):
"""A generic error while attempting to communicate with Discourse"""
class DiscourseServerError(DiscourseError):
"""The Discourse Server encountered an error while processing the request"""
class DiscourseClientError(DiscourseError):
"""An invalid request has been made"""
class DiscourseRateLimitedError(DiscourseError):
"""Request required more than the permissible number of retries"""

View File

@ -1,100 +0,0 @@
"""Implement Single Sign On for Discourse with a Python managed auth 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
from urllib.parse import unquote, urlencode, parse_qs
from pydiscourse.exceptions import DiscourseError
def sso_validate(payload, signature, secret):
"""Validates SSO payload.
Args:
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):
"""Returns an encoded SSO payload"""
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):
"""Returns the Discourse redirection URL.
Args:
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)

View File

View File

@ -1,136 +0,0 @@
"""Test fixtures."""
import datetime
import pytest
from pydiscourse import client
@pytest.fixture(scope="session")
def sso_secret():
return "d836444a9e4084d5b224a60c208dce14"
@pytest.fixture(scope="session")
def sso_nonce():
return "cb68251eefb5211e58c00ff1395f0c0b"
@pytest.fixture(scope="session")
def sso_payload():
return "bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGI%3D%0A"
@pytest.fixture(scope="session")
def sso_signature():
return "2828aa29899722b35a2f191d34ef9b3ce695e0e6eeec47deb46d588d70c7cb56"
@pytest.fixture(scope="session")
def name():
return "sam"
@pytest.fixture(scope="session")
def username():
return "samsam"
@pytest.fixture(scope="session")
def external_id():
return "hello123"
@pytest.fixture(scope="session")
def email():
return "test@test.com"
@pytest.fixture(scope="session")
def redirect_url(sso_payload):
return f"/session/sso_login?sso={sso_payload}YW0mdXNlcm5hbWU9c2Ftc2FtJmVtYWlsPXRlc3QlNDB0ZXN0LmNvbSZleHRl%0Acm5hbF9pZD1oZWxsbzEyMw%3D%3D%0A&sig=1c884222282f3feacd76802a9dd94e8bc8deba5d619b292bed75d63eb3152c0b"
@pytest.fixture(scope="session")
def discourse_host():
return "http://testhost"
@pytest.fixture(scope="session")
def discourse_api_username():
return "testuser"
@pytest.fixture(scope="session")
def discourse_api_key():
return "testkey"
@pytest.fixture(scope="session")
def discourse_client(discourse_host, discourse_api_username, discourse_api_key):
return client.DiscourseClient(
discourse_host,
discourse_api_username,
discourse_api_key,
)
@pytest.fixture
def _frozen_time(mocker):
now = mocker.patch("pydiscourse.client.now")
now.return_value = datetime.datetime(
2023,
8,
13,
12,
30,
15,
tzinfo=datetime.timezone.utc,
)
@pytest.fixture
def discourse_request(discourse_host, discourse_client, requests_mock):
"""Fixture for mocking Discourse API requests.
The only request arguments are the method and the path.
Example:
>>> def test_something(discourse_request):
>>> request = discourse_request(
>>> "put", # the method, case-insensitive
>>> "/the-path.json?q=4", # the absolute path with query, NO host
>>> headers={'content-type': 'text/plain'}, # override default headers
>>> content=b"ERROR", # override bytestring response
>>> )
If `content` is provided, that will be used as the response body.
If `json` is provided, then the body will return the given JSON-
compatable Python structure (e.g. dictionary).
If neither is given then the return `json` will be an empty
dictionary (`{}`).
Returns a function for inserting sensible default values.
"""
def inner(method, path, headers=None, json=None, content=None):
full_path = f"{discourse_host}{path}"
if not headers:
headers = {
"Content-Type": "application/json; charset=utf-8",
"Api-Key": discourse_client.api_key,
"Api-Username": discourse_client.api_username,
}
kwargs = {}
if content:
kwargs["content"] = content
elif json:
kwargs["json"] = json
else:
kwargs["json"] = {}
return requests_mock.request(method, full_path, headers=headers, **kwargs)
return inner

View File

@ -1,220 +1,113 @@
"""Tests for the client methods."""
import unittest
import mock
import urllib.parse
import pytest
from pydiscourse import client
def test_empty_content_http_ok(discourse_host, discourse_client, discourse_request):
"""Empty content should not raise error
Critical to test against *bytestrings* rather than unicode
"""
discourse_request(
"get",
"/users/admin/1/unsuspend",
headers={"Content-Type": "text/plain; charset=utf-8"},
content=b" ",
)
resp = discourse_client._request("GET", "/users/admin/1/unsuspend", {})
assert resp is None
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'})
class TestUserManagement:
def test_get_user(self, discourse_host, discourse_client, discourse_request):
request = discourse_request(
"get",
"/users/someuser.json",
json={"user": "someuser"},
)
discourse_client.user("someuser")
class ClientBaseTestCase(unittest.TestCase):
def setUp(self):
self.host = 'testhost'
self.api_username = 'testuser'
self.api_key = 'testkey'
assert request.called_once
self.client = client.DiscourseClient(self.host, self.api_username, self.api_key)
def test_users(self, discourse_client, discourse_request):
request = discourse_request("get", "/admin/users/list/active.json")
discourse_client.users()
assert request.called_once
def assertRequestCalled(self, request, verb, url, **params):
self.assertTrue(request.called)
def test_create_user(self, discourse_host, discourse_client, discourse_request):
session_request = discourse_request(
"get",
"/session/hp.json",
json={"challenge": "challenge", "value": "value"},
)
user_request = discourse_request("post", "/users")
discourse_client.create_user(
"Test User",
"testuser",
"test@example.com",
"notapassword",
)
args, kwargs = request.call_args
assert session_request.called_once
assert user_request.called_once
self.assertEqual(args[0], verb)
self.assertEqual(args[1], self.host + url)
def test_update_email(self, discourse_host, discourse_client, discourse_request):
request = discourse_request("put", "/users/someuser/preferences/email")
discourse_client.update_email("someuser", "newmeail@example.com")
assert request.called_once
def test_update_user(self, discourse_client, discourse_request):
request = discourse_request("put", "/users/someuser")
discourse_client.update_user("someuser", a="a", b="b")
assert request.called_once
def test_update_username(self, discourse_client, discourse_request):
request = discourse_request("put", "/users/someuser/preferences/username")
discourse_client.update_username("someuser", "newname")
assert request.called_once
def test_by_external_id(self, discourse_client, discourse_request):
request = discourse_request(
"get",
"/users/by-external/123",
json={"user": "123"},
)
discourse_client.by_external_id(123)
assert request.called_once
def test_anonymize(self, discourse_client, discourse_request):
request = discourse_request("put", "/admin/users/123/anonymize")
discourse_client.anonymize(123)
assert request.called_once
@pytest.mark.usefixtures("_frozen_time")
def test_suspend_user(self, discourse_client, discourse_request):
request = discourse_request("put", "/admin/users/123/suspend")
discourse_client.suspend(123, 1, "Testing")
assert request.called_once
assert request.last_request.method == "PUT"
request_payload = urllib.parse.parse_qs(request.last_request.text)
assert request_payload["reason"] == ["Testing"]
assert request_payload["suspend_until"] == ["2023-08-14T12:30:15+00:00"]
def test_unsuspend_user(self, discourse_client, discourse_request):
request = discourse_request("put", "/admin/users/123/unsuspend")
discourse_client.unsuspend(123)
assert request.called_once
def test_user_bagdes(self, discourse_client, discourse_request):
request = discourse_request("get", "/user-badges/myusername.json")
discourse_client.user_badges("myusername")
assert request.called_once
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 TestTopics:
def test_hot_topics(self, discourse_client, discourse_request):
request = discourse_request("get", "/hot.json")
discourse_client.hot_topics()
assert request.called_once
@mock.patch('requests.request')
class TestUser(ClientBaseTestCase):
def test_latest_topics(self, discourse_client, discourse_request):
request = discourse_request("get", "/latest.json")
discourse_client.latest_topics()
def test_user(self, request):
prepare_response(request)
self.client.user('someuser')
self.assertRequestCalled(request, 'GET', '/users/someuser.json')
assert request.called_once
def test_create_user(self, request):
prepare_response(request)
self.client.create_user('Test User', 'testuser', 'test@example.com', 'notapassword')
self.assertEqual(request.call_count, 2)
# XXX incomplete
def test_new_topics(self, discourse_client, discourse_request):
request = discourse_request("get", "/new.json")
discourse_client.new_topics()
assert request.called_once
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)
def test_topic(self, discourse_client, discourse_request):
request = discourse_request("get", "/t/some-test-slug/22.json")
discourse_client.topic("some-test-slug", 22)
assert request.called_once
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')
def test_topics_by(self, discourse_client, discourse_request):
request = discourse_request(
"get",
"/topics/created-by/someuser.json",
json={"topic_list": {"topics": []}},
)
discourse_client.topics_by("someuser")
assert request.called_once
def test_invite_user_to_topic(self, discourse_client, discourse_request):
request = discourse_request("post", "/t/22/invite.json")
discourse_client.invite_user_to_topic("test@example.com", 22)
assert request.called_once
request_payload = urllib.parse.parse_qs(request.last_request.text)
assert request_payload["email"] == ["test@example.com"]
assert request_payload["topic_id"] == ["22"]
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')
class TestPosts:
def test_latest_posts(self, discourse_client, discourse_request):
request = discourse_request("get", "/posts.json?before=54321")
discourse_client.latest_posts(before=54321)
assert request.called_once
@mock.patch('requests.request')
class TestTopics(ClientBaseTestCase):
def test_post_by_number(self, discourse_client, discourse_request):
request = discourse_request("get", "/posts/by_number/8796/5")
discourse_client.post_by_number(8796, 5)
assert request.called_once
def test_hot_topics(self, request):
prepare_response(request)
self.client.hot_topics()
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')
def test_new_topics(self, request):
prepare_response(request)
self.client.new_topics()
self.assertRequestCalled(request, 'GET', '/new.json')
def test_topic(self, request):
prepare_response(request)
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'])
def invite_user_to_topic(self, request):
prepare_response(request)
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)
class TestSearch:
def test_search(self, discourse_client, discourse_request):
request = discourse_request("get", "/search.json?q=needle")
discourse_client.search(q="needle")
assert request.called_once
@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')
class TestCategories:
def test_categories(self, discourse_client, discourse_request):
request = discourse_request(
"get",
"/categories.json",
json={"category_list": {"categories": []}},
)
discourse_client.categories()
assert request.called_once
def test_update_category(self, discourse_client, discourse_request):
request = discourse_request("put", "/categories/123")
discourse_client.update_category(123, a="a", b="b")
request_payload = request.last_request.json()
assert request_payload["a"] == "a"
assert request_payload["b"] == "b"
class TestBadges:
def test_badges(self, discourse_client, discourse_request):
request = discourse_request("get", "/admin/badges.json")
discourse_client.badges()
assert request.called_once
def test_grant_badge_to(self, discourse_client, discourse_request):
request = discourse_request("post", "/user_badges")
discourse_client.grant_badge_to("username", 1)
request_payload = urllib.parse.parse_qs(request.last_request.text)
assert request_payload["username"] == ["username"]
assert request_payload["badge_id"] == ["1"]
class TestAbout:
def test_about(self, discourse_client, discourse_request):
request = discourse_request("get", "/about.json")
discourse_client.about()
assert request.called_once
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'])

View File

@ -1,95 +1,78 @@
from base64 import b64decode
from urllib.parse import unquote
from urllib.parse import urlparse, parse_qs
import base64
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
import pytest
from pydiscourse import sso
from pydiscourse.exceptions import DiscourseError
def test_sso_validate_missing_payload():
with pytest.raises(DiscourseError) as excinfo:
sso.sso_validate(None, "abc", "123")
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'
assert excinfo.value.args[0] == "No SSO payload or signature."
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_sso_validate_empty_payload():
with pytest.raises(DiscourseError) as excinfo:
sso.sso_validate("", "abc", "123")
class Test_sso_validate(SSOTestCase):
def test_missing_args(self):
with self.assertRaises(DiscourseError):
sso.sso_validate(None, self.signature, self.secret)
assert excinfo.value.args[0] == "Invalid payload."
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_sso_validate_missing_signature():
with pytest.raises(DiscourseError) as excinfo:
sso.sso_validate("sig", None, "123")
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')
assert excinfo.value.args[0] == "No SSO payload or signature."
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)
@pytest.mark.parametrize("bad_secret", [None, ""])
def test_sso_validate_missing_secret(bad_secret):
with pytest.raises(DiscourseError) as excinfo:
sso.sso_validate("payload", "signature", bad_secret)
# check the params have all the data we expect
payload = base64.decodestring(payload)
payload = unquote(payload)
payload = dict((p.split('=') for p in payload.split('&')))
assert excinfo.value.args[0] == "Invalid secret."
def test_sso_validate_invalid_signature(sso_payload, sso_signature, sso_secret):
with pytest.raises(DiscourseError) as excinfo:
sso.sso_validate("Ym9i", sso_signature, sso_secret)
assert excinfo.value.args[0] == "Invalid payload."
def test_sso_validate_invalid_payload_nonce(sso_payload, sso_secret):
with pytest.raises(DiscourseError) as excinfo:
sso.sso_validate(sso_payload, "notavalidsignature", sso_secret)
assert excinfo.value.args[0] == "Payload does not match signature."
def test_valid_nonce(sso_payload, sso_signature, sso_secret, sso_nonce):
generated_nonce = sso.sso_validate(sso_payload, sso_signature, sso_secret)
assert generated_nonce == sso_nonce
def test_valid_redirect_url(
sso_secret,
sso_nonce,
name,
email,
username,
external_id,
redirect_url,
):
url = sso.sso_redirect_url(
sso_nonce,
sso_secret,
email,
external_id,
username,
name="sam",
)
assert "/session/sso_login" in 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], sso_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("&"))
assert payload == {
"username": username,
"nonce": sso_nonce,
"external_id": external_id,
"name": name,
"email": email,
}
self.assertEqual(payload, {
'username': self.username,
'nonce': self.nonce,
'external_id': self.external_id,
'name': self.name,
'email': self.email
})

29
tox.ini
View File

@ -1,28 +1,11 @@
[tox]
envlist = py38, py39, py310, py311
[gh-actions]
python =
3.8: py38
3.9: py39
3.10: py310
3.11: py311
3.12: py312
envlist = py26, py27, py34
[testenv]
setenv =
PYTHONPATH = {toxinidir}:{toxinidir}/pydiscourse
commands =
pytest {posargs} --cov=pydiscourse
coverage report -m --include='**/pydiscourse/client.py' --fail-under=46
coverage report -m --include='**/pydiscourse/sso.py' --fail-under=100
deps =
-r{toxinidir}/requirements.txt
deps=-rrequirements.dev.txt
commands=nosetests
[testenv:ruff]
basepython=python
skip_install=true
[testenv:py26]
deps=
ruff
commands=
ruff .
-rrequirements.dev.txt
unittest2