Compare commits

..

1 Commits

Author SHA1 Message Date
Ben Lopatin 5dd1dab051 Run test workflow on pull request 2019-10-13 18:15:51 -04:00
23 changed files with 431 additions and 963 deletions
-3
View File
@@ -1,3 +0,0 @@
c0db7215c95dbd31770ade1fc6ea65aa426d4590
0177c46356b9d0fc4b93f09aab7a224643a3685e
f6b4c02fc0f144dffc88cdd48b8261a69228d2f0
-33
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/*
+12 -14
View File
@@ -1,25 +1,23 @@
name: Tests
on: [ push, pull_request ]
on: [push, pull_request]
jobs:
test:
name: Test on Python ${{ matrix.python-version }}
name: Test on Python ${{ matrix.py_version }}
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ "3.8", "3.9", "3.10", "3.11" ]
py_version: [2.7, 3.5, 3.6, 3.7]
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
- 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
-6
View File
@@ -36,9 +36,3 @@ coverage.xml
# Sphinx documentation
docs/_build/
# Pyenv
.python-version
# PyCharm
.idea
+11
View File
@@ -0,0 +1,11 @@
sudo: false
language: python
python:
- "2.7"
- "3.4"
- "3.5"
- "3.6"
- "pypy"
- "pypy3"
script: python setup.py test
+1 -4
View File
@@ -9,7 +9,4 @@ 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>
+8 -38
View File
@@ -19,9 +19,9 @@ 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 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 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.
@@ -30,27 +30,10 @@ please ask for help in an issue.
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/>`_::
The best way to run the tests is with `tox <http://tox.readthedocs.org/en/latest/>`_::
pip install tox
tox
detox
Or it's slightly faster cousin `detox
<https://pypi.python.org/pypi/detox>`_ which will parallelize test runs::
@@ -58,29 +41,16 @@ Or it's slightly faster cousin `detox
pip install detox
detox
Writing tests
-------------
Alternatively, you can run the self test with the following commands::
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.
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
-50
View File
@@ -3,56 +3,6 @@
Release history
===============
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
-----
+2 -2
View File
@@ -51,9 +51,9 @@ copyright = u'2014, Marc Sibson'
# built documents.
#
# The short X.Y version.
version = '1.5'
version = '0.9'
# The full version, including alpha/beta/rc tags.
release = '1.5.0'
release = '0.9.0'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
+5
View File
@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
__version__ = "0.9.0"
from pydiscourse.client import DiscourseClient
@@ -27,14 +27,6 @@ POST = "POST"
PUT = "PUT"
def now() -> datetime:
"""Returns the current UTC time.
This function enables simple mocking for freezing time.
"""
return datetime.utcnow()
class DiscourseClient(object):
"""Discourse API client"""
@@ -136,30 +128,6 @@ class DiscourseClient(object):
**kwargs
)
def user_by_id(self, pk):
"""
Get user from ID
Args:
pk: user id
Returns:
user
"""
return self._get("/admin/users/{0}.json".format(pk))
def user_by_email(self, email):
"""
Get user from email
Args:
email: user email
Returns:
user
"""
return self._get("/admin/users/list/all.json?email={0}".format(email))
def create_user(self, name, username, email, password, **kwargs):
"""
Create a Discourse user
@@ -179,7 +147,7 @@ class DiscourseClient(object):
????
"""
r = self._get("/session/hp.json")
r = self._get("/users/hp.json")
challenge = r["challenge"][::-1] # reverse challenge, discourse security check
confirmations = r["value"]
return self._post(
@@ -244,7 +212,7 @@ class DiscourseClient(object):
????
"""
suspend_until = (now() + timedelta(days=duration)).isoformat()
suspend_until = (datetime.now() + timedelta(days=duration)).isoformat()
return self._put(
"/admin/users/{0}/suspend".format(userid),
suspend_until=suspend_until,
@@ -403,7 +371,7 @@ class DiscourseClient(object):
"""
return self._put(
"/users/{0}/preferences/username".format(username),
new_username=new_username,
username=new_username,
**kwargs
)
@@ -521,14 +489,8 @@ class DiscourseClient(object):
JSON API response
"""
return self._get(
"/c/{0}.json".format(category_id),
override_request_kwargs={"allow_redirects": True},
**kwargs
)
return self._get("/c/{0}.json".format(category_id), **kwargs)
# Doesn't work on recent Discourse versions (2014+)
# https://github.com/discourse/discourse_api/pull/204
def hot_topics(self, **kwargs):
"""
@@ -540,15 +502,6 @@ class DiscourseClient(object):
"""
return self._get("/hot.json", **kwargs)
def top_topics(self, **kwargs):
"""
Get top topics
Returns:
List of top topics
"""
return self._get("/top.json", **kwargs)
def latest_topics(self, **kwargs):
"""
@@ -611,36 +564,6 @@ class DiscourseClient(object):
"""
return self._get("/t/{0}/{1}.json".format(topic_id, post_id), **kwargs)
def post_action_users(self, post_id, post_action_type_id=None, **kwargs):
"""
Args:
post_id: int
post_action_type_id: Optional[int]
**kwargs:
Returns:
"""
# https://meta.discourse.org/t/getting-who-liked-a-post-from-the-api/103618
kwargs["id"] = post_id
if post_action_type_id is not None:
kwargs["post_action_type_id"] = post_action_type_id
return self._get("/post_action_users", **kwargs)
def post_by_id(self, post_id, **kwargs):
"""
Get a post from its id
Args:
post_id: id of the post
**kwargs:
Returns:
post
"""
return self._get("/posts/{0}.json".format(post_id), **kwargs)
def posts(self, topic_id, post_ids=None, **kwargs):
"""
Get a set of posts from a topic
@@ -657,21 +580,6 @@ class DiscourseClient(object):
kwargs["post_ids[]"] = post_ids
return self._get("/t/{0}/posts.json".format(topic_id), **kwargs)
def latest_posts(self, before=None, **kwargs):
"""
List latest posts across topics
Args:
before: Load posts with an id lower than this value. Useful for pagination.
**kwargs:
Returns:
"""
if before:
kwargs["before"] = before
return self._get("/posts.json", **kwargs)
def topic_timings(self, topic_id, time, timings={}, **kwargs):
"""
Set time spent reading a post
@@ -784,14 +692,6 @@ class DiscourseClient(object):
kwargs["post[edit_reason]"] = edit_reason
return self._put("/posts/{0}".format(post_id), **kwargs)
def reset_bump_date(self, topic_id, **kwargs):
"""
Reset bump date
See https://meta.discourse.org/t/what-is-a-bump/105562
"""
return self._put("/t/{0}/reset-bump-date".format(topic_id), **kwargs)
def topics_by(self, username, **kwargs):
"""
@@ -932,30 +832,21 @@ class DiscourseClient(object):
"""
return self._get("/categories.json", **kwargs)["category_list"]["categories"]
def category(self, category_id, parent=None, **kwargs):
def category(self, name, parent=None, **kwargs):
"""
Args:
category_id:
name:
parent:
**kwargs:
Returns:
"""
if parent:
name = u"{0}/{1}".format(parent, name)
return self._get(u"/c/{0}/show.json".format(category_id), **kwargs)
def update_category(self, category_id, **kwargs):
"""
Args:
category_id:
**kwargs:
Returns:
"""
return self._put("/categories/{0}".format(category_id), json=True, **kwargs)
return self._get(u"/category/{0}.json".format(name), **kwargs)
def delete_category(self, category_id, **kwargs):
"""
@@ -970,32 +861,12 @@ class DiscourseClient(object):
"""
return self._delete(u"/categories/{0}".format(category_id), **kwargs)
def get_site_info(self):
"""
Get site info to fetch all categories and subcategories
"""
return self._get("/site.json")
def get_site_settings(self):
"""
Get site settings
"""
return self._get("/admin/site_settings.json")
def category_latest_topics(self, name, parent=None, **kwargs):
"""
Get latest topics from a category
"""
if parent:
name = u"{0}/{1}".format(parent, name)
return self._get(u"/c/{0}/l/latest.json".format(name), **kwargs)
def site_settings(self, **kwargs):
"""
Update site settings
Args:
**kwargs: key-value of properties to update
settings:
**kwargs:
Returns:
@@ -1074,7 +945,7 @@ class DiscourseClient(object):
"""
Get all infos of a group by group name
"""
return self._get("/groups/{0}.json".format(group_name))
return self._get("/groups/{0}/members.json".format(group_name))
def create_group(
self,
@@ -1153,23 +1024,8 @@ class DiscourseClient(object):
JSON API response
"""
return self.add_group_owners(groupid, [username])
def add_group_owners(self, groupid, usernames):
"""
Add a list of owners to a group by usernames
Args:
groupid: the ID of the group
username: the list of new owner usernames
Returns:
JSON API response
"""
usernames = ",".join(usernames)
return self._put(
"/groups/{0}/owners.json".format(groupid), **{"usernames": usernames}
"/admin/groups/{0}/owners.json".format(groupid), usernames=username
)
def delete_group_owner(self, groupid, userid):
@@ -1197,28 +1053,13 @@ class DiscourseClient(object):
group = self._get("/groups/{0}/members.json".format(group_name))
return group["owners"]
def _get_paginated_list(self, url, name, offset, **kwargs):
result = []
initial_offset = offset
while True:
kwargs["offset"] = offset
response = self._get(url, **kwargs)
nreturned = len(response[name])
result.extend(response[name])
offset += nreturned
if response["meta"]["total"] == len(result) - initial_offset:
return result
if nreturned == 0:
raise RuntimeError("more items expected, but none returned")
def group_members(self, group_name, offset=0, **kwargs):
"""
Get all members of a group by group name
"""
return self._get_paginated_list(
"/groups/{0}/members.json".format(group_name),
"members", offset, **kwargs)
kwargs["offset"] = offset
group = self._get("/groups/{0}/members.json".format(group_name), **kwargs)
return group["members"]
def add_group_member(self, groupid, username):
"""
@@ -1236,7 +1077,7 @@ class DiscourseClient(object):
"""
return self._put(
"/groups/{0}/members.json".format(groupid), usernames=username
"/admin/groups/{0}/members.json".format(groupid), usernames=username
)
def add_group_members(self, groupid, usernames):
@@ -1256,8 +1097,7 @@ class DiscourseClient(object):
"""
usernames = ",".join(usernames)
return self._put(
"/groups/{0}/members.json".format(groupid), usernames=usernames,
json=True,
"/admin/groups/{0}/members.json".format(groupid), usernames=usernames
)
def add_user_to_group(self, groupid, userid):
@@ -1277,7 +1117,7 @@ class DiscourseClient(object):
"""
return self._post("/admin/users/{0}/groups".format(userid), group_id=groupid)
def delete_group_member(self, groupid, username):
def delete_group_member(self, groupid, userid):
"""
Deletes a member from a group by user ID
@@ -1285,16 +1125,15 @@ class DiscourseClient(object):
Args:
groupid: the ID of the group
username: the user name of the user
userid: the ID of the user
Returns:
JSON API response
"""
return self._request(
DELETE, "/groups/{0}/members.json".format(groupid),
json={"usernames": username})
return self._delete(
"/admin/groups/{0}/members.json".format(groupid), user_id=userid
)
def color_schemes(self, **kwargs):
"""
@@ -1442,29 +1281,7 @@ class DiscourseClient(object):
kwargs["parent_tag_name"] = parent_tag_name
return self._post("/tag_groups", json=True, **kwargs)["tag_group"]
def data_explorer_query(self, query_id, **kwargs):
"""
Run a query with database explorer plugin.
Requires discourse-data-explorer installed
https://github.com/discourse/discourse-data-explorer
"""
return self._post(
"/admin/plugins/explorer/queries/{}/run".format(query_id), **kwargs
)
def notifications(self, category_id, **kwargs):
"""
Get notifications
Args:
category_id
**kwargs:
notification_level=(int)
"""
return self._post("/category/{}/notifications".format(category_id), **kwargs)
def _get(self, path, override_request_kwargs=None, **kwargs):
def _get(self, path, **kwargs):
"""
Args:
@@ -1474,11 +1291,9 @@ class DiscourseClient(object):
Returns:
"""
return self._request(
GET, path, params=kwargs, override_request_kwargs=override_request_kwargs
)
return self._request(GET, path, params=kwargs)
def _put(self, path, json=False, override_request_kwargs=None, **kwargs):
def _put(self, path, json=False, **kwargs):
"""
Args:
@@ -1489,18 +1304,12 @@ class DiscourseClient(object):
"""
if not json:
return self._request(
PUT, path, data=kwargs, override_request_kwargs=override_request_kwargs
)
return self._request(PUT, path, data=kwargs)
else:
return self._request(
PUT, path, json=kwargs, override_request_kwargs=override_request_kwargs
)
return self._request(PUT, path, json=kwargs)
def _post(
self, path, files=None, json=False, override_request_kwargs=None, **kwargs
):
def _post(self, path, files={}, json=False, **kwargs):
"""
Args:
@@ -1511,24 +1320,12 @@ class DiscourseClient(object):
"""
if not json:
return self._request(
POST,
path,
files=files,
data=kwargs,
override_request_kwargs=override_request_kwargs,
)
return self._request(POST, path, files=files, data=kwargs)
else:
return self._request(
POST,
path,
files=files,
json=kwargs,
override_request_kwargs=override_request_kwargs,
)
return self._request(POST, path, files=files, json=kwargs)
def _delete(self, path, override_request_kwargs=None, **kwargs):
def _delete(self, path, **kwargs):
"""
Args:
@@ -1538,20 +1335,9 @@ class DiscourseClient(object):
Returns:
"""
return self._request(
DELETE, path, params=kwargs, override_request_kwargs=override_request_kwargs
)
return self._request(DELETE, path, params=kwargs)
def _request(
self,
verb,
path,
params=None,
files=None,
data=None,
json=None,
override_request_kwargs=None,
):
def _request(self, verb, path, params={}, files={}, data={}, json={}):
"""
Executes HTTP request to API and handles response
@@ -1559,22 +1345,17 @@ class DiscourseClient(object):
verb: HTTP verb as string: GET, DELETE, PUT, POST
path: the path on the Discourse API
params: dictionary of parameters to include to the API
override_request_kwargs: dictionary of requests.request
keyword arguments to override defaults
Returns:
dictionary of response body data or None
"""
override_request_kwargs = override_request_kwargs or {}
params["api_key"] = self.api_key
if "api_username" not in params:
params["api_username"] = self.api_username
url = self.host + path
headers = {
"Accept": "application/json; charset=utf-8",
"Api-Key": self.api_key,
"Api-Username": self.api_username,
}
headers = {"Accept": "application/json; charset=utf-8"}
# How many times should we retry if rate limited
retry_count = 4
@@ -1582,7 +1363,9 @@ class DiscourseClient(object):
retry_backoff = 1
while retry_count > 0:
request_kwargs = dict(
response = requests.request(
verb,
url,
allow_redirects=False,
params=params,
files=files,
@@ -1592,10 +1375,6 @@ class DiscourseClient(object):
timeout=self.timeout,
)
request_kwargs.update(override_request_kwargs)
response = requests.request(verb, url, **request_kwargs)
log.debug("response %s: %s", response.status_code, repr(response.text))
if response.ok:
break
@@ -1611,29 +1390,20 @@ class DiscourseClient(object):
if 400 <= response.status_code < 500:
if 429 == response.status_code:
# This codepath relies on wait_seconds from Discourse v2.0.0.beta3 / v1.9.3 or higher.
content_type = response.headers.get("Content-Type")
if content_type is not None and "application/json" in content_type:
ret = response.json()
wait_delay = (
retry_backoff + ret["extras"]["wait_seconds"]
) # how long to back off for.
else:
# We got an early 429 error without a proper JSON body
ret = response.content
wait_delay = retry_backoff + 10
rj = response.json()
wait_delay = (
retry_backoff + rj["extras"]["wait_seconds"]
) # how long to back off for.
limit_name = response.headers.get(
"Discourse-Rate-Limit-Error-Code", "<unknown>")
log.info(
"We have been rate limited (limit: {2}) and will wait {0} seconds ({1} retries left)".format(
wait_delay, retry_count, limit_name
)
)
if retry_count > 1:
time.sleep(wait_delay)
retry_count -= 1
log.debug("API returned {0}".format(ret))
log.info(
"We have been rate limited and waited {0} seconds ({1} retries left)".format(
wait_delay, retry_count
)
)
log.debug("API returned {0}".format(rj))
continue
else:
raise DiscourseClientError(msg, response=response)
@@ -1671,10 +1441,7 @@ class DiscourseClient(object):
except ValueError:
raise DiscourseError("failed to decode response", response=response)
# Checking "errors" length because
# data-explorer (e.g. POST /admin/plugins/explorer/queries/{}/run)
# sends an empty errors array
if "errors" in decoded and len(decoded["errors"]) > 0:
if "errors" in decoded:
message = decoded.get("message")
if not message:
message = u",".join(decoded["errors"])
+17
View File
@@ -0,0 +1,17 @@
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 """
@@ -1,7 +1,5 @@
#!/usr/bin/env python
"""Simple command line interface for making Discourse API queries."""
import cmd
import json
import logging
@@ -14,19 +12,15 @@ from pydiscourse.client import DiscourseClient, DiscourseError
class DiscourseCmd(cmd.Cmd):
"""Handles CLI commands"""
prompt = "discourse>"
output = sys.stdout
def __init__(self, client):
"""Initialize command"""
cmd.Cmd.__init__(self)
self.client = client
self.prompt = "%s>" % self.client.host
def __getattr__(self, attr):
"""Gets attributes with dynamic name handling"""
if attr.startswith("do_"):
method = getattr(self.client, attr[3:])
@@ -54,7 +48,6 @@ 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=(",", ": ")
@@ -64,7 +57,6 @@ class DiscourseCmd(cmd.Cmd):
def main():
"""Runs the CLI application"""
op = optparse.OptionParser()
op.add_option("--host", default="http://localhost:4000")
op.add_option("--api-user", default="system")
+16 -14
View File
@@ -1,4 +1,6 @@
"""Implement Single Sign On for Discourse with a Python managed auth DB.
"""
Utilities to implement Single Sign On for Discourse with a Python managed
authentication DB
https://meta.discourse.org/t/official-single-sign-on-for-discourse/13045
@@ -24,34 +26,37 @@ from base64 import b64encode, b64decode
import hmac
import hashlib
from urllib.parse import unquote, urlencode, parse_qs
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):
"""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
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.")
raise DiscourseError("Invalid secret..")
payload = unquote(payload)
if not payload:
raise DiscourseError("Invalid payload.")
raise DiscourseError("Invalid payload..")
decoded = b64decode(payload.encode("utf-8")).decode("utf-8")
if "nonce" not in decoded:
raise DiscourseError("Invalid payload.")
raise DiscourseError("Invalid payload..")
h = hmac.new(
secret.encode("utf-8"), payload.encode("utf-8"), digestmod=hashlib.sha256
@@ -67,7 +72,6 @@ def sso_validate(payload, signature, secret):
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()})
@@ -75,16 +79,14 @@ def sso_payload(secret, **kwargs):
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
return value: URL to redirect users back to discourse, now logged in as user_username
"""
kwargs.update(
{
-7
View File
@@ -1,7 +0,0 @@
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/
+1 -45
View File
@@ -1,46 +1,2 @@
[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
[options.packages.find]
where=src
[options.entry_points]
console_scripts =
pydiscoursecli = pydiscourse.main:main
[bdist_wheel]
[wheel]
universal = 1
[build-system]
requires =
setuptools >= "40.9.0"
wheel
+49 -6
View File
@@ -1,7 +1,50 @@
# -*- coding: utf-8 -*-
"""
See setup.cfg for packaging settings
"""
from setuptools import setup, find_packages
from setuptools import setup
setup()
README = open('README.rst').read()
HISTORY = open('HISTORY.rst').read().replace('.. :changelog:', '')
with open("pydiscourse/__init__.py", "r") as module_file:
for line in module_file:
if line.startswith("__version__"):
version_string = line.split("=")[1]
VERSION = version_string.strip().replace("\"", "")
setup(
name="pydiscourse",
version=VERSION,
description="A Python library for the Discourse API",
long_description=README + '\n\n' + HISTORY,
author="Marc Sibson and contributors",
author_email="ben+pydiscourse@benlopatin.com",
license="BSD",
url="https://github.com/bennylope/pydiscourse",
packages=find_packages(exclude=["tests.*", "tests"]),
install_requires=[
'requests>=2.4.2',
],
tests_require=[
'mock',
],
test_suite='tests',
entry_points={
'console_scripts': [
'pydiscoursecli = pydiscourse.main:main'
]
},
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 :: 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,
)
-8
View File
@@ -1,8 +0,0 @@
"""Python client for the Discourse API."""
__version__ = "1.5.1"
from pydiscourse.client import DiscourseClient
__all__ = ["DiscourseClient"]
-19
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"""
-128
View File
@@ -1,128 +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():
return "/session/sso_login?sso=bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGImbmFtZT1z%0AYW0mdXNlcm5hbWU9c2Ftc2FtJmVtYWlsPXRlc3QlNDB0ZXN0LmNvbSZleHRl%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
+186 -206
View File
@@ -1,234 +1,214 @@
"""Tests for the client methods."""
import sys
import unittest
import urllib.parse
try:
from unittest import mock
except ImportError:
import mock
from pydiscourse import client
def test_empty_content_http_ok(discourse_host, discourse_client, requests_mock):
"""Empty content should not raise error
if sys.version_info < (3,):
Critical to test against *bytestrings* rather than unicode
"""
requests_mock.get(
f"{discourse_host}/users/admin/1/unsuspend",
headers={"Content-Type": "text/plain; charset=utf-8"},
content=b" ",
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"}
)
resp = discourse_client._request("GET", "/users/admin/1/unsuspend", {})
assert resp is None
class ClientBaseTestCase(unittest.TestCase):
"""
"""
def setUp(self):
self.host = "http://testhost"
self.api_username = "testuser"
self.api_key = "testkey"
self.client = client.DiscourseClient(self.host, self.api_username, self.api_key)
def assertRequestCalled(self, request, verb, url, **params):
self.assertTrue(request.called)
args, kwargs = request.call_args
self.assertEqual(args[0], verb)
self.assertEqual(args[1], self.host + url)
kwargs = kwargs["params"]
self.assertEqual(kwargs.pop("api_username"), self.api_username)
self.assertEqual(kwargs.pop("api_key"), self.api_key)
if verb == "GET":
self.assertEqual(kwargs, params)
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 TestClientRequests(ClientBaseTestCase):
"""
Tests for common request handling
"""
assert request.called_once
@mock.patch("pydiscourse.client.requests")
def test_empty_content_http_ok(self, mocked_requests):
"""Empty content should not raise error
def test_create_user(self, discourse_host, discourse_client, requests_mock):
session_request = requests_mock.get(
f"{discourse_host}/session/hp.json",
headers={"Content-Type": "application/json; charset=utf-8"},
json={"challenge": "challenge", "value": "value"},
)
user_request = requests_mock.post(
f"{discourse_host}/users",
headers={"Content-Type": "application/json; charset=utf-8"},
json={},
)
discourse_client.create_user(
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")
class TestUser(ClientBaseTestCase):
def test_user(self, request):
prepare_response(request)
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.assertEqual(request.call_count, 2)
assert session_request.called_once
assert user_request.called_once
# XXX incomplete
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"}
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
)
discourse_client.by_external_id(123)
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_suspend_user(self, discourse_client, discourse_request, frozen_time):
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
class TestTopics:
def test_hot_topics(self, discourse_client, requests_mock):
request = requests_mock.get(
f"{discourse_client.host}/hot.json",
headers={"Content-Type": "application/json; charset=utf-8"},
json={},
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"
)
discourse_client.hot_topics()
assert request.called_once
def test_latest_topics(self, discourse_client, requests_mock):
request = requests_mock.get(
f"{discourse_client.host}/latest.json",
headers={"Content-Type": "application/json; charset=utf-8"},
json={},
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"
)
discourse_client.latest_topics()
assert request.called_once
def test_unsuspend_user(self, request):
prepare_response(request)
self.client.unsuspend(123)
self.assertRequestCalled(request, "PUT", "/admin/users/123/unsuspend")
def test_new_topics(self, discourse_client, requests_mock):
request = requests_mock.get(
f"{discourse_client.host}/new.json",
headers={"Content-Type": "application/json; charset=utf-8"},
json={},
def test_user_bagdes(self, request):
prepare_response(request)
self.client.user_badges("username")
self.assertRequestCalled(
request, "GET", "/user-badges/{}.json".format("username")
)
discourse_client.new_topics()
assert request.called_once
def test_topic(self, discourse_client, requests_mock):
request = requests_mock.get(
f"{discourse_client.host}/t/some-test-slug/22.json",
headers={"Content-Type": "application/json; charset=utf-8"},
json={},
@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")
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("some-test-slug", 22)
self.assertRequestCalled(request, "GET", "/t/some-test-slug/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
)
discourse_client.topic("some-test-slug", 22)
assert request.called_once
def test_topics_by(self, discourse_client, requests_mock):
request = requests_mock.get(
f"{discourse_client.host}/topics/created-by/someuser.json",
headers={"Content-Type": "application/json; charset=utf-8"},
json={"topic_list": {"topics": []}},
@mock.patch("pydiscourse.client.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")
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
)
discourse_client.topics_by("someuser")
assert request.called_once
def test_invite_user_to_topic(self, discourse_client, requests_mock):
request = requests_mock.post(
f"{discourse_client.host}/t/22/invite.json",
headers={"Content-Type": "application/json; charset=utf-8"},
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"]
class TestEverything:
def test_latest_posts(self, discourse_client, requests_mock):
request = requests_mock.get(
f"{discourse_client.host}/posts.json?before=54321",
headers={"Content-Type": "application/json; charset=utf-8"},
json={},
)
discourse_client.latest_posts(before=54321)
assert request.called_once
def test_search(self, discourse_client, requests_mock):
request = requests_mock.get(
f"{discourse_client.host}/search.json?term=needle",
headers={"Content-Type": "application/json; charset=utf-8"},
json={},
)
discourse_client.search(term="needle")
assert request.called_once
def test_categories(self, discourse_client, requests_mock):
request = requests_mock.get(
f"{discourse_client.host}/categories.json",
headers={"Content-Type": "application/json; charset=utf-8"},
json={"category_list": {"categories": []}},
)
discourse_client.categories()
assert request.called_once
def test_update_category(self, discourse_client, requests_mock):
# self.assertRequestCalled(request, "PUT", "/categories/123", a="a", b="b")
request = requests_mock.put(
f"{discourse_client.host}/categories/123",
headers={"Content-Type": "application/json; charset=utf-8"},
json={},
)
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"
def test_users(self, discourse_client, requests_mock):
request = requests_mock.get(
f"{discourse_client.host}/admin/users/list/active.json",
headers={"Content-Type": "application/json; charset=utf-8"},
json={},
)
discourse_client.users()
assert request.called_once
def test_badges(self, discourse_client, requests_mock):
request = requests_mock.get(
f"{discourse_client.host}/admin/badges.json",
headers={"Content-Type": "application/json; charset=utf-8"},
json={},
)
discourse_client.badges()
assert request.called_once
def test_grant_badge_to(self, discourse_client, requests_mock):
request = requests_mock.post(
f"{discourse_client.host}/user_badges",
headers={"Content-Type": "application/json; charset=utf-8"},
json={},
)
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"]
+66 -70
View File
@@ -1,89 +1,85 @@
from base64 import b64decode
from urllib.parse import unquote
from urllib.parse import urlparse, parse_qs
import pytest
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
def test_sso_validate_missing_payload():
with pytest.raises(DiscourseError) as excinfo:
sso.sso_validate(None, "abc", "123")
class SSOTestCase(unittest.TestCase):
assert excinfo.value.args[0] == "No SSO payload or signature."
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_sso_validate_empty_payload():
with pytest.raises(DiscourseError) as excinfo:
sso.sso_validate("", "abc", "123")
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_sso_validate_missing_signature():
with pytest.raises(DiscourseError) as excinfo:
sso.sso_validate("sig", None, "123")
def test_invalid_signature(self):
with self.assertRaises(DiscourseError):
sso.sso_validate(self.payload, "notavalidsignature", self.secret)
assert excinfo.value.args[0] == "No SSO payload or signature."
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",
)
@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)
self.assertIn("/session/sso_login", url[:20])
assert excinfo.value.args[0] == "Invalid secret."
# 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("&")))
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,
},
)
+4 -16
View File
@@ -1,22 +1,10 @@
[tox]
envlist = py38, py39, py310, py311
[gh-actions]
python =
3.8: py38
3.9: py39
3.10: py310
3.11: py311
envlist = py27, py34, py35, py36, py37, pypy, pypy3
[testenv]
setenv =
PYTHONPATH = {toxinidir}:{toxinidir}/pydiscourse
commands =
pytest {posargs} --cov=pydiscourse
coverage report -m --include='**/pydiscourse/client.py' --fail-under=45
coverage report -m --include='**/pydiscourse/sso.py' --fail-under=100
deps =
-r{toxinidir}/requirements.txt
commands = python setup.py test
[testenv:flake8]
basepython=python
@@ -24,10 +12,10 @@ deps=
flake8
flake8_docstrings
commands=
flake8 src/pydiscourse --docstring-convention google --ignore D415
flake8 pydiscourse
[flake8]
ignore = E126,E128
max-line-length = 119
max-line-length = 99
exclude = .ropeproject
max-complexity = 10