Compare commits

..

No commits in common. "master" and "test-against-python311" have entirely different histories.

21 changed files with 443 additions and 914 deletions

3
.coveragerc Normal file
View File

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

View File

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

View File

@ -3,35 +3,13 @@ name: Tests
on: [ push, pull_request ] on: [ push, pull_request ]
jobs: 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: test:
needs: lint
name: Test on Python ${{ matrix.python-version }} name: Test on Python ${{ matrix.python-version }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ] python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11" ]
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1

6
.gitignore vendored
View File

@ -36,9 +36,3 @@ coverage.xml
# Sphinx documentation # Sphinx documentation
docs/_build/ 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

View File

@ -21,7 +21,7 @@ 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, 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 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 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 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 Testing
======= =======
Running tests The best way to run the tests is with `tox <http://tox.readthedocs.org/en/latest/>`_::
-------------
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 pip install tox
tox detox
Or it's slightly faster cousin `detox Or it's slightly faster cousin `detox
<https://pypi.python.org/pypi/detox>`_ which will parallelize test runs:: <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 pip install detox
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 pip install -r requirements.dev.txt
write a test or tests when you add a new feature. pip install -e .
python setup.py test
**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 Live Testing
============ ============
You can test against a Discourse instance by following the [Official Discourse developement instructions][discoursedev]. 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:: For the impatient here is the quick and dirty version::
git clone git@github.com:discourse/discourse.git git clone git@github.com:discourse/discourse.git

View File

@ -3,45 +3,6 @@
Release history 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 1.3.0
----- -----

View File

@ -2,13 +2,9 @@
pydiscourse pydiscourse
=========== ===========
.. image:: https://img.shields.io/pypi/v/pydiscourse?color=blue .. image:: https://github.com/bennylope/pydiscourse/workflows/Tests/badge.svg
:alt: PyPI
:target: https://pypi.org/project/pydiscourse/
.. image:: https://github.com/pydiscourse/pydiscourse/workflows/Tests/badge.svg
:alt: Build Status :alt: Build Status
:target: https://github.com/pydiscourse/pydiscourse/actions :target: https://github.com/bennylope/pydiscourse/actions
.. image:: https://img.shields.io/badge/Check%20out%20the-Docs-blue.svg .. image:: https://img.shields.io/badge/Check%20out%20the-Docs-blue.svg
:alt: Check out the Docs :alt: Check out the Docs
@ -41,52 +37,42 @@ Installation
Examples Examples
======== ========
Create a client connection to a Discourse server: 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')
from pydiscourse import DiscourseClient Get info about a user::
client = DiscourseClient(
'http://example.com',
api_username='username',
api_key='areallylongstringfromdiscourse')
Get info about a user: user = client.user('eviltrout')
print user
.. code:: python user_topics = client.topics_by('johnsmith')
print user_topics
user = client.user('eviltrout') Create a new user::
print user
user_topics = client.topics_by('johnsmith') user = client.create_user('The Black Knight', 'blacknight', 'knight@python.org', 'justafleshwound')
print user_topics
Create a new user: Implement SSO for Discourse with your Python server::
.. code:: python @login_required
def discourse_sso_view(request):
user = client.create_user('The Black Knight', 'blacknight', 'knight@python.org', 'justafleshwound') payload = request.GET.get('sso')
signature = request.GET.get('sig')
Implement SSO for Discourse with your Python server: nonce = sso_validate(payload, signature, SECRET)
url = sso_redirect_url(nonce, SECRET, request.user.email, request.user.id, request.user.username)
.. code:: python return redirect('http://discuss.example.com' + url)
@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 Command line
============ ============
To help experiment with the Discourse API, pydiscourse provides a simple command line client: 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
export DISCOURSE_API_KEY=your_master_key pydiscoursecli --host-http://yourhost --api-user-system topics_by johnsmith
pydiscoursecli --host-http://yourhost --api-user-system latest_topics pydiscoursecli --host-http://yourhost --api-user-system user eviltrout
pydiscoursecli --host-http://yourhost --api-user-system topics_by johnsmith
pydiscoursecli --host-http://yourhost --api-user-system user eviltrout

View File

@ -51,9 +51,9 @@ copyright = u'2014, Marc Sibson'
# built documents. # built documents.
# #
# The short X.Y version. # The short X.Y version.
version = '1.7' version = '1.1'
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = '1.7.0' release = '1.1.1'
# The language for content autogenerated by Sphinx. Refer to documentation # The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages. # for a list of supported languages.

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/*"]

View File

@ -1,9 +1,2 @@
pre-commit==3.3.3 pytest==6.2.5
ruff==0.0.286 pytest-cov==3.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/

View File

@ -18,6 +18,7 @@ package_dir =
=src =src
install_requires = install_requires =
requests>=2.4.2 requests>=2.4.2
typing; python_version<"3.6"
classifiers = classifiers =
Development Status :: 5 - Production/Stable Development Status :: 5 - Production/Stable
Environment :: Web Environment Environment :: Web Environment
@ -25,11 +26,10 @@ classifiers =
License :: OSI Approved :: MIT License License :: OSI Approved :: MIT License
Operating System :: OS Independent Operating System :: OS Independent
Programming Language :: Python Programming Language :: Python
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Programming Language :: Python :: 3.12
[options.packages.find] [options.packages.find]
where=src where=src

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
""" """
See setup.cfg for packaging settings See setup.cfg for packaging settings
""" """

View File

@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
"""Python client for the Discourse API.""" """Python client for the Discourse API."""
__version__ = "1.7.0" __version__ = "1.3.0"
from pydiscourse.client import DiscourseClient from pydiscourse.client import DiscourseClient

View File

@ -27,15 +27,7 @@ POST = "POST"
PUT = "PUT" PUT = "PUT"
def now() -> datetime: class DiscourseClient(object):
"""Returns the current UTC time.
This function enables simple mocking for freezing time.
"""
return datetime.utcnow()
class DiscourseClient:
"""Discourse API client""" """Discourse API client"""
def __init__(self, host, api_username, api_key, timeout=None): def __init__(self, host, api_username, api_key, timeout=None):
@ -70,16 +62,16 @@ class DiscourseClient:
dict of user information dict of user information
""" """
return self._get(f"/users/{username}.json")["user"] return self._get("/users/{0}.json".format(username))["user"]
def approve(self, user_id): def approve(self, user_id):
return self._get(f"/admin/users/{user_id}/approve.json") return self._get("/admin/users/{0}/approve.json".format(user_id))
def activate(self, user_id): def activate(self, user_id):
return self._put(f"/admin/users/{user_id}/activate.json") return self._put("/admin/users/{0}/activate.json".format(user_id))
def deactivate(self, user_id): def deactivate(self, user_id):
return self._put(f"/admin/users/{user_id}/deactivate.json") return self._put("/admin/users/{0}/deactivate.json".format(user_id))
def user_all(self, user_id): def user_all(self, user_id):
""" """
@ -90,7 +82,7 @@ class DiscourseClient:
Returns: Returns:
dict of user information dict of user information
""" """
return self._get(f"/admin/users/{user_id}.json") return self._get("/admin/users/{0}.json".format(user_id))
def invite(self, email, group_names, custom_message, **kwargs): def invite(self, email, group_names, custom_message, **kwargs):
""" """
@ -111,7 +103,7 @@ class DiscourseClient:
email=email, email=email,
group_names=group_names, group_names=group_names,
custom_message=custom_message, custom_message=custom_message,
**kwargs, **kwargs
) )
def invite_link(self, email, group_names, custom_message, **kwargs): def invite_link(self, email, group_names, custom_message, **kwargs):
@ -133,7 +125,7 @@ class DiscourseClient:
email=email, email=email,
group_names=group_names, group_names=group_names,
custom_message=custom_message, custom_message=custom_message,
**kwargs, **kwargs
) )
def user_by_id(self, pk): def user_by_id(self, pk):
@ -146,7 +138,7 @@ class DiscourseClient:
Returns: Returns:
user user
""" """
return self._get(f"/admin/users/{pk}.json") return self._get("/admin/users/{0}.json".format(pk))
def user_by_email(self, email): def user_by_email(self, email):
""" """
@ -158,7 +150,7 @@ class DiscourseClient:
Returns: Returns:
user user
""" """
return self._get(f"/admin/users/list/all.json?email={email}") return self._get("/admin/users/list/all.json?email={0}".format(email))
def create_user(self, name, username, email, password, **kwargs): def create_user(self, name, username, email, password, **kwargs):
""" """
@ -190,7 +182,7 @@ class DiscourseClient:
password=password, password=password,
password_confirmation=confirmations, password_confirmation=confirmations,
challenge=challenge, challenge=challenge,
**kwargs, **kwargs
) )
def user_by_external_id(self, external_id): def user_by_external_id(self, external_id):
@ -202,7 +194,7 @@ class DiscourseClient:
Returns: Returns:
""" """
response = self._get(f"/users/by-external/{external_id}") response = self._get("/users/by-external/{0}".format(external_id))
return response["user"] return response["user"]
by_external_id = user_by_external_id by_external_id = user_by_external_id
@ -216,7 +208,7 @@ class DiscourseClient:
Returns: Returns:
""" """
return self._post(f"/admin/users/{userid}/log_out") return self._post("/admin/users/{0}/log_out".format(userid))
def trust_level(self, userid, level): def trust_level(self, userid, level):
""" """
@ -228,18 +220,7 @@ class DiscourseClient:
Returns: Returns:
""" """
return self._put(f"/admin/users/{userid}/trust_level", level=level) return self._put("/admin/users/{0}/trust_level".format(userid), level=level)
def anonymize(self, userid):
"""
Args:
userid: the Discourse user ID
Returns:
"""
return self._put(f"/admin/users/{userid}/anonymize")
def suspend(self, userid, duration, reason): def suspend(self, userid, duration, reason):
""" """
@ -255,9 +236,9 @@ class DiscourseClient:
???? ????
""" """
suspend_until = (now() + timedelta(days=duration)).isoformat() suspend_until = (datetime.now() + timedelta(days=duration)).isoformat()
return self._put( return self._put(
f"/admin/users/{userid}/suspend", "/admin/users/{0}/suspend".format(userid),
suspend_until=suspend_until, suspend_until=suspend_until,
reason=reason, reason=reason,
) )
@ -272,21 +253,21 @@ class DiscourseClient:
Returns: Returns:
None??? None???
""" """
return self._put(f"/admin/users/{userid}/unsuspend") return self._put("/admin/users/{0}/unsuspend".format(userid))
def list_users(self, user_type, **kwargs): def list_users(self, type, **kwargs):
""" """
optional user search: filter='test@example.com' or filter='scott' optional user search: filter='test@example.com' or filter='scott'
Args: Args:
user_type: type:
**kwargs: **kwargs:
Returns: Returns:
""" """
return self._get(f"/admin/users/list/{user_type}.json", **kwargs) return self._get("/admin/users/list/{0}.json".format(type), **kwargs)
def update_avatar_from_url(self, username, url, **kwargs): def update_avatar_from_url(self, username, url, **kwargs):
""" """
@ -300,9 +281,7 @@ class DiscourseClient:
""" """
return self._post( return self._post(
f"/users/{username}/preferences/avatar", "/users/{0}/preferences/avatar".format(username), file=url, **kwargs
file=url,
**kwargs,
) )
def update_avatar_image(self, username, img, **kwargs): def update_avatar_image(self, username, img, **kwargs):
@ -320,12 +299,10 @@ class DiscourseClient:
""" """
files = {"file": img} files = {"file": img}
return self._post( return self._post(
f"/users/{username}/preferences/avatar", "/users/{0}/preferences/avatar".format(username), files=files, **kwargs
files=files,
**kwargs,
) )
def toggle_gravatar(self, username, *, state=True, **kwargs): def toggle_gravatar(self, username, state=True, **kwargs):
""" """
Args: Args:
@ -336,14 +313,14 @@ class DiscourseClient:
Returns: Returns:
""" """
url = f"/users/{username}/preferences/avatar/toggle" url = "/users/{0}/preferences/avatar/toggle".format(username)
if bool(state): if bool(state):
kwargs["use_uploaded_avatar"] = "true" kwargs["use_uploaded_avatar"] = "true"
else: else:
kwargs["use_uploaded_avatar"] = "false" kwargs["use_uploaded_avatar"] = "false"
return self._put(url, **kwargs) return self._put(url, **kwargs)
def pick_avatar(self, username, *, gravatar=True, generated=False, **kwargs): def pick_avatar(self, username, gravatar=True, generated=False, **kwargs):
""" """
Args: Args:
@ -355,7 +332,7 @@ class DiscourseClient:
Returns: Returns:
""" """
url = f"/users/{username}/preferences/avatar/pick" url = "/users/{0}/preferences/avatar/pick".format(username)
return self._put(url, **kwargs) return self._put(url, **kwargs)
def update_avatar(self, username, url, **kwargs): def update_avatar(self, username, url, **kwargs):
@ -373,9 +350,9 @@ class DiscourseClient:
kwargs["synchronous"] = "true" kwargs["synchronous"] = "true"
upload_response = self._post("/uploads", url=url, **kwargs) upload_response = self._post("/uploads", url=url, **kwargs)
return self._put( return self._put(
f"/users/{username}/preferences/avatar/pick", "/users/{0}/preferences/avatar/pick".format(username),
upload_id=upload_response["id"], upload_id=upload_response["id"],
**kwargs, **kwargs
) )
def update_email(self, username, email, **kwargs): def update_email(self, username, email, **kwargs):
@ -390,9 +367,7 @@ class DiscourseClient:
""" """
return self._put( return self._put(
f"/users/{username}/preferences/email", "/users/{0}/preferences/email".format(username), email=email, **kwargs
email=email,
**kwargs,
) )
def update_user(self, username, **kwargs): def update_user(self, username, **kwargs):
@ -405,7 +380,7 @@ class DiscourseClient:
Returns: Returns:
""" """
return self._put(f"/users/{username}", json=True, **kwargs) return self._put("/users/{0}".format(username), json=True, **kwargs)
def update_username(self, username, new_username, **kwargs): def update_username(self, username, new_username, **kwargs):
""" """
@ -419,9 +394,9 @@ class DiscourseClient:
""" """
return self._put( return self._put(
f"/users/{username}/preferences/username", "/users/{0}/preferences/username".format(username),
new_username=new_username, new_username=new_username,
**kwargs, **kwargs
) )
def set_preference(self, username=None, **kwargs): def set_preference(self, username=None, **kwargs):
@ -436,7 +411,7 @@ class DiscourseClient:
""" """
if username is None: if username is None:
username = self.api_username username = self.api_username
return self._put(f"/users/{username}", **kwargs) return self._put(u"/users/{0}".format(username), **kwargs)
def sync_sso(self, **kwargs): def sync_sso(self, **kwargs):
""" """
@ -452,7 +427,7 @@ class DiscourseClient:
""" """
sso_secret = kwargs.pop("sso_secret") sso_secret = kwargs.pop("sso_secret")
payload = sso_payload(sso_secret, **kwargs) payload = sso_payload(sso_secret, **kwargs)
return self._post(f"/admin/users/sync_sso?{payload}", **kwargs) return self._post("/admin/users/sync_sso?{0}".format(payload), **kwargs)
def generate_api_key(self, userid, **kwargs): def generate_api_key(self, userid, **kwargs):
""" """
@ -464,7 +439,7 @@ class DiscourseClient:
Returns: Returns:
""" """
return self._post(f"/admin/users/{userid}/generate_api_key", **kwargs) return self._post("/admin/users/{0}/generate_api_key".format(userid), **kwargs)
def delete_user(self, userid, **kwargs): def delete_user(self, userid, **kwargs):
""" """
@ -480,22 +455,22 @@ class DiscourseClient:
Returns: Returns:
""" """
return self._delete(f"/admin/users/{userid}.json", **kwargs) return self._delete("/admin/users/{0}.json".format(userid), **kwargs)
def users(self, filter_name=None, **kwargs): def users(self, filter=None, **kwargs):
""" """
Args: Args:
filter_name: filter:
**kwargs: **kwargs:
Returns: Returns:
""" """
if filter_name is None: if filter is None:
filter_name = "active" filter = "active"
return self._get(f"/admin/users/list/{filter_name}.json", **kwargs) return self._get("/admin/users/list/{0}.json".format(filter), **kwargs)
def private_messages(self, username=None, **kwargs): def private_messages(self, username=None, **kwargs):
""" """
@ -509,7 +484,7 @@ class DiscourseClient:
""" """
if username is None: if username is None:
username = self.api_username username = self.api_username
return self._get(f"/topics/private-messages/{username}.json", **kwargs) return self._get("/topics/private-messages/{0}.json".format(username), **kwargs)
def private_messages_unread(self, username=None, **kwargs): def private_messages_unread(self, username=None, **kwargs):
""" """
@ -524,8 +499,7 @@ class DiscourseClient:
if username is None: if username is None:
username = self.api_username username = self.api_username
return self._get( return self._get(
f"/topics/private-messages-unread/{username}.json", "/topics/private-messages-unread/{0}.json".format(username), **kwargs
**kwargs,
) )
def category_topics(self, category_id, **kwargs): def category_topics(self, category_id, **kwargs):
@ -540,9 +514,9 @@ class DiscourseClient:
""" """
return self._get( return self._get(
f"/c/{category_id}.json", "/c/{0}.json".format(category_id),
override_request_kwargs={"allow_redirects": True}, override_request_kwargs={"allow_redirects": True},
**kwargs, **kwargs
) )
# Doesn't work on recent Discourse versions (2014+) # Doesn't work on recent Discourse versions (2014+)
@ -600,7 +574,7 @@ class DiscourseClient:
Returns: Returns:
""" """
return self._get(f"/t/{slug}/{topic_id}.json", **kwargs) return self._get("/t/{0}/{1}.json".format(slug, topic_id), **kwargs)
def delete_topic(self, topic_id, **kwargs): def delete_topic(self, topic_id, **kwargs):
""" """
@ -614,7 +588,7 @@ class DiscourseClient:
JSON API response JSON API response
""" """
return self._delete(f"/t/{topic_id}", **kwargs) return self._delete(u"/t/{0}".format(topic_id), **kwargs)
def post(self, topic_id, post_id, **kwargs): def post(self, topic_id, post_id, **kwargs):
""" """
@ -627,7 +601,7 @@ class DiscourseClient:
Returns: Returns:
""" """
return self._get(f"/t/{topic_id}/{post_id}.json", **kwargs) 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): def post_action_users(self, post_id, post_action_type_id=None, **kwargs):
""" """
@ -657,20 +631,7 @@ class DiscourseClient:
Returns: Returns:
post post
""" """
return self._get(f"/posts/{post_id}.json", **kwargs) return self._get("/posts/{0}.json".format(post_id), **kwargs)
def post_by_number(self, topic_id, post_number, **kwargs):
"""
Get a post from its number inside a specific topic
Args:
topic_id: the topic the post belongs to
post_number: the number of the post inside the topic
**kwargs:
Returns:
post
"""
return self._get(f"/posts/by_number/{topic_id}/{post_number}", **kwargs)
def posts(self, topic_id, post_ids=None, **kwargs): def posts(self, topic_id, post_ids=None, **kwargs):
""" """
@ -686,22 +647,7 @@ class DiscourseClient:
""" """
if post_ids: if post_ids:
kwargs["post_ids[]"] = post_ids kwargs["post_ids[]"] = post_ids
return self._get(f"/t/{topic_id}/posts.json", **kwargs) 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): def topic_timings(self, topic_id, time, timings={}, **kwargs):
""" """
@ -721,7 +667,7 @@ class DiscourseClient:
kwargs["topic_id"] = topic_id kwargs["topic_id"] = topic_id
kwargs["topic_time"] = time kwargs["topic_time"] = time
for post_num, timing in timings.items(): for post_num, timing in timings.items():
kwargs[f"timings[{post_num}]"] = timing kwargs["timings[{0}]".format(post_num)] = timing
return self._post("/topics/timings", **kwargs) return self._post("/topics/timings", **kwargs)
@ -735,7 +681,7 @@ class DiscourseClient:
Returns: Returns:
""" """
return self._get(f"/t/{topic_id}/posts.json", **kwargs) return self._get("/t/{0}/posts.json".format(topic_id), **kwargs)
def update_topic(self, topic_url, title, **kwargs): def update_topic(self, topic_url, title, **kwargs):
""" """
@ -750,16 +696,10 @@ class DiscourseClient:
""" """
kwargs["title"] = title kwargs["title"] = title
return self._put(f"{topic_url}", **kwargs) return self._put("{}".format(topic_url), **kwargs)
def create_post( def create_post(
self, self, content, category_id=None, topic_id=None, title=None, tags=[], **kwargs
content,
category_id=None,
topic_id=None,
title=None,
tags=[],
**kwargs,
): ):
""" """
@ -782,7 +722,7 @@ class DiscourseClient:
title=title, title=title,
raw=content, raw=content,
topic_id=topic_id, topic_id=topic_id,
**kwargs, **kwargs
) )
def update_topic_status(self, topic_id, status, enabled, **kwargs): def update_topic_status(self, topic_id, status, enabled, **kwargs):
@ -803,7 +743,7 @@ class DiscourseClient:
kwargs["enabled"] = "true" kwargs["enabled"] = "true"
else: else:
kwargs["enabled"] = "false" kwargs["enabled"] = "false"
return self._put(f"/t/{topic_id}/status", **kwargs) return self._put("/t/{0}/status".format(topic_id), **kwargs)
def update_post(self, post_id, content, edit_reason="", **kwargs): def update_post(self, post_id, content, edit_reason="", **kwargs):
""" """
@ -819,7 +759,7 @@ class DiscourseClient:
""" """
kwargs["post[raw]"] = content kwargs["post[raw]"] = content
kwargs["post[edit_reason]"] = edit_reason kwargs["post[edit_reason]"] = edit_reason
return self._put(f"/posts/{post_id}", **kwargs) return self._put("/posts/{0}".format(post_id), **kwargs)
def reset_bump_date(self, topic_id, **kwargs): def reset_bump_date(self, topic_id, **kwargs):
""" """
@ -827,7 +767,7 @@ class DiscourseClient:
See https://meta.discourse.org/t/what-is-a-bump/105562 See https://meta.discourse.org/t/what-is-a-bump/105562
""" """
return self._put(f"/t/{topic_id}/reset-bump-date", **kwargs) return self._put("/t/{0}/reset-bump-date".format(topic_id), **kwargs)
def topics_by(self, username, **kwargs): def topics_by(self, username, **kwargs):
""" """
@ -839,7 +779,7 @@ class DiscourseClient:
Returns: Returns:
""" """
url = f"/topics/created-by/{username}.json" url = "/topics/created-by/{0}.json".format(username)
return self._get(url, **kwargs)["topic_list"]["topics"] return self._get(url, **kwargs)["topic_list"]["topics"]
def invite_user_to_topic(self, user_email, topic_id): def invite_user_to_topic(self, user_email, topic_id):
@ -853,19 +793,19 @@ class DiscourseClient:
""" """
kwargs = {"email": user_email, "topic_id": topic_id} kwargs = {"email": user_email, "topic_id": topic_id}
return self._post(f"/t/{topic_id}/invite.json", **kwargs) return self._post("/t/{0}/invite.json".format(topic_id), **kwargs)
def search(self, q, **kwargs): def search(self, term, **kwargs):
""" """
Args: Args:
q: term:
**kwargs: **kwargs:
Returns: Returns:
""" """
kwargs["q"] = q kwargs["term"] = term
return self._get("/search.json", **kwargs) return self._get("/search.json", **kwargs)
def badges(self, **kwargs): def badges(self, **kwargs):
@ -891,10 +831,7 @@ class DiscourseClient:
""" """
return self._post( return self._post(
"/user_badges", "/user_badges", username=username, badge_id=badge_id, **kwargs
username=username,
badge_id=badge_id,
**kwargs,
) )
def user_badges(self, username, **kwargs): def user_badges(self, username, **kwargs):
@ -906,7 +843,7 @@ class DiscourseClient:
Returns: Returns:
""" """
return self._get(f"/user-badges/{username}.json") return self._get("/user-badges/{}.json".format(username))
def user_emails(self, username, **kwargs): def user_emails(self, username, **kwargs):
""" """
@ -918,16 +855,10 @@ class DiscourseClient:
Returns: Returns:
""" """
return self._get(f"/u/{username}/emails.json") return self._get("/u/{}/emails.json".format(username))
def create_category( def create_category(
self, self, name, color, text_color="FFFFFF", permissions=None, parent=None, **kwargs
name,
color,
text_color="FFFFFF",
permissions=None,
parent=None,
**kwargs,
): ):
""" """
@ -951,7 +882,7 @@ class DiscourseClient:
permissions = {"everyone": "1"} permissions = {"everyone": "1"}
for key, value in permissions.items(): for key, value in permissions.items():
kwargs[f"permissions[{key}]"] = value kwargs["permissions[{0}]".format(key)] = value
if parent: if parent:
parent_id = None parent_id = None
@ -961,7 +892,7 @@ class DiscourseClient:
continue continue
if not parent_id: if not parent_id:
raise DiscourseClientError(f"{parent} not found") raise DiscourseClientError(u"{0} not found".format(parent))
kwargs["parent_category_id"] = parent_id kwargs["parent_category_id"] = parent_id
@ -989,19 +920,7 @@ class DiscourseClient:
""" """
return self._get(f"/c/{category_id}/show.json", **kwargs) 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(f"/categories/{category_id}", json=True, **kwargs)
def delete_category(self, category_id, **kwargs): def delete_category(self, category_id, **kwargs):
""" """
@ -1014,7 +933,7 @@ class DiscourseClient:
Returns: Returns:
""" """
return self._delete(f"/categories/{category_id}", **kwargs) return self._delete(u"/categories/{0}".format(category_id), **kwargs)
def get_site_info(self): def get_site_info(self):
""" """
@ -1033,8 +952,8 @@ class DiscourseClient:
Get latest topics from a category Get latest topics from a category
""" """
if parent: if parent:
name = f"{parent}/{name}" name = u"{0}/{1}".format(parent, name)
return self._get(f"/c/{name}/l/latest.json", **kwargs) return self._get(u"/c/{0}/l/latest.json".format(name), **kwargs)
def site_settings(self, **kwargs): def site_settings(self, **kwargs):
""" """
@ -1049,9 +968,7 @@ class DiscourseClient:
for setting, value in kwargs.items(): for setting, value in kwargs.items():
setting = setting.replace(" ", "_") setting = setting.replace(" ", "_")
self._request( self._request(
PUT, PUT, "/admin/site_settings/{0}".format(setting), {setting: value}
f"/admin/site_settings/{setting}",
{setting: value},
) )
def customize_site_texts(self, site_texts, **kwargs): def customize_site_texts(self, site_texts, **kwargs):
@ -1068,9 +985,7 @@ class DiscourseClient:
for site_text, value in site_texts.items(): for site_text, value in site_texts.items():
kwargs = {"site_text": {"value": value}} kwargs = {"site_text": {"value": value}}
self._put( self._put(
f"/admin/customize/site_texts/{site_text}", "/admin/customize/site_texts/{0}".format(site_text), json=True, **kwargs
json=True,
**kwargs,
) )
def groups(self, **kwargs): def groups(self, **kwargs):
@ -1124,12 +1039,11 @@ class DiscourseClient:
""" """
Get all infos of a group by group name Get all infos of a group by group name
""" """
return self._get(f"/groups/{group_name}.json") return self._get("/groups/{0}.json".format(group_name))
def create_group( def create_group(
self, self,
name, name,
*,
title="", title="",
visible=True, visible=True,
alias_level=0, alias_level=0,
@ -1141,7 +1055,7 @@ class DiscourseClient:
flair_url=None, flair_url=None,
flair_bg_color=None, flair_bg_color=None,
flair_color=None, flair_color=None,
**kwargs, **kwargs
): ):
""" """
Args: Args:
@ -1167,7 +1081,9 @@ class DiscourseClient:
kwargs["automatic_membership_retroactive"] = automatic_membership_retroactive kwargs["automatic_membership_retroactive"] = automatic_membership_retroactive
kwargs["primary_group"] = primary_group kwargs["primary_group"] = primary_group
kwargs["automatic"] = automatic kwargs["automatic"] = automatic
kwargs["automatic_membership_email_domains"] = automatic_membership_email_domains kwargs[
"automatic_membership_email_domains"
] = automatic_membership_email_domains
kwargs["grant_trust_level"] = grant_trust_level kwargs["grant_trust_level"] = grant_trust_level
kwargs["flair_url"] = flair_url kwargs["flair_url"] = flair_url
kwargs["flair_bg_color"] = flair_bg_color kwargs["flair_bg_color"] = flair_bg_color
@ -1188,7 +1104,7 @@ class DiscourseClient:
JSON API response JSON API response
""" """
return self._delete(f"/admin/groups/{groupid}.json") return self._delete("/admin/groups/{0}.json".format(groupid))
def add_group_owner(self, groupid, username): def add_group_owner(self, groupid, username):
""" """
@ -1202,7 +1118,9 @@ class DiscourseClient:
JSON API response JSON API response
""" """
return self.add_group_owners(groupid, [username]) return self._put(
"/admin/groups/{0}/owners.json".format(groupid), **{"group[usernames]": username}
)
def add_group_owners(self, groupid, usernames): def add_group_owners(self, groupid, usernames):
""" """
@ -1218,8 +1136,7 @@ class DiscourseClient:
""" """
usernames = ",".join(usernames) usernames = ",".join(usernames)
return self._put( return self._put(
f"/groups/{groupid}/owners.json", "/admin/groups/{0}/owners.json".format(groupid), **{"group[usernames]": usernames}
usernames=usernames,
) )
def delete_group_owner(self, groupid, userid): def delete_group_owner(self, groupid, userid):
@ -1237,37 +1154,23 @@ class DiscourseClient:
""" """
return self._delete( return self._delete(
f"/admin/groups/{groupid}/owners.json", "/admin/groups/{0}/owners.json".format(groupid), user_id=userid
user_id=userid,
) )
def group_owners(self, group_name): def group_owners(self, group_name):
""" """
Get all owners of a group by group name Get all owners of a group by group name
""" """
group = self._get(f"/groups/{group_name}/members.json") group = self._get("/groups/{0}/members.json".format(group_name))
return group["owners"] 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): def group_members(self, group_name, offset=0, **kwargs):
""" """
Get all members of a group by group name Get all members of a group by group name
""" """
return self._get_paginated_list(f"/groups/{group_name}/members.json", "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): def add_group_member(self, groupid, username):
""" """
@ -1285,8 +1188,7 @@ class DiscourseClient:
""" """
return self._put( return self._put(
f"/groups/{groupid}/members.json", "/admin/groups/{0}/members.json".format(groupid), usernames=username
usernames=username,
) )
def add_group_members(self, groupid, usernames): def add_group_members(self, groupid, usernames):
@ -1306,9 +1208,7 @@ class DiscourseClient:
""" """
usernames = ",".join(usernames) usernames = ",".join(usernames)
return self._put( return self._put(
f"/groups/{groupid}/members.json", "/admin/groups/{0}/members.json".format(groupid), usernames=usernames
usernames=usernames,
json=True,
) )
def add_user_to_group(self, groupid, userid): def add_user_to_group(self, groupid, userid):
@ -1326,9 +1226,9 @@ class DiscourseClient:
DiscourseError if user is already member of group DiscourseError if user is already member of group
""" """
return self._post(f"/admin/users/{userid}/groups", group_id=groupid) 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 Deletes a member from a group by user ID
@ -1336,13 +1236,15 @@ class DiscourseClient:
Args: Args:
groupid: the ID of the group groupid: the ID of the group
username: the user name of the user userid: the ID of the user
Returns: Returns:
JSON API response JSON API response
""" """
return self._request(DELETE, f"/groups/{groupid}/members.json", json={"usernames": username}) return self._delete(
"/admin/groups/{0}/members.json".format(groupid), user_id=userid
)
def color_schemes(self, **kwargs): def color_schemes(self, **kwargs):
""" """
@ -1374,7 +1276,9 @@ class DiscourseClient:
kwargs["enabled"] = "true" kwargs["enabled"] = "true"
else: else:
kwargs["enabled"] = "false" kwargs["enabled"] = "false"
kwargs["colors"] = [{"name": name, "hex": color} for name, color in colors.items()] kwargs["colors"] = [
{"name": name, "hex": color} for name, color in colors.items()
]
kwargs = {"color_scheme": kwargs} kwargs = {"color_scheme": kwargs}
return self._post("/admin/color_schemes.json", json=True, **kwargs) return self._post("/admin/color_schemes.json", json=True, **kwargs)
@ -1416,7 +1320,7 @@ class DiscourseClient:
kwargs["locked"] = "true" kwargs["locked"] = "true"
else: else:
kwargs["locked"] = "false" kwargs["locked"] = "false"
return self._put(f"/admin/users/{user_id}/trust_level_lock", **kwargs) return self._put("/admin/users/{}/trust_level_lock".format(user_id), **kwargs)
def block(self, user_id, **kwargs): def block(self, user_id, **kwargs):
""" """
@ -1429,23 +1333,23 @@ class DiscourseClient:
Returns: Returns:
""" """
return self._put(f"/admin/users/{user_id}/block", **kwargs) return self._put("/admin/users/{}/block".format(user_id), **kwargs)
def upload_image(self, image, upload_type, synchronous, **kwargs): def upload_image(self, image, type, synchronous, **kwargs):
""" """
Upload image or avatar Upload image or avatar
Args: Args:
name: name:
file: file:
upload_type: one of "avatar" "profile_background" "card_background" "custom_emoji" "composer" type:
synchronous: synchronous:
**kwargs: **kwargs:
Returns: Returns:
""" """
kwargs["type"] = upload_type kwargs["type"] = type
if bool(synchronous): if bool(synchronous):
kwargs["synchronous"] = "true" kwargs["synchronous"] = "true"
else: else:
@ -1453,20 +1357,20 @@ class DiscourseClient:
files = {"file": open(image, "rb")} files = {"file": open(image, "rb")}
return self._post("/uploads.json", files=files, **kwargs) return self._post("/uploads.json", files=files, **kwargs)
def user_actions(self, username, actions_filter, offset=0, **kwargs): def user_actions(self, username, filter, offset=0, **kwargs):
""" """
List all possible user actions List all possible user actions
Args: Args:
username: username:
actions_filter: filter:
**kwargs: **kwargs:
Returns: Returns:
""" """
kwargs["username"] = username kwargs["username"] = username
kwargs["filter"] = actions_filter kwargs["filter"] = filter
kwargs["offset"] = offset kwargs["offset"] = offset
return self._get("/user_actions.json", **kwargs)["user_actions"] return self._get("/user_actions.json", **kwargs)["user_actions"]
@ -1495,8 +1399,7 @@ class DiscourseClient:
https://github.com/discourse/discourse-data-explorer https://github.com/discourse/discourse-data-explorer
""" """
return self._post( return self._post(
f"/admin/plugins/explorer/queries/{query_id}/run", "/admin/plugins/explorer/queries/{}/run".format(query_id), **kwargs
**kwargs,
) )
def notifications(self, category_id, **kwargs): def notifications(self, category_id, **kwargs):
@ -1509,13 +1412,7 @@ class DiscourseClient:
notification_level=(int) notification_level=(int)
""" """
return self._post(f"/category/{category_id}/notifications", **kwargs) return self._post("/category/{}/notifications".format(category_id), **kwargs)
def about(self):
"""
Get site info
"""
return self._get("/about.json")
def _get(self, path, override_request_kwargs=None, **kwargs): def _get(self, path, override_request_kwargs=None, **kwargs):
""" """
@ -1528,13 +1425,10 @@ class DiscourseClient:
""" """
return self._request( return self._request(
GET, GET, path, params=kwargs, override_request_kwargs=override_request_kwargs
path,
params=kwargs,
override_request_kwargs=override_request_kwargs,
) )
def _put(self, path, *, json=False, override_request_kwargs=None, **kwargs): def _put(self, path, json=False, override_request_kwargs=None, **kwargs):
""" """
Args: Args:
@ -1546,28 +1440,16 @@ class DiscourseClient:
""" """
if not json: if not json:
return self._request( return self._request(
PUT, PUT, path, data=kwargs, override_request_kwargs=override_request_kwargs
path,
data=kwargs,
override_request_kwargs=override_request_kwargs,
) )
else: else:
return self._request( return self._request(
PUT, PUT, path, json=kwargs, override_request_kwargs=override_request_kwargs
path,
json=kwargs,
override_request_kwargs=override_request_kwargs,
) )
def _post( def _post(
self, self, path, files=None, json=False, override_request_kwargs=None, **kwargs
path,
*,
files=None,
json=False,
override_request_kwargs=None,
**kwargs,
): ):
""" """
@ -1607,10 +1489,7 @@ class DiscourseClient:
""" """
return self._request( return self._request(
DELETE, DELETE, path, params=kwargs, override_request_kwargs=override_request_kwargs
path,
params=kwargs,
override_request_kwargs=override_request_kwargs,
) )
def _request( def _request(
@ -1672,12 +1551,12 @@ class DiscourseClient:
break break
if not response.ok: if not response.ok:
try: try:
msg = ",".join(response.json()["errors"]) msg = u",".join(response.json()["errors"])
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):
if response.reason: if response.reason:
msg = response.reason msg = response.reason
else: else:
msg = f"{response.status_code}: {response.text}" msg = u"{0}: {1}".format(response.status_code, response.text)
if 400 <= response.status_code < 500: if 400 <= response.status_code < 500:
if 429 == response.status_code: if 429 == response.status_code:
@ -1685,21 +1564,26 @@ class DiscourseClient:
content_type = response.headers.get("Content-Type") content_type = response.headers.get("Content-Type")
if content_type is not None and "application/json" in content_type: if content_type is not None and "application/json" in content_type:
ret = response.json() ret = response.json()
wait_delay = retry_backoff + ret["extras"]["wait_seconds"] # how long to back off for. wait_delay = (
retry_backoff + ret["extras"]["wait_seconds"]
) # how long to back off for.
else: else:
# We got an early 429 error without a proper JSON body # We got an early 429 error without a proper JSON body
ret = response.content ret = response.content
wait_delay = retry_backoff + 10 wait_delay = retry_backoff + 10
limit_name = response.headers.get("Discourse-Rate-Limit-Error-Code", "<unknown>") limit_name = response.headers.get(
"Discourse-Rate-Limit-Error-Code", "<unknown>")
log.info( log.info(
f"We have been rate limited (limit: {limit_name}) and will wait {wait_delay} seconds ({retry_count} retries left)", "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: if retry_count > 1:
time.sleep(wait_delay) time.sleep(wait_delay)
retry_count -= 1 retry_count -= 1
log.debug(f"API returned {ret}") log.debug("API returned {0}".format(ret))
continue continue
else: else:
raise DiscourseClientError(msg, response=response) raise DiscourseClientError(msg, response=response)
@ -1715,8 +1599,7 @@ class DiscourseClient:
if response.status_code == 302: if response.status_code == 302:
raise DiscourseError( raise DiscourseError(
"Unexpected Redirect, invalid api key or host?", "Unexpected Redirect, invalid api key or host?", response=response
response=response,
) )
json_content = "application/json; charset=utf-8" json_content = "application/json; charset=utf-8"
@ -1727,22 +1610,24 @@ class DiscourseClient:
return None return None
raise DiscourseError( raise DiscourseError(
f'Invalid Response, expecting "{json_content}" got "{content_type}"', 'Invalid Response, expecting "{0}" got "{1}"'.format(
json_content, content_type
),
response=response, response=response,
) )
try: try:
decoded = response.json() decoded = response.json()
except ValueError as err: except ValueError:
raise DiscourseError("failed to decode response", response=response) from err raise DiscourseError("failed to decode response", response=response)
# Checking "errors" length because # Checking "errors" length because
# data-explorer (e.g. POST /admin/plugins/explorer/queries/{}/run) # noqa: ERA001 # data-explorer (e.g. POST /admin/plugins/explorer/queries/{}/run)
# sends an empty errors array # sends an empty errors array
if "errors" in decoded and len(decoded["errors"]) > 0: if "errors" in decoded and len(decoded["errors"]) > 0:
message = decoded.get("message") message = decoded.get("message")
if not message: if not message:
message = ",".join(decoded["errors"]) message = u",".join(decoded["errors"])
raise DiscourseError(message, response=response) raise DiscourseError(message, response=response)
return decoded return decoded

View File

@ -38,7 +38,7 @@ class DiscourseCmd(cmd.Cmd):
return method(*args, **kwargs) return method(*args, **kwargs)
except DiscourseError as e: except DiscourseError as e:
sys.stderr.write(f"{e}, {e.response.text}\n") print(e, e.response.text)
return e.response return e.response
return wrapper return wrapper
@ -57,11 +57,7 @@ class DiscourseCmd(cmd.Cmd):
"""Writes output of the command to console""" """Writes output of the command to console"""
try: try:
json.dump( json.dump(
result, result, self.output, sort_keys=True, indent=4, separators=(",", ": ")
self.output,
sort_keys=True,
indent=4,
separators=(",", ": "),
) )
except TypeError: except TypeError:
self.output.write(result.text) self.output.write(result.text)

View File

@ -43,20 +43,18 @@ def sso_validate(payload, signature, secret):
raise DiscourseError("No SSO payload or signature.") raise DiscourseError("No SSO payload or signature.")
if not secret: if not secret:
raise DiscourseError("Invalid secret.") raise DiscourseError("Invalid secret..")
payload = unquote(payload) payload = unquote(payload)
if not payload: if not payload:
raise DiscourseError("Invalid payload.") raise DiscourseError("Invalid payload..")
decoded = b64decode(payload.encode("utf-8")).decode("utf-8") decoded = b64decode(payload.encode("utf-8")).decode("utf-8")
if "nonce" not in decoded: if "nonce" not in decoded:
raise DiscourseError("Invalid payload.") raise DiscourseError("Invalid payload..")
h = hmac.new( h = hmac.new(
secret.encode("utf-8"), secret.encode("utf-8"), payload.encode("utf-8"), digestmod=hashlib.sha256
payload.encode("utf-8"),
digestmod=hashlib.sha256,
) )
this_signature = h.hexdigest() this_signature = h.hexdigest()
@ -94,7 +92,7 @@ def sso_redirect_url(nonce, secret, email, external_id, username, **kwargs):
"email": email, "email": email,
"external_id": external_id, "external_id": external_id,
"username": username, "username": username,
}, }
) )
return "/session/sso_login?%s" % sso_payload(secret, **kwargs) return "/session/sso_login?%s" % sso_payload(secret, **kwargs)

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,211 @@
"""Tests for the client methods.""" import sys
import unittest
import urllib.parse from unittest import mock
import pytest from pydiscourse import client
def test_empty_content_http_ok(discourse_host, discourse_client, discourse_request): if sys.version_info < (3,):
"""Empty content should not raise error
Critical to test against *bytestrings* rather than unicode def b(x):
""" return x
discourse_request(
"get",
"/users/admin/1/unsuspend", else:
headers={"Content-Type": "text/plain; charset=utf-8"}, import codecs
content=b" ",
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)
headers = kwargs["headers"]
self.assertEqual(headers.pop("Api-Username"), self.api_username)
self.assertEqual(headers.pop("Api-Key"), self.api_key)
if verb == "GET":
self.assertEqual(kwargs["params"], params)
class TestUserManagement: class TestClientRequests(ClientBaseTestCase):
def test_get_user(self, discourse_host, discourse_client, discourse_request): """
request = discourse_request( Tests for common request handling
"get", """
"/users/someuser.json",
json={"user": "someuser"}, @mock.patch("pydiscourse.client.requests")
def test_empty_content_http_ok(self, mocked_requests):
"""Empty content should not raise error
Critical to test against *bytestrings* rather than unicode
"""
mocked_response = mock.MagicMock()
mocked_response.content = b(" ")
mocked_response.status_code = 200
mocked_response.headers = {"content-type": "text/plain; charset=utf-8"}
assert "content-type" in mocked_response.headers
mocked_requests.request = mock.MagicMock()
mocked_requests.request.return_value = mocked_response
resp = self.client._request("GET", "/users/admin/1/unsuspend", {})
self.assertIsNone(resp)
@mock.patch("requests.request")
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"
) )
discourse_client.user("someuser") self.assertEqual(request.call_count, 2)
assert request.called_once # XXX incomplete
def test_users(self, discourse_client, discourse_request): def test_update_email(self, request):
request = discourse_request("get", "/admin/users/list/active.json") prepare_response(request)
discourse_client.users() email = "test@example.com"
assert request.called_once self.client.update_email("someuser", email)
self.assertRequestCalled(
def test_create_user(self, discourse_host, discourse_client, discourse_request): request, "PUT", "/users/someuser/preferences/email", email=email
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",
) )
assert session_request.called_once def test_update_user(self, request):
assert user_request.called_once prepare_response(request)
self.client.update_user("someuser", a="a", b="b")
self.assertRequestCalled(request, "PUT", "/users/someuser", a="a", b="b")
def test_update_email(self, discourse_host, discourse_client, discourse_request): def test_update_username(self, request):
request = discourse_request("put", "/users/someuser/preferences/email") prepare_response(request)
discourse_client.update_email("someuser", "newmeail@example.com") self.client.update_username("someuser", "newname")
self.assertRequestCalled(
assert request.called_once request, "PUT", "/users/someuser/preferences/username", username="newname"
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_by_external_id(self, request):
prepare_response(request)
self.client.by_external_id(123)
self.assertRequestCalled(request, "GET", "/users/by-external/123")
def test_anonymize(self, discourse_client, discourse_request): def test_suspend_user(self, request):
request = discourse_request("put", "/admin/users/123/anonymize") prepare_response(request)
discourse_client.anonymize(123) self.client.suspend(123, 1, "Testing")
self.assertRequestCalled(
assert request.called_once request, "PUT", "/admin/users/123/suspend", duration=1, reason="Testing"
@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
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
def test_latest_topics(self, discourse_client, discourse_request):
request = discourse_request("get", "/latest.json")
discourse_client.latest_topics()
assert request.called_once
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_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_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_unsuspend_user(self, request):
prepare_response(request)
self.client.unsuspend(123)
self.assertRequestCalled(request, "PUT", "/admin/users/123/unsuspend")
def test_invite_user_to_topic(self, discourse_client, discourse_request): def test_user_bagdes(self, request):
request = discourse_request("post", "/t/22/invite.json") prepare_response(request)
discourse_client.invite_user_to_topic("test@example.com", 22) self.client.user_badges("username")
assert request.called_once self.assertRequestCalled(
request, "GET", "/user-badges/{}.json".format("username")
request_payload = urllib.parse.parse_qs(request.last_request.text)
assert request_payload["email"] == ["test@example.com"]
assert request_payload["topic_id"] == ["22"]
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
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
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
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: @mock.patch("requests.request")
def test_badges(self, discourse_client, discourse_request): class TestTopics(ClientBaseTestCase):
request = discourse_request("get", "/admin/badges.json")
discourse_client.badges()
assert request.called_once
def test_grant_badge_to(self, discourse_client, discourse_request): def test_hot_topics(self, request):
request = discourse_request("post", "/user_badges") prepare_response(request)
discourse_client.grant_badge_to("username", 1) self.client.hot_topics()
self.assertRequestCalled(request, "GET", "/hot.json")
request_payload = urllib.parse.parse_qs(request.last_request.text) def test_latest_topics(self, request):
prepare_response(request)
self.client.latest_topics()
self.assertRequestCalled(request, "GET", "/latest.json")
assert request_payload["username"] == ["username"] def test_new_topics(self, request):
assert request_payload["badge_id"] == ["1"] 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
)
class TestAbout: @mock.patch("pydiscourse.client.requests.request")
def test_about(self, discourse_client, discourse_request): class MiscellaneousTests(ClientBaseTestCase):
request = discourse_request("get", "/about.json")
discourse_client.about() def test_search(self, request):
assert request.called_once 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
)

View File

@ -1,95 +1,76 @@
from base64 import b64decode from base64 import b64decode
import unittest
from urllib.parse import unquote from urllib.parse import unquote
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
import pytest
from pydiscourse import sso from pydiscourse import sso
from pydiscourse.exceptions import DiscourseError from pydiscourse.exceptions import DiscourseError
def test_sso_validate_missing_payload(): class SSOTestCase(unittest.TestCase):
with pytest.raises(DiscourseError) as excinfo:
sso.sso_validate(None, "abc", "123")
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(): def test_missing_args(self):
with pytest.raises(DiscourseError) as excinfo: with self.assertRaises(DiscourseError):
sso.sso_validate("", "abc", "123") 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(): def test_invalid_signature(self):
with pytest.raises(DiscourseError) as excinfo: with self.assertRaises(DiscourseError):
sso.sso_validate("sig", None, "123") 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, ""]) self.assertIn("/session/sso_login", url[:20])
def test_sso_validate_missing_secret(bad_secret):
with pytest.raises(DiscourseError) as excinfo:
sso.sso_validate("payload", "signature", bad_secret)
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): self.assertEqual(
with pytest.raises(DiscourseError) as excinfo: payload,
sso.sso_validate("Ym9i", sso_signature, sso_secret) {
"username": self.username,
assert excinfo.value.args[0] == "Invalid payload." "nonce": self.nonce,
"external_id": self.external_id,
"name": self.name,
def test_sso_validate_invalid_payload_nonce(sso_payload, sso_secret): "email": self.email,
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,
}

24
tox.ini
View File

@ -1,28 +1,30 @@
[tox] [tox]
envlist = py38, py39, py310, py311 envlist = py37, py38, py39, py310
[gh-actions] [gh-actions]
python = python =
3.7: py37
3.8: py38 3.8: py38
3.9: py39 3.9: py39
3.10: py310 3.10: py310
3.11: py311
3.12: py312
[testenv] [testenv]
setenv = setenv =
PYTHONPATH = {toxinidir}:{toxinidir}/pydiscourse PYTHONPATH = {toxinidir}:{toxinidir}/pydiscourse
commands = commands = pytest {posargs} --cov=pydiscourse
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 = deps =
-r{toxinidir}/requirements.txt -r{toxinidir}/requirements.txt
[testenv:ruff] [testenv:flake8]
basepython=python basepython=python
skip_install=true
deps= deps=
ruff flake8
flake8_docstrings
commands= commands=
ruff . flake8 src/pydiscourse --docstring-convention google --ignore D415
[flake8]
ignore = E126,E128
max-line-length = 119
exclude = .ropeproject
max-complexity = 10