Compare commits

...

43 Commits

Author SHA1 Message Date
Samuël WEBER
6ee6fc8b88 docs: rename bennylope -> pydiscourse in readme 2025-02-25 11:33:51 -05:00
Samuël WEBER
7d05b1c42c docs: add pypi badge in readme 2025-02-25 11:33:51 -05:00
Samuël WEBER
b03c9738f1 docs: readme code highlighted 2025-02-25 11:33:29 -05:00
Ben Lopatin
36f8f1e737
Merge pull request #93 from pydiscourse/v1.7
Bump version
2024-04-16 16:34:57 -04:00
Ben Lopatin
2d27cc2a3c Update History 2024-04-16 16:32:22 -04:00
Ben Lopatin
f81c1b775a Add support for Python 3.12 2024-04-16 16:31:58 -04:00
Ben Lopatin
270f4bbbdb Bump version 2024-04-16 16:31:17 -04:00
Samuël WEBER
7d981684aa add anonymize endpoint 2024-02-24 13:26:43 -05:00
Samuël WEBER
53be265b23 fix: search term moved to q 2023-09-27 11:17:01 -04:00
Samuël WEBER
a07b122c39 add about endpoint for site statistics 2023-09-07 10:57:28 -04:00
Ben Lopatin
6d54153a24
Merge pull request #90 from pydiscourse/new-version
Update version for release
2023-09-01 10:37:25 -04:00
Ben Lopatin
c30a453a6b Bump version 2023-09-01 10:35:28 -04:00
Ben Lopatin
3e87ef3849
Merge pull request #89 from pydiscourse/use-discourse-request
Replace mocks and increase coverage requirement
2023-09-01 10:23:50 -04:00
Ben Lopatin
0f1cc41967 Bump up coverage requirement 2023-09-01 10:21:48 -04:00
Ben Lopatin
7ca7f47e94 Replace requests_mock with discourse_request
Requires just slightly less upfront configuration in each test.
2023-09-01 10:21:11 -04:00
Ben Lopatin
d8ec2f61aa
Merge pull request #88 from Dettorer/add-endpoint-post-by-number
Add a wrapper for the /posts/by_number endpoint
2023-09-01 10:08:46 -04:00
Paul Dettorer Hervot
f94d861c64 Add a wrapper for the /posts/by_number endpoint 2023-09-01 11:57:11 +02:00
Ben Lopatin
565b714d81
Merge pull request #87 from pydiscourse/bennylope-patch-1
Delete .coveragerc
2023-08-31 21:10:41 -04:00
Ben Lopatin
e1a9038789
Delete .coveragerc
Inapplicable now
2023-08-31 21:08:30 -04:00
Ben Lopatin
b38f99bffe
Merge pull request #86 from pydiscourse/add-ruff
Add ruff and cleanup code
2023-08-31 18:10:24 -04:00
Ben Lopatin
4d20bfd79c Update history and version 2023-08-31 18:09:02 -04:00
Ben Lopatin
99359aa518 Ignore formatting and simple rule-based changes 2023-08-31 18:03:15 -04:00
Ben Lopatin
ea7d47c5da Add 'bad' update to test actions 2023-08-31 17:59:38 -04:00
Ben Lopatin
fcdd6ab8dd Add linting rule 2023-08-31 17:56:09 -04:00
Ben Lopatin
c850e34112 Add ruff for general use 2023-08-31 17:53:51 -04:00
Ben Lopatin
ee21957027 Add ruff and black pre-commit 2023-08-31 17:47:11 -04:00
Ben Lopatin
acb0af24d1 Handle commented code 2023-08-31 17:46:35 -04:00
Ben Lopatin
31db8017bc Remove unnecessary dict 2023-08-31 17:46:18 -04:00
Ben Lopatin
5d28665fbb Raise exception from original ValueError 2023-08-31 17:45:53 -04:00
Ben Lopatin
51771057f5 Make arguments keyword only
Applies where boolean defaults are included
2023-08-31 17:40:49 -04:00
Ben Lopatin
d7e249847a Replace shadowed builtin names 2023-08-31 17:37:51 -04:00
Ben Lopatin
b348fe13f3 Use stderr instead of print 2023-08-31 17:32:57 -04:00
Ben Lopatin
30f9b9b338 Inject fixture 2023-08-31 17:30:10 -04:00
Ben Lopatin
815bfd8af6 Remove encoding 2023-08-31 17:27:28 -04:00
Ben Lopatin
2be1a46c1d Format code 2023-08-31 17:24:12 -04:00
Ben Lopatin
2aac9a20be Use f-strings 2023-08-31 17:23:54 -04:00
Ben Lopatin
fe4f67c041 Use relative values 2023-08-31 17:22:02 -04:00
Ben Lopatin
6c1d9a9e1d Use data from fixture
Makes it explicit that this data is used here, plus makes the line
shorter.
2023-08-31 17:21:05 -04:00
Ben Lopatin
7ab58533b7 Remove unnecessary parens 2023-08-31 17:18:22 -04:00
Ben Lopatin
dc498679cc Remove explict object inheritance 2023-08-31 17:17:34 -04:00
Ben Lopatin
c49d29620d Remove unicode literal 2023-08-31 17:17:04 -04:00
Ben Lopatin
2a3036f039 Add trailing commas 2023-08-31 17:15:40 -04:00
Ben Lopatin
14b2f7a08d
Merge pull request #85 from pydiscourse/cleanup
Clean up tests, Python support
2023-08-31 16:17:55 -04:00
19 changed files with 486 additions and 285 deletions

View File

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

View File

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

View File

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

19
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,19 @@
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

@ -3,6 +3,30 @@
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
-----

View File

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

View File

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

49
pyproject.toml Normal file
View File

@ -0,0 +1,49 @@
[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,3 +1,5 @@
pre-commit==3.3.3
ruff==0.0.286
pytest==7.4.0
pytest-cov==4.1.0
pytest-mock==3.11.1 # https://github.com/pytest-dev/pytest-mock/

View File

@ -29,6 +29,7 @@ classifiers =
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Programming Language :: Python :: 3.12
[options.packages.find]
where=src

View File

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

View File

@ -1,6 +1,6 @@
"""Python client for the Discourse API."""
__version__ = "1.5.1"
__version__ = "1.7.0"
from pydiscourse.client import DiscourseClient

View File

@ -35,7 +35,7 @@ def now() -> datetime:
return datetime.utcnow()
class DiscourseClient(object):
class DiscourseClient:
"""Discourse API client"""
def __init__(self, host, api_username, api_key, timeout=None):
@ -70,16 +70,16 @@ class DiscourseClient(object):
dict of user information
"""
return self._get("/users/{0}.json".format(username))["user"]
return self._get(f"/users/{username}.json")["user"]
def approve(self, user_id):
return self._get("/admin/users/{0}/approve.json".format(user_id))
return self._get(f"/admin/users/{user_id}/approve.json")
def activate(self, user_id):
return self._put("/admin/users/{0}/activate.json".format(user_id))
return self._put(f"/admin/users/{user_id}/activate.json")
def deactivate(self, user_id):
return self._put("/admin/users/{0}/deactivate.json".format(user_id))
return self._put(f"/admin/users/{user_id}/deactivate.json")
def user_all(self, user_id):
"""
@ -90,7 +90,7 @@ class DiscourseClient(object):
Returns:
dict of user information
"""
return self._get("/admin/users/{0}.json".format(user_id))
return self._get(f"/admin/users/{user_id}.json")
def invite(self, email, group_names, custom_message, **kwargs):
"""
@ -111,7 +111,7 @@ class DiscourseClient(object):
email=email,
group_names=group_names,
custom_message=custom_message,
**kwargs
**kwargs,
)
def invite_link(self, email, group_names, custom_message, **kwargs):
@ -133,7 +133,7 @@ class DiscourseClient(object):
email=email,
group_names=group_names,
custom_message=custom_message,
**kwargs
**kwargs,
)
def user_by_id(self, pk):
@ -146,7 +146,7 @@ class DiscourseClient(object):
Returns:
user
"""
return self._get("/admin/users/{0}.json".format(pk))
return self._get(f"/admin/users/{pk}.json")
def user_by_email(self, email):
"""
@ -158,7 +158,7 @@ class DiscourseClient(object):
Returns:
user
"""
return self._get("/admin/users/list/all.json?email={0}".format(email))
return self._get(f"/admin/users/list/all.json?email={email}")
def create_user(self, name, username, email, password, **kwargs):
"""
@ -190,7 +190,7 @@ class DiscourseClient(object):
password=password,
password_confirmation=confirmations,
challenge=challenge,
**kwargs
**kwargs,
)
def user_by_external_id(self, external_id):
@ -202,7 +202,7 @@ class DiscourseClient(object):
Returns:
"""
response = self._get("/users/by-external/{0}".format(external_id))
response = self._get(f"/users/by-external/{external_id}")
return response["user"]
by_external_id = user_by_external_id
@ -216,7 +216,7 @@ class DiscourseClient(object):
Returns:
"""
return self._post("/admin/users/{0}/log_out".format(userid))
return self._post(f"/admin/users/{userid}/log_out")
def trust_level(self, userid, level):
"""
@ -228,7 +228,18 @@ class DiscourseClient(object):
Returns:
"""
return self._put("/admin/users/{0}/trust_level".format(userid), level=level)
return self._put(f"/admin/users/{userid}/trust_level", 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):
"""
@ -246,7 +257,7 @@ class DiscourseClient(object):
"""
suspend_until = (now() + timedelta(days=duration)).isoformat()
return self._put(
"/admin/users/{0}/suspend".format(userid),
f"/admin/users/{userid}/suspend",
suspend_until=suspend_until,
reason=reason,
)
@ -261,21 +272,21 @@ class DiscourseClient(object):
Returns:
None???
"""
return self._put("/admin/users/{0}/unsuspend".format(userid))
return self._put(f"/admin/users/{userid}/unsuspend")
def list_users(self, type, **kwargs):
def list_users(self, user_type, **kwargs):
"""
optional user search: filter='test@example.com' or filter='scott'
Args:
type:
user_type:
**kwargs:
Returns:
"""
return self._get("/admin/users/list/{0}.json".format(type), **kwargs)
return self._get(f"/admin/users/list/{user_type}.json", **kwargs)
def update_avatar_from_url(self, username, url, **kwargs):
"""
@ -289,7 +300,9 @@ class DiscourseClient(object):
"""
return self._post(
"/users/{0}/preferences/avatar".format(username), file=url, **kwargs
f"/users/{username}/preferences/avatar",
file=url,
**kwargs,
)
def update_avatar_image(self, username, img, **kwargs):
@ -307,10 +320,12 @@ class DiscourseClient(object):
"""
files = {"file": img}
return self._post(
"/users/{0}/preferences/avatar".format(username), files=files, **kwargs
f"/users/{username}/preferences/avatar",
files=files,
**kwargs,
)
def toggle_gravatar(self, username, state=True, **kwargs):
def toggle_gravatar(self, username, *, state=True, **kwargs):
"""
Args:
@ -321,14 +336,14 @@ class DiscourseClient(object):
Returns:
"""
url = "/users/{0}/preferences/avatar/toggle".format(username)
url = f"/users/{username}/preferences/avatar/toggle"
if bool(state):
kwargs["use_uploaded_avatar"] = "true"
else:
kwargs["use_uploaded_avatar"] = "false"
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:
@ -340,7 +355,7 @@ class DiscourseClient(object):
Returns:
"""
url = "/users/{0}/preferences/avatar/pick".format(username)
url = f"/users/{username}/preferences/avatar/pick"
return self._put(url, **kwargs)
def update_avatar(self, username, url, **kwargs):
@ -358,9 +373,9 @@ class DiscourseClient(object):
kwargs["synchronous"] = "true"
upload_response = self._post("/uploads", url=url, **kwargs)
return self._put(
"/users/{0}/preferences/avatar/pick".format(username),
f"/users/{username}/preferences/avatar/pick",
upload_id=upload_response["id"],
**kwargs
**kwargs,
)
def update_email(self, username, email, **kwargs):
@ -375,7 +390,9 @@ class DiscourseClient(object):
"""
return self._put(
"/users/{0}/preferences/email".format(username), email=email, **kwargs
f"/users/{username}/preferences/email",
email=email,
**kwargs,
)
def update_user(self, username, **kwargs):
@ -388,7 +405,7 @@ class DiscourseClient(object):
Returns:
"""
return self._put("/users/{0}".format(username), json=True, **kwargs)
return self._put(f"/users/{username}", json=True, **kwargs)
def update_username(self, username, new_username, **kwargs):
"""
@ -402,9 +419,9 @@ class DiscourseClient(object):
"""
return self._put(
"/users/{0}/preferences/username".format(username),
f"/users/{username}/preferences/username",
new_username=new_username,
**kwargs
**kwargs,
)
def set_preference(self, username=None, **kwargs):
@ -419,7 +436,7 @@ class DiscourseClient(object):
"""
if username is None:
username = self.api_username
return self._put(u"/users/{0}".format(username), **kwargs)
return self._put(f"/users/{username}", **kwargs)
def sync_sso(self, **kwargs):
"""
@ -435,7 +452,7 @@ class DiscourseClient(object):
"""
sso_secret = kwargs.pop("sso_secret")
payload = sso_payload(sso_secret, **kwargs)
return self._post("/admin/users/sync_sso?{0}".format(payload), **kwargs)
return self._post(f"/admin/users/sync_sso?{payload}", **kwargs)
def generate_api_key(self, userid, **kwargs):
"""
@ -447,7 +464,7 @@ class DiscourseClient(object):
Returns:
"""
return self._post("/admin/users/{0}/generate_api_key".format(userid), **kwargs)
return self._post(f"/admin/users/{userid}/generate_api_key", **kwargs)
def delete_user(self, userid, **kwargs):
"""
@ -463,22 +480,22 @@ class DiscourseClient(object):
Returns:
"""
return self._delete("/admin/users/{0}.json".format(userid), **kwargs)
return self._delete(f"/admin/users/{userid}.json", **kwargs)
def users(self, filter=None, **kwargs):
def users(self, filter_name=None, **kwargs):
"""
Args:
filter:
filter_name:
**kwargs:
Returns:
"""
if filter is None:
filter = "active"
if filter_name is None:
filter_name = "active"
return self._get("/admin/users/list/{0}.json".format(filter), **kwargs)
return self._get(f"/admin/users/list/{filter_name}.json", **kwargs)
def private_messages(self, username=None, **kwargs):
"""
@ -492,7 +509,7 @@ class DiscourseClient(object):
"""
if username is None:
username = self.api_username
return self._get("/topics/private-messages/{0}.json".format(username), **kwargs)
return self._get(f"/topics/private-messages/{username}.json", **kwargs)
def private_messages_unread(self, username=None, **kwargs):
"""
@ -507,7 +524,8 @@ class DiscourseClient(object):
if username is None:
username = self.api_username
return self._get(
"/topics/private-messages-unread/{0}.json".format(username), **kwargs
f"/topics/private-messages-unread/{username}.json",
**kwargs,
)
def category_topics(self, category_id, **kwargs):
@ -522,9 +540,9 @@ class DiscourseClient(object):
"""
return self._get(
"/c/{0}.json".format(category_id),
f"/c/{category_id}.json",
override_request_kwargs={"allow_redirects": True},
**kwargs
**kwargs,
)
# Doesn't work on recent Discourse versions (2014+)
@ -582,7 +600,7 @@ class DiscourseClient(object):
Returns:
"""
return self._get("/t/{0}/{1}.json".format(slug, topic_id), **kwargs)
return self._get(f"/t/{slug}/{topic_id}.json", **kwargs)
def delete_topic(self, topic_id, **kwargs):
"""
@ -596,7 +614,7 @@ class DiscourseClient(object):
JSON API response
"""
return self._delete(u"/t/{0}".format(topic_id), **kwargs)
return self._delete(f"/t/{topic_id}", **kwargs)
def post(self, topic_id, post_id, **kwargs):
"""
@ -609,7 +627,7 @@ class DiscourseClient(object):
Returns:
"""
return self._get("/t/{0}/{1}.json".format(topic_id, post_id), **kwargs)
return self._get(f"/t/{topic_id}/{post_id}.json", **kwargs)
def post_action_users(self, post_id, post_action_type_id=None, **kwargs):
"""
@ -639,7 +657,20 @@ class DiscourseClient(object):
Returns:
post
"""
return self._get("/posts/{0}.json".format(post_id), **kwargs)
return self._get(f"/posts/{post_id}.json", **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):
"""
@ -655,7 +686,7 @@ class DiscourseClient(object):
"""
if post_ids:
kwargs["post_ids[]"] = post_ids
return self._get("/t/{0}/posts.json".format(topic_id), **kwargs)
return self._get(f"/t/{topic_id}/posts.json", **kwargs)
def latest_posts(self, before=None, **kwargs):
"""
@ -690,7 +721,7 @@ class DiscourseClient(object):
kwargs["topic_id"] = topic_id
kwargs["topic_time"] = time
for post_num, timing in timings.items():
kwargs["timings[{0}]".format(post_num)] = timing
kwargs[f"timings[{post_num}]"] = timing
return self._post("/topics/timings", **kwargs)
@ -704,7 +735,7 @@ class DiscourseClient(object):
Returns:
"""
return self._get("/t/{0}/posts.json".format(topic_id), **kwargs)
return self._get(f"/t/{topic_id}/posts.json", **kwargs)
def update_topic(self, topic_url, title, **kwargs):
"""
@ -719,10 +750,16 @@ class DiscourseClient(object):
"""
kwargs["title"] = title
return self._put("{}".format(topic_url), **kwargs)
return self._put(f"{topic_url}", **kwargs)
def create_post(
self, content, category_id=None, topic_id=None, title=None, tags=[], **kwargs
self,
content,
category_id=None,
topic_id=None,
title=None,
tags=[],
**kwargs,
):
"""
@ -745,7 +782,7 @@ class DiscourseClient(object):
title=title,
raw=content,
topic_id=topic_id,
**kwargs
**kwargs,
)
def update_topic_status(self, topic_id, status, enabled, **kwargs):
@ -766,7 +803,7 @@ class DiscourseClient(object):
kwargs["enabled"] = "true"
else:
kwargs["enabled"] = "false"
return self._put("/t/{0}/status".format(topic_id), **kwargs)
return self._put(f"/t/{topic_id}/status", **kwargs)
def update_post(self, post_id, content, edit_reason="", **kwargs):
"""
@ -782,7 +819,7 @@ class DiscourseClient(object):
"""
kwargs["post[raw]"] = content
kwargs["post[edit_reason]"] = edit_reason
return self._put("/posts/{0}".format(post_id), **kwargs)
return self._put(f"/posts/{post_id}", **kwargs)
def reset_bump_date(self, topic_id, **kwargs):
"""
@ -790,7 +827,7 @@ class DiscourseClient(object):
See https://meta.discourse.org/t/what-is-a-bump/105562
"""
return self._put("/t/{0}/reset-bump-date".format(topic_id), **kwargs)
return self._put(f"/t/{topic_id}/reset-bump-date", **kwargs)
def topics_by(self, username, **kwargs):
"""
@ -802,7 +839,7 @@ class DiscourseClient(object):
Returns:
"""
url = "/topics/created-by/{0}.json".format(username)
url = f"/topics/created-by/{username}.json"
return self._get(url, **kwargs)["topic_list"]["topics"]
def invite_user_to_topic(self, user_email, topic_id):
@ -816,19 +853,19 @@ class DiscourseClient(object):
"""
kwargs = {"email": user_email, "topic_id": topic_id}
return self._post("/t/{0}/invite.json".format(topic_id), **kwargs)
return self._post(f"/t/{topic_id}/invite.json", **kwargs)
def search(self, term, **kwargs):
def search(self, q, **kwargs):
"""
Args:
term:
q:
**kwargs:
Returns:
"""
kwargs["term"] = term
kwargs["q"] = q
return self._get("/search.json", **kwargs)
def badges(self, **kwargs):
@ -854,7 +891,10 @@ class DiscourseClient(object):
"""
return self._post(
"/user_badges", username=username, badge_id=badge_id, **kwargs
"/user_badges",
username=username,
badge_id=badge_id,
**kwargs,
)
def user_badges(self, username, **kwargs):
@ -866,7 +906,7 @@ class DiscourseClient(object):
Returns:
"""
return self._get("/user-badges/{}.json".format(username))
return self._get(f"/user-badges/{username}.json")
def user_emails(self, username, **kwargs):
"""
@ -878,10 +918,16 @@ class DiscourseClient(object):
Returns:
"""
return self._get("/u/{}/emails.json".format(username))
return self._get(f"/u/{username}/emails.json")
def create_category(
self, name, color, text_color="FFFFFF", permissions=None, parent=None, **kwargs
self,
name,
color,
text_color="FFFFFF",
permissions=None,
parent=None,
**kwargs,
):
"""
@ -905,7 +951,7 @@ class DiscourseClient(object):
permissions = {"everyone": "1"}
for key, value in permissions.items():
kwargs["permissions[{0}]".format(key)] = value
kwargs[f"permissions[{key}]"] = value
if parent:
parent_id = None
@ -915,7 +961,7 @@ class DiscourseClient(object):
continue
if not parent_id:
raise DiscourseClientError(u"{0} not found".format(parent))
raise DiscourseClientError(f"{parent} not found")
kwargs["parent_category_id"] = parent_id
@ -943,7 +989,7 @@ class DiscourseClient(object):
"""
return self._get(u"/c/{0}/show.json".format(category_id), **kwargs)
return self._get(f"/c/{category_id}/show.json", **kwargs)
def update_category(self, category_id, **kwargs):
"""
@ -955,7 +1001,7 @@ class DiscourseClient(object):
Returns:
"""
return self._put("/categories/{0}".format(category_id), json=True, **kwargs)
return self._put(f"/categories/{category_id}", json=True, **kwargs)
def delete_category(self, category_id, **kwargs):
"""
@ -968,7 +1014,7 @@ class DiscourseClient(object):
Returns:
"""
return self._delete(u"/categories/{0}".format(category_id), **kwargs)
return self._delete(f"/categories/{category_id}", **kwargs)
def get_site_info(self):
"""
@ -987,8 +1033,8 @@ class DiscourseClient(object):
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)
name = f"{parent}/{name}"
return self._get(f"/c/{name}/l/latest.json", **kwargs)
def site_settings(self, **kwargs):
"""
@ -1003,7 +1049,9 @@ class DiscourseClient(object):
for setting, value in kwargs.items():
setting = setting.replace(" ", "_")
self._request(
PUT, "/admin/site_settings/{0}".format(setting), {setting: value}
PUT,
f"/admin/site_settings/{setting}",
{setting: value},
)
def customize_site_texts(self, site_texts, **kwargs):
@ -1020,7 +1068,9 @@ class DiscourseClient(object):
for site_text, value in site_texts.items():
kwargs = {"site_text": {"value": value}}
self._put(
"/admin/customize/site_texts/{0}".format(site_text), json=True, **kwargs
f"/admin/customize/site_texts/{site_text}",
json=True,
**kwargs,
)
def groups(self, **kwargs):
@ -1074,11 +1124,12 @@ class DiscourseClient(object):
"""
Get all infos of a group by group name
"""
return self._get("/groups/{0}.json".format(group_name))
return self._get(f"/groups/{group_name}.json")
def create_group(
self,
name,
*,
title="",
visible=True,
alias_level=0,
@ -1090,7 +1141,7 @@ class DiscourseClient(object):
flair_url=None,
flair_bg_color=None,
flair_color=None,
**kwargs
**kwargs,
):
"""
Args:
@ -1116,9 +1167,7 @@ class DiscourseClient(object):
kwargs["automatic_membership_retroactive"] = automatic_membership_retroactive
kwargs["primary_group"] = primary_group
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["flair_url"] = flair_url
kwargs["flair_bg_color"] = flair_bg_color
@ -1139,7 +1188,7 @@ class DiscourseClient(object):
JSON API response
"""
return self._delete("/admin/groups/{0}.json".format(groupid))
return self._delete(f"/admin/groups/{groupid}.json")
def add_group_owner(self, groupid, username):
"""
@ -1169,7 +1218,8 @@ class DiscourseClient(object):
"""
usernames = ",".join(usernames)
return self._put(
"/groups/{0}/owners.json".format(groupid), **{"usernames": usernames}
f"/groups/{groupid}/owners.json",
usernames=usernames,
)
def delete_group_owner(self, groupid, userid):
@ -1187,14 +1237,15 @@ class DiscourseClient(object):
"""
return self._delete(
"/admin/groups/{0}/owners.json".format(groupid), user_id=userid
f"/admin/groups/{groupid}/owners.json",
user_id=userid,
)
def group_owners(self, group_name):
"""
Get all owners of a group by group name
"""
group = self._get("/groups/{0}/members.json".format(group_name))
group = self._get(f"/groups/{group_name}/members.json")
return group["owners"]
def _get_paginated_list(self, url, name, offset, **kwargs):
@ -1216,9 +1267,7 @@ class DiscourseClient(object):
"""
Get all members of a group by group name
"""
return self._get_paginated_list(
"/groups/{0}/members.json".format(group_name),
"members", offset, **kwargs)
return self._get_paginated_list(f"/groups/{group_name}/members.json", "members", offset, **kwargs)
def add_group_member(self, groupid, username):
"""
@ -1236,7 +1285,8 @@ class DiscourseClient(object):
"""
return self._put(
"/groups/{0}/members.json".format(groupid), usernames=username
f"/groups/{groupid}/members.json",
usernames=username,
)
def add_group_members(self, groupid, usernames):
@ -1256,7 +1306,8 @@ class DiscourseClient(object):
"""
usernames = ",".join(usernames)
return self._put(
"/groups/{0}/members.json".format(groupid), usernames=usernames,
f"/groups/{groupid}/members.json",
usernames=usernames,
json=True,
)
@ -1275,7 +1326,7 @@ class DiscourseClient(object):
DiscourseError if user is already member of group
"""
return self._post("/admin/users/{0}/groups".format(userid), group_id=groupid)
return self._post(f"/admin/users/{userid}/groups", group_id=groupid)
def delete_group_member(self, groupid, username):
"""
@ -1291,10 +1342,7 @@ class DiscourseClient(object):
JSON API response
"""
return self._request(
DELETE, "/groups/{0}/members.json".format(groupid),
json={"usernames": username})
return self._request(DELETE, f"/groups/{groupid}/members.json", json={"usernames": username})
def color_schemes(self, **kwargs):
"""
@ -1326,9 +1374,7 @@ class DiscourseClient(object):
kwargs["enabled"] = "true"
else:
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}
return self._post("/admin/color_schemes.json", json=True, **kwargs)
@ -1370,7 +1416,7 @@ class DiscourseClient(object):
kwargs["locked"] = "true"
else:
kwargs["locked"] = "false"
return self._put("/admin/users/{}/trust_level_lock".format(user_id), **kwargs)
return self._put(f"/admin/users/{user_id}/trust_level_lock", **kwargs)
def block(self, user_id, **kwargs):
"""
@ -1383,23 +1429,23 @@ class DiscourseClient(object):
Returns:
"""
return self._put("/admin/users/{}/block".format(user_id), **kwargs)
return self._put(f"/admin/users/{user_id}/block", **kwargs)
def upload_image(self, image, type, synchronous, **kwargs):
def upload_image(self, image, upload_type, synchronous, **kwargs):
"""
Upload image or avatar
Args:
name:
file:
type:
upload_type: one of "avatar" "profile_background" "card_background" "custom_emoji" "composer"
synchronous:
**kwargs:
Returns:
"""
kwargs["type"] = type
kwargs["type"] = upload_type
if bool(synchronous):
kwargs["synchronous"] = "true"
else:
@ -1407,20 +1453,20 @@ class DiscourseClient(object):
files = {"file": open(image, "rb")}
return self._post("/uploads.json", files=files, **kwargs)
def user_actions(self, username, filter, offset=0, **kwargs):
def user_actions(self, username, actions_filter, offset=0, **kwargs):
"""
List all possible user actions
Args:
username:
filter:
actions_filter:
**kwargs:
Returns:
"""
kwargs["username"] = username
kwargs["filter"] = filter
kwargs["filter"] = actions_filter
kwargs["offset"] = offset
return self._get("/user_actions.json", **kwargs)["user_actions"]
@ -1449,7 +1495,8 @@ class DiscourseClient(object):
https://github.com/discourse/discourse-data-explorer
"""
return self._post(
"/admin/plugins/explorer/queries/{}/run".format(query_id), **kwargs
f"/admin/plugins/explorer/queries/{query_id}/run",
**kwargs,
)
def notifications(self, category_id, **kwargs):
@ -1462,7 +1509,13 @@ class DiscourseClient(object):
notification_level=(int)
"""
return self._post("/category/{}/notifications".format(category_id), **kwargs)
return self._post(f"/category/{category_id}/notifications", **kwargs)
def about(self):
"""
Get site info
"""
return self._get("/about.json")
def _get(self, path, override_request_kwargs=None, **kwargs):
"""
@ -1475,10 +1528,13 @@ class DiscourseClient(object):
"""
return self._request(
GET, path, params=kwargs, override_request_kwargs=override_request_kwargs
GET,
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:
@ -1490,16 +1546,28 @@ class DiscourseClient(object):
"""
if not json:
return self._request(
PUT, path, data=kwargs, override_request_kwargs=override_request_kwargs
PUT,
path,
data=kwargs,
override_request_kwargs=override_request_kwargs,
)
else:
return self._request(
PUT, path, json=kwargs, override_request_kwargs=override_request_kwargs
PUT,
path,
json=kwargs,
override_request_kwargs=override_request_kwargs,
)
def _post(
self, path, files=None, json=False, override_request_kwargs=None, **kwargs
self,
path,
*,
files=None,
json=False,
override_request_kwargs=None,
**kwargs,
):
"""
@ -1539,7 +1607,10 @@ class DiscourseClient(object):
"""
return self._request(
DELETE, path, params=kwargs, override_request_kwargs=override_request_kwargs
DELETE,
path,
params=kwargs,
override_request_kwargs=override_request_kwargs,
)
def _request(
@ -1601,12 +1672,12 @@ class DiscourseClient(object):
break
if not response.ok:
try:
msg = u",".join(response.json()["errors"])
msg = ",".join(response.json()["errors"])
except (ValueError, TypeError, KeyError):
if response.reason:
msg = response.reason
else:
msg = u"{0}: {1}".format(response.status_code, response.text)
msg = f"{response.status_code}: {response.text}"
if 400 <= response.status_code < 500:
if 429 == response.status_code:
@ -1614,26 +1685,21 @@ class DiscourseClient(object):
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.
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
limit_name = response.headers.get(
"Discourse-Rate-Limit-Error-Code", "<unknown>")
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
)
f"We have been rate limited (limit: {limit_name}) and will wait {wait_delay} seconds ({retry_count} retries left)",
)
if retry_count > 1:
time.sleep(wait_delay)
retry_count -= 1
log.debug("API returned {0}".format(ret))
log.debug(f"API returned {ret}")
continue
else:
raise DiscourseClientError(msg, response=response)
@ -1649,7 +1715,8 @@ class DiscourseClient(object):
if response.status_code == 302:
raise DiscourseError(
"Unexpected Redirect, invalid api key or host?", response=response
"Unexpected Redirect, invalid api key or host?",
response=response,
)
json_content = "application/json; charset=utf-8"
@ -1660,24 +1727,22 @@ class DiscourseClient(object):
return None
raise DiscourseError(
'Invalid Response, expecting "{0}" got "{1}"'.format(
json_content, content_type
),
f'Invalid Response, expecting "{json_content}" got "{content_type}"',
response=response,
)
try:
decoded = response.json()
except ValueError:
raise DiscourseError("failed to decode response", response=response)
except ValueError as err:
raise DiscourseError("failed to decode response", response=response) from err
# Checking "errors" length because
# data-explorer (e.g. POST /admin/plugins/explorer/queries/{}/run)
# data-explorer (e.g. POST /admin/plugins/explorer/queries/{}/run) # noqa: ERA001
# sends an empty errors array
if "errors" in decoded and len(decoded["errors"]) > 0:
message = decoded.get("message")
if not message:
message = u",".join(decoded["errors"])
message = ",".join(decoded["errors"])
raise DiscourseError(message, response=response)
return decoded

View File

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

View File

@ -54,7 +54,9 @@ def sso_validate(payload, signature, secret):
raise DiscourseError("Invalid payload.")
h = hmac.new(
secret.encode("utf-8"), payload.encode("utf-8"), digestmod=hashlib.sha256
secret.encode("utf-8"),
payload.encode("utf-8"),
digestmod=hashlib.sha256,
)
this_signature = h.hexdigest()
@ -92,7 +94,7 @@ def sso_redirect_url(nonce, secret, email, external_id, username, **kwargs):
"email": email,
"external_id": external_id,
"username": username,
}
},
)
return "/session/sso_login?%s" % sso_payload(secret, **kwargs)

View File

@ -47,8 +47,8 @@ def email():
@pytest.fixture(scope="session")
def redirect_url():
return "/session/sso_login?sso=bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGImbmFtZT1z%0AYW0mdXNlcm5hbWU9c2Ftc2FtJmVtYWlsPXRlc3QlNDB0ZXN0LmNvbSZleHRl%0Acm5hbF9pZD1oZWxsbzEyMw%3D%3D%0A&sig=1c884222282f3feacd76802a9dd94e8bc8deba5d619b292bed75d63eb3152c0b"
def redirect_url(sso_payload):
return f"/session/sso_login?sso={sso_payload}YW0mdXNlcm5hbWU9c2Ftc2FtJmVtYWlsPXRlc3QlNDB0ZXN0LmNvbSZleHRl%0Acm5hbF9pZD1oZWxsbzEyMw%3D%3D%0A&sig=1c884222282f3feacd76802a9dd94e8bc8deba5d619b292bed75d63eb3152c0b"
@pytest.fixture(scope="session")
@ -69,15 +69,23 @@ def discourse_api_key():
@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
discourse_host,
discourse_api_username,
discourse_api_key,
)
@pytest.fixture
def frozen_time(mocker):
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
2023,
8,
13,
12,
30,
15,
tzinfo=datetime.timezone.utc,
)

View File

@ -2,14 +2,17 @@
import urllib.parse
import pytest
def test_empty_content_http_ok(discourse_host, discourse_client, requests_mock):
def test_empty_content_http_ok(discourse_host, discourse_client, discourse_request):
"""Empty content should not raise error
Critical to test against *bytestrings* rather than unicode
"""
requests_mock.get(
f"{discourse_host}/users/admin/1/unsuspend",
discourse_request(
"get",
"/users/admin/1/unsuspend",
headers={"Content-Type": "text/plain; charset=utf-8"},
content=b" ",
)
@ -22,25 +25,31 @@ def test_empty_content_http_ok(discourse_host, discourse_client, requests_mock):
class TestUserManagement:
def test_get_user(self, discourse_host, discourse_client, discourse_request):
request = discourse_request(
"get", "/users/someuser.json", json={"user": "someuser"}
"get",
"/users/someuser.json",
json={"user": "someuser"},
)
discourse_client.user("someuser")
assert request.called_once
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"},
def test_users(self, discourse_client, discourse_request):
request = discourse_request("get", "/admin/users/list/active.json")
discourse_client.users()
assert request.called_once
def test_create_user(self, discourse_host, discourse_client, discourse_request):
session_request = discourse_request(
"get",
"/session/hp.json",
json={"challenge": "challenge", "value": "value"},
)
user_request = requests_mock.post(
f"{discourse_host}/users",
headers={"Content-Type": "application/json; charset=utf-8"},
json={},
)
user_request = discourse_request("post", "/users")
discourse_client.create_user(
"Test User", "testuser", "test@example.com", "notapassword"
"Test User",
"testuser",
"test@example.com",
"notapassword",
)
assert session_request.called_once
@ -66,13 +75,22 @@ class TestUserManagement:
def test_by_external_id(self, discourse_client, discourse_request):
request = discourse_request(
"get", "/users/by-external/123", json={"user": "123"}
"get",
"/users/by-external/123",
json={"user": "123"},
)
discourse_client.by_external_id(123)
assert request.called_once
def test_suspend_user(self, discourse_client, discourse_request, frozen_time):
def test_anonymize(self, discourse_client, discourse_request):
request = discourse_request("put", "/admin/users/123/anonymize")
discourse_client.anonymize(123)
assert request.called_once
@pytest.mark.usefixtures("_frozen_time")
def test_suspend_user(self, discourse_client, discourse_request):
request = discourse_request("put", "/admin/users/123/suspend")
discourse_client.suspend(123, 1, "Testing")
@ -98,59 +116,39 @@ class TestUserManagement:
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_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, requests_mock):
request = requests_mock.get(
f"{discourse_client.host}/latest.json",
headers={"Content-Type": "application/json; charset=utf-8"},
json={},
)
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, requests_mock):
request = requests_mock.get(
f"{discourse_client.host}/new.json",
headers={"Content-Type": "application/json; charset=utf-8"},
json={},
)
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, 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={},
)
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, requests_mock):
request = requests_mock.get(
f"{discourse_client.host}/topics/created-by/someuser.json",
headers={"Content-Type": "application/json; charset=utf-8"},
def test_topics_by(self, discourse_client, discourse_request):
request = discourse_request(
"get",
"/topics/created-by/someuser.json",
json={"topic_list": {"topics": []}},
)
discourse_client.topics_by("someuser")
assert request.called_once
def test_invite_user_to_topic(self, discourse_client, requests_mock):
request = requests_mock.post(
f"{discourse_client.host}/t/22/invite.json",
headers={"Content-Type": "application/json; charset=utf-8"},
json={},
)
def test_invite_user_to_topic(self, discourse_client, discourse_request):
request = discourse_request("post", "/t/22/invite.json")
discourse_client.invite_user_to_topic("test@example.com", 22)
assert request.called_once
@ -160,41 +158,37 @@ class TestTopics:
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={},
)
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_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")
def test_post_by_number(self, discourse_client, discourse_request):
request = discourse_request("get", "/posts/by_number/8796/5")
discourse_client.post_by_number(8796, 5)
assert request.called_once
def test_categories(self, discourse_client, requests_mock):
request = requests_mock.get(
f"{discourse_client.host}/categories.json",
headers={"Content-Type": "application/json; charset=utf-8"},
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, 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={},
)
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()
@ -202,33 +196,25 @@ class TestEverything:
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={},
)
class TestBadges:
def test_badges(self, discourse_client, discourse_request):
request = discourse_request("get", "/admin/badges.json")
discourse_client.badges()
assert request.called_once
def test_grant_badge_to(self, discourse_client, requests_mock):
request = requests_mock.post(
f"{discourse_client.host}/user_badges",
headers={"Content-Type": "application/json; charset=utf-8"},
json={},
)
def test_grant_badge_to(self, discourse_client, discourse_request):
request = discourse_request("post", "/user_badges")
discourse_client.grant_badge_to("username", 1)
request_payload = urllib.parse.parse_qs(request.last_request.text)
assert request_payload["username"] == ["username"]
assert request_payload["badge_id"] == ["1"]
class TestAbout:
def test_about(self, discourse_client, discourse_request):
request = discourse_request("get", "/about.json")
discourse_client.about()
assert request.called_once

View File

@ -57,7 +57,13 @@ def test_valid_nonce(sso_payload, sso_signature, sso_secret, sso_nonce):
def test_valid_redirect_url(
sso_secret, sso_nonce, name, email, username, external_id, redirect_url
sso_secret,
sso_nonce,
name,
email,
username,
external_id,
redirect_url,
):
url = sso.sso_redirect_url(
sso_nonce,
@ -78,7 +84,7 @@ def test_valid_redirect_url(
# 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("&")))
payload = dict(p.split("=") for p in payload.split("&"))
assert payload == {
"username": username,

17
tox.ini
View File

@ -7,27 +7,22 @@ python =
3.9: py39
3.10: py310
3.11: py311
3.12: py312
[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/client.py' --fail-under=46
coverage report -m --include='**/pydiscourse/sso.py' --fail-under=100
deps =
-r{toxinidir}/requirements.txt
[testenv:flake8]
[testenv:ruff]
basepython=python
skip_install=true
deps=
flake8
flake8_docstrings
ruff
commands=
flake8 src/pydiscourse --docstring-convention google --ignore D415
[flake8]
ignore = E126,E128
max-line-length = 119
exclude = .ropeproject
max-complexity = 10
ruff .