Compare commits

...

105 Commits

Author SHA1 Message Date
Karl Goetz 7898ff3ff1 Merge pull request #69 from themotleyfool/add-latest-posts
Add endpoint to fetch latest posts across topics
2023-01-25 21:51:55 +11:00
Max Lancaster 9709744b33 Add endpoint to fetch latest posts across topics
Added endpoint for looking up latest posts across topics, see Discourse api documentation here:

https://docs.discourse.org/#tag/Posts/operation/listPosts
2023-01-24 18:21:42 -05:00
Karl Goetz 22e236a009 Merge pull request #72 from bennylope/test-against-python311
Add Python 3.11 to test matrix
2023-01-25 07:23:35 +11:00
Ben Lopatin 0857a9cfe7 Add Python 3.11 to test matrix 2023-01-22 16:53:04 -05:00
Ben Lopatin 30e2068b4d Merge pull request #70 from themotleyfool/fix-github-action-display
Fix python version display in github actions
2023-01-22 16:51:53 -05:00
Ben Lopatin 227924f098 Merge pull request #68 from inducer/rate-limit-improvements
Rate limit improvements
2023-01-22 16:48:05 -05:00
Max Lancaster 201ff3d717 Fix python version display in github actions
test.yml uses the wrong python version variable which is causing to not display the title correctly in the github actions interface, see github documentation here:

https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#using-the-python-starter-workflow
2023-01-17 19:33:35 -05:00
Andreas Kloeckner 393422f964 Rate limiting: print limit name, log before waiting 2022-12-14 17:06:17 -06:00
Andreas Kloeckner d9e0af7e59 Rate-limited: do not fail if no Content-Type header 2022-12-14 17:05:51 -06:00
Ben Lopatin 227c3fb469 Bump version and add changelog 2022-07-28 20:09:22 -04:00
Ben Lopatin 0378a38d87 Merge pull request #65 from Natureshadow/add-group-owners
Add group owners
2022-07-28 20:04:58 -04:00
Ben Lopatin 5c12e06e58 Merge pull request #62 from Natureshadow/fix-429-not-json
Handle HTTP 429 errors that are not JSON-encoded
2022-07-28 20:04:43 -04:00
Ben Lopatin 27363e5fa7 Merge pull request #63 from Natureshadow/fix-add-group-owner
Use new API for add_group_owner
2022-07-28 20:03:10 -04:00
Dominik George 8be16c34ff Allow adding multiple group owners by list 2022-07-28 22:26:38 +02:00
Dominik George 8971629bcb Use new API for add_group_owner
Closes #61
2022-07-28 22:22:03 +02:00
Dominik George 7e237c6b68 Handle HTTP 429 errors that are not JSON-encoded
Closes #60
2022-07-28 22:19:11 +02:00
Ben Lopatin 062904fd2a Fix URL
Must not have quotes...
2022-04-18 14:15:56 -04:00
Ben Lopatin 0d1eb1b816 Update history for release 2022-04-18 14:08:47 -04:00
Ben Lopatin 457abc559f Merge pull request #58 from Sebastian2023/fix_category
Fix getting category details
2022-04-18 14:03:31 -04:00
Sebastian Scherbel 3b7b7d2490 Fix getting category details 2022-04-18 14:02:25 -04:00
Ben Lopatin 1d74e2f1b7 Merge pull request #59 from Sebastian2023/add_site
Add option to fetch all categories including subcategories
2022-04-18 13:57:26 -04:00
Sebastian Scherbel e254926726 Add option to fetch all categories including subcategories 2022-04-18 09:33:03 +02:00
Ben Lopatin ff40a1c8c0 Correct license 2021-12-18 16:27:15 -05:00
Ben Lopatin 7054b9118f Merge pull request #54 from bennylope/python-updates
Update supported Python versions
2021-12-18 16:26:31 -05:00
Ben Lopatin b5ccf244a5 Ignore blame for moving to src, formatting 2021-12-18 16:24:31 -05:00
Ben Lopatin 901a53a10d Add missing console scripts 2021-12-18 16:22:12 -05:00
Ben Lopatin 71cb943e55 Update flake8 config 2021-12-18 16:18:05 -05:00
Ben Lopatin f6b4c02fc0 Format with black 2021-12-18 16:16:47 -05:00
Ben Lopatin 4eaff3a790 Update docstrings 2021-12-18 15:31:53 -05:00
Ben Lopatin 864b1b047f Add module docstring and __all__ definition 2021-12-18 15:31:03 -05:00
Ben Lopatin 69bdc5f76f Add and format docstrings 2021-12-18 15:26:15 -05:00
Ben Lopatin 27c76de371 Add docstrings 2021-12-18 15:25:20 -05:00
Ben Lopatin baaa049dc6 Fix flake8 path 2021-12-18 15:17:37 -05:00
Ben Lopatin bbe216ef8c Remove quotes from versions
Doesn't seem to be actually running tests...
2021-12-18 15:14:13 -05:00
Ben Lopatin d9a5c081a9 Use tox-gh-actions 2021-12-18 15:10:09 -05:00
Ben Lopatin f61ffdbcdb Install explicitly before testing 2021-12-18 15:06:47 -05:00
Ben Lopatin 8a0e742abd Update tests to use pytest 2021-12-18 15:05:43 -05:00
Ben Lopatin 1a22796e8e Use setup.cfg for metadata 2021-12-18 14:55:46 -05:00
Ben Lopatin 0177c46356 Move package into src directory 2021-12-18 14:55:46 -05:00
Ben Lopatin ef5f8523d8 Update tox environments 2021-12-18 14:42:50 -05:00
Ben Lopatin 96f9ea4b50 Remove Python 2 compatibility checks 2021-12-18 14:41:33 -05:00
Ben Lopatin 9b11c7d06a Use strings for version identifiers 2021-12-18 14:36:14 -05:00
Ben Lopatin 20c1915cbe Drop support for Python 2.7, 3.5, 3.6
Python 2.7 is EOL
Python 3.5 is EOL
Python 3.6 is EOL in 5 days
2021-12-18 14:31:45 -05:00
Ben Lopatin d5b9aacf01 Remove Travis config 2021-12-18 14:29:41 -05:00
Ben Lopatin bc8a2907b9 Merge pull request #53 from inducer/post-actions
Add post_action_users to see who liked a post
2021-12-18 14:28:11 -05:00
Andreas Kloeckner 1f595c3e7f Add post_action_users 2021-12-17 18:12:12 -06:00
Karl Goetz 188decb02a Merge pull request #50 from Ircam-Web/master
fix(errors): handle data-explorer responses
2021-02-12 15:32:35 +11:00
Martin Desrumaux f4bd3e3b17 fix(errors): handle data-explorer responses 2021-02-11 23:43:00 +01:00
Karl Goetz f74722dfb8 Merge pull request #49 from Ircam-Web/master
Implement new routes
2021-02-03 06:09:16 +11:00
Martin Desrumaux d101264391 fix(request): remove errors len check 2021-02-02 14:36:23 +01:00
Martin Desrumaux 3e94eaee05 fix(request): handle empty html documents 2021-01-22 14:12:20 +01:00
Martin Desrumaux 5763ba6ee8 docs(category): Add deprecation notice 2021-01-22 04:28:57 +01:00
Martin Desrumaux 099993a379 replace ' with " 2021-01-22 03:19:07 +01:00
Martin Desrumaux d887772b30 merge upstream 2021-01-22 03:11:09 +01:00
Raphaël Yancey e5d1ef2f02 Added notifications() method 2021-01-22 02:12:16 +01:00
Raphaël Voyazopoulos ee2769d0b9 Added category_latest_topics() method 2021-01-22 02:10:59 +01:00
Raphaël Voyazopoulos b2f6e1df96 Added delete_topic() method 2021-01-22 02:09:27 +01:00
Raphaël Voyazopoulos 0008bfdf0a Added top_topics() method 2021-01-22 02:09:27 +01:00
Raphaël Voyazopoulos 2eb6d672a0 Added user_by_id() method 2021-01-22 02:09:27 +01:00
Raphaël Voyazopoulos a68cb0244f Added data_explorer_query() method 2021-01-22 02:09:23 +01:00
Raphaël Voyazopoulos c0566f2aad Handling case where server returns empty errors prop 2021-01-22 02:07:15 +01:00
Raphaël Voyazopoulos fc6a78c948 Added user_by_email() method 2021-01-22 02:06:26 +01:00
Raphaël Voyazopoulos df30e1acc8 Added user_emails() method 2021-01-22 02:06:26 +01:00
Raphaël Voyazopoulos ec730ec026 Added empty response b'' as possible (not failed) responses 2021-01-22 02:06:23 +01:00
Raphaël Voyazopoulos 7fce4dc129 Added reset-bump-date endpoint 2021-01-21 18:05:31 +01:00
Raphaël Voyazopoulos 2bdfdb85ec Added get_site_settings() method 2021-01-21 18:05:28 +01:00
Raphaël Voyazopoulos f27ed47206 Added the delete_category method 2021-01-21 17:51:09 +01:00
Ben Lopatin cc9f35b5f3 Merge pull request #40 from kirstaylo/patch-1
Update client.py
2020-11-10 18:45:18 -05:00
Ben Lopatin 689e0981a0 Merge branch 'master' into patch-1 2020-11-10 18:39:37 -05:00
Ben Lopatin 712f9282b1 Version bump 1.1.2 2020-11-10 18:06:02 -05:00
Ben Lopatin b69a142811 Add blame ignore file 2020-11-10 18:03:57 -05:00
Ben Lopatin ce7038b05d Fix version and add history 2020-11-10 18:03:57 -05:00
Ben Lopatin ffbd47868d Merge pull request #47 from dkgv/master
Fix endpoint used for user creation (closes #46)
2020-11-10 17:26:53 -05:00
Gustav 5040b24dcc Fix endpoint used for user creation (closes #46)
See https://review.discourse.org/t/fix-move-hp-request-from-users-to-token-10795/15871
2020-11-10 13:26:48 +01:00
Ben Lopatin e7906a0568 Merge pull request #44 from dkgv/pypi-publishing
Add workflow to publish package to PyPI on tag/release (fixes #43)
2020-10-07 09:25:22 -04:00
Gustav 11a82695c5 Add workflow to publish package to PyPI on tag/release (fixes #43) 2020-10-04 22:16:42 +02:00
kirstaylo f1e7ee069c Update client.py
To avoid the error DiscourseClientError: param is missing or the value is empty: new_username
2020-08-31 12:39:04 +01:00
Alex Kerney 6e31953118 Fix 413 response from Discourse due to empty dict passed as json 2020-08-11 13:17:52 -04:00
Christian Kindel 2ad158e195 Fix call to get group info by name 2020-08-10 21:13:26 -04:00
Ben Lopatin 719035e9a9 Fix classifier error 2020-07-21 17:22:55 -04:00
Ben Lopatin 3a4af08827 Bump version 1.1.0 2020-07-21 17:21:10 -04:00
Alex Kerney 5d334f1d80 Use immutable arguments and adjust naming to simplify 2020-07-21 17:14:54 -04:00
Alex Kerney 361bf77949 Allow client methods to override requests kwargs
Due to some changes in the Discourse API, certain methods now work better if redirects are allowed.

Get /c/{id}.json will redirect to /c/{category_slug}.json which will cause the client.category_topics(id) method to fail as the redirect is not followed by default.

Now the keyword arguments to requests.request can be overridden by individual methods. This is implemented for .category_topics
2020-07-21 17:14:54 -04:00
Alex Kerney aeb763c42c Authenticate via headers
Closes bennylope/pydiscourse#27
2019-12-04 18:09:00 -05:00
Ben Lopatin 69867b3c10 Run test workflow on pull request 2019-10-13 18:30:40 -04:00
Ben Lopatin c3ae5b3c76 Update return documentation 2019-10-13 18:12:23 -04:00
Ben Lopatin d02ab15d3f Formatting 2019-10-13 18:10:12 -04:00
Richard Leyton 9a8641e596 Added invite() and invite_link() methods 2019-10-12 18:09:52 +01:00
Ben Lopatin 802f018519 Replace bad link with route documentation
Closes gh-173
2019-10-06 12:12:43 -04:00
Ben Lopatin be74c4e5b7 Add GitHub Actions testing workflow (#24)
* Add GitHub Actions testing workflow

* Update named Python versions and dev status

* Use unittest.mock as default mock source

Fall back to package for Python 2.7

* Try installing mock outside of setup

* Switch to GitHub actions shield
2019-10-06 12:04:51 -04:00
Ben Lopatin 9198a1d549 Version bump 0.9.0 2019-10-06 11:43:56 -04:00
Logan Kilpatrick 53cc24744f Update README.rst (#21)
Update README.rst with link to web accessible documentation.
2019-10-06 12:05:50 +11:00
Ben Lopatin 9dcf5832b6 Merge pull request #18 from Lakshmipathi/master
Fix activate and deactivate call
2019-07-21 18:21:15 -04:00
Lakshmipathi.G 10c27d6338 Fix activate and deactivate call
Signed-off-by: Lakshmipathi.G <Lakshmipathi.G@giis.co.in>
2019-07-14 12:43:37 +05:30
Ben Lopatin 89f12f707b Break out of infinite loop for ok responses 2019-04-21 13:52:14 -04:00
Karl Goetz 0cef55a02f Handle HTTP 429, rate limiting
Per the announcement on Discourse meta, global API rate limits have been
introduced to the Discourse API.
This change adds a new DiscourseRateLimitedError class and a retry mechanism on
receipt of a 429.

https://meta.discourse.org/t/global-rate-limits-in-discourse/78612

Closes: #11

----

Added by Maintainer:

Closes: #13
Formatted with black as well
2019-04-21 13:39:34 -04:00
Ben Lopatin faa8895321 Merge pull request #16 from cck197/master
extra methods for deactivate/activate hack; see https://meta.discours…
2019-04-21 13:32:18 -04:00
Ben Lopatin e434edb2ea Merge pull request #17 from orsonmmz/topics
Added category_topics() and delete_topic() methods
2019-01-02 09:37:03 -05:00
Maciej Suminski 9601d96701 Added category_topics() and delete_topic() methods 2019-01-01 14:12:33 +01:00
Christopher Kelly d5ce2d78dc extra methods for deactivate/activate hack; see https://meta.discourse.org/t/creating-active-users-via-the-api-gem/33133/3 2018-12-07 19:37:55 -08:00
Ben Lopatin 84b59d2e4f Merge pull request #15 from cck197/master
groups endpoint moved; see https://meta.discourse.org/t/group-api-emp…
2018-12-07 16:52:56 -05:00
Christopher Kelly 1970e53059 suspend uses suspend_until ISO date string 2018-12-07 12:11:25 -08:00
Christopher Kelly 0c7b60fef8 groups endpoint moved; see https://meta.discourse.org/t/group-api-empty-response/67892 2018-12-07 11:28:21 -08:00
Ben Lopatin de6e758be6 Remove Python 3.7 from Travis testing
From
https://docs.travis-ci.com/user/languages/python/#development-releases-support:

> Recent Python branches require OpenSSL 1.0.2+. As this library is not
> available for Trusty, 3.7, 3.7-dev, 3.8-dev, and nightly do not work (or
> use outdated archive).
2018-10-29 18:35:43 -04:00
Ben Lopatin 0f2efa8e74 Update Python versions supported 2018-10-29 18:31:33 -04:00
23 changed files with 635 additions and 177 deletions
+3
View File
@@ -0,0 +1,3 @@
c0db7215c95dbd31770ade1fc6ea65aa426d4590
0177c46356b9d0fc4b93f09aab7a224643a3685e
f6b4c02fc0f144dffc88cdd48b8261a69228d2f0
+33
View File
@@ -0,0 +1,33 @@
name: Publish package to PyPI
on:
push:
branches: [master]
release:
types: [created]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
- name: Build and publish distribution
if: (github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags')) || github.event_name == 'release'
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
python setup.py sdist bdist_wheel
twine upload dist/*
+25
View File
@@ -0,0 +1,25 @@
name: Tests
on: [ push, pull_request ]
jobs:
test:
name: Test on Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11" ]
steps:
- uses: actions/checkout@v1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install tox tox-gh-actions
- name: Test with tox
run: tox
+6
View File
@@ -36,3 +36,9 @@ coverage.xml
# Sphinx documentation
docs/_build/
# Pyenv
.python-version
# PyCharm
.idea
-10
View File
@@ -1,10 +0,0 @@
sudo: false
language: python
python:
- "2.7"
- "3.4"
- "3.5"
- "pypy"
- "pypy3"
script: python setup.py test
+4 -1
View File
@@ -9,4 +9,7 @@ Scott Nixon
Jason Dorweiler
Pierre-Alain Dupont
Karl Goetz
Alex Kerney
Gustav <https://github.com/dkgv>
Sebastian2023 <https://github.com/Sebastian2023>
Dominik George <https://github.com/Natureshadow>
+6 -3
View File
@@ -43,7 +43,7 @@ Or it's slightly faster cousin `detox
Alternatively, you can run the self test with the following commands::
pip install -r requirements.dev.txt
pip install -r requirements.txt
pip install -e .
python setup.py test
@@ -69,5 +69,8 @@ Once running you can access the Discourse install at http://localhost:4000.
TODO
====
Refer to, https://github.com/discourse/discourse_api/blob/master/routes.txt for
a list of all operations available in Discourse.
For a list of all operations:
you can just run rake routes inside of the discourse repo to get an up to date list
Or check the old [`routes.txt`](https://github.com/discourse/discourse_api/blob/aa75df6cd851f0666f9e8071c4ef9dfdd39fc8f8/routes.txt) file, though this is certainly outdated.
+41
View File
@@ -3,6 +3,47 @@
Release history
===============
1.3.0
-----
- Add fix for handling global Discourse timeouts
- Add group owners
- Update API for add_group_owner
1.2.0
-----
- BREAKING? Dropped support for Python 2.7, 3.4, 3.5
- Added numerous new endpoint queries
- Updated category querying
1.1.2
-----
- Fix for Discourse users API change
1.1.1
-----
- Fix for empty dictionary and 413 API response
- Fix for getting member groups
1.1.0
-----
- Added ability to follow redirects in requests
1.0.0
-----
- Authenticate with headers
0.9.0
-----
- Added rate limiting support
- Added some support for user activation
0.8.0
-----
+7 -2
View File
@@ -2,9 +2,14 @@
pydiscourse
===========
.. image:: https://secure.travis-ci.org/bennylope/pydiscourse.svg?branch=master
.. image:: https://github.com/bennylope/pydiscourse/workflows/Tests/badge.svg
:alt: Build Status
:target: http://travis-ci.org/bennylope/pydiscourse
:target: https://github.com/bennylope/pydiscourse/actions
.. image:: https://img.shields.io/badge/Check%20out%20the-Docs-blue.svg
:alt: Check out the Docs
:target: https://discourse.readthedocs.io/en/latest/
A Python library for working with Discourse.
+2 -2
View File
@@ -51,9 +51,9 @@ copyright = u'2014, Marc Sibson'
# built documents.
#
# The short X.Y version.
version = '0.8'
version = '1.1'
# The full version, including alpha/beta/rc tags.
release = '0.8.0'
release = '1.1.1'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
-5
View File
@@ -1,5 +0,0 @@
# -*- coding: utf-8 -*-
__version__ = "0.8.0"
from pydiscourse.client import DiscourseClient
-13
View File
@@ -1,13 +0,0 @@
from requests.exceptions import HTTPError
class DiscourseError(HTTPError):
""" A generic error while attempting to communicate with Discourse """
class DiscourseServerError(DiscourseError):
""" The Discourse Server encountered an error while processing the request """
class DiscourseClientError(DiscourseError):
""" An invalid request has been made """
+2
View File
@@ -0,0 +1,2 @@
pytest==6.2.5
pytest-cov==3.0.0
+46 -1
View File
@@ -1,2 +1,47 @@
[wheel]
[metadata]
name = pydiscourse
version = attr: pydiscourse.__version__
author = "Marc Sibson and contributors"
author_email = "ben@benlopatin.com"
license = "MIT"
url = https://github.com/bennylope/pydiscourse
description = "A Python library for the Discourse API"
long_description = file: README.rst, HISTORY.rst
platforms =
OS Independent
[options]
zip_safe = False
include_package_data = True
packages = find:
package_dir =
=src
install_requires =
requests>=2.4.2
typing; python_version<"3.6"
classifiers =
Development Status :: 5 - Production/Stable
Environment :: Web Environment
Intended Audience :: Developers
License :: OSI Approved :: MIT License
Operating System :: OS Independent
Programming Language :: Python
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
[options.packages.find]
where=src
[options.entry_points]
console_scripts =
pydiscoursecli = pydiscourse.main:main
[bdist_wheel]
universal = 1
[build-system]
requires =
setuptools >= "40.9.0"
wheel
+6 -48
View File
@@ -1,49 +1,7 @@
from setuptools import setup, find_packages
# -*- coding: utf-8 -*-
"""
See setup.cfg for packaging settings
"""
README = open('README.rst').read()
HISTORY = open('HISTORY.rst').read().replace('.. :changelog:', '')
with open("pydiscourse/__init__.py", "r") as module_file:
for line in module_file:
if line.startswith("__version__"):
version_string = line.split("=")[1]
VERSION = version_string.strip().replace("\"", "")
setup(
name="pydiscourse",
version=VERSION,
description="A Python library for the Discourse API",
long_description=README + '\n\n' + HISTORY,
author="Marc Sibson and contributors",
author_email="ben+pydiscourse@benlopatin.com",
license="BSD",
url="https://github.com/bennylope/pydiscourse",
packages=find_packages(exclude=["tests.*", "tests"]),
install_requires=[
'requests>=2.4.2',
],
tests_require=[
'mock',
],
test_suite='tests',
entry_points={
'console_scripts': [
'pydiscoursecli = pydiscourse.main:main'
]
},
classifiers=[
"Development Status :: 3 - Alpha",
"Environment :: Web Environment",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
'Programming Language :: Python :: Implementation :: PyPy',
],
zip_safe=False,
)
from setuptools import setup
setup()
+10
View File
@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
"""Python client for the Discourse API."""
__version__ = "1.3.0"
from pydiscourse.client import DiscourseClient
__all__ = ["DiscourseClient"]
@@ -3,11 +3,17 @@ Core API client module
"""
import logging
import time
import requests
from datetime import timedelta, datetime
from pydiscourse.exceptions import (
DiscourseError, DiscourseServerError, DiscourseClientError
DiscourseError,
DiscourseServerError,
DiscourseClientError,
DiscourseRateLimitedError,
)
from pydiscourse.sso import sso_payload
@@ -58,6 +64,15 @@ class DiscourseClient(object):
"""
return self._get("/users/{0}.json".format(username))["user"]
def approve(self, user_id):
return self._get("/admin/users/{0}/approve.json".format(user_id))
def activate(self, user_id):
return self._put("/admin/users/{0}/activate.json".format(user_id))
def deactivate(self, user_id):
return self._put("/admin/users/{0}/deactivate.json".format(user_id))
def user_all(self, user_id):
"""
Get all user information for a specific user, needs to be admin
@@ -69,6 +84,74 @@ class DiscourseClient(object):
"""
return self._get("/admin/users/{0}.json".format(user_id))
def invite(self, email, group_names, custom_message, **kwargs):
"""
Invite a user by email to join your forum
Args:
email: their email, will be used for activation and summary emails
group_names: the group names
custom_message: message to include
**kwargs: ???? what else can be sent through?
Returns:
API response body (dict)
"""
return self._post(
"/invites",
email=email,
group_names=group_names,
custom_message=custom_message,
**kwargs
)
def invite_link(self, email, group_names, custom_message, **kwargs):
"""
Generate an invite link for a user to join your forum
Args:
email: their email, will be used for activation and summary emails
group_names: the group names
custom_message: message to include
**kwargs: ???? what else can be sent through?
Returns:
Invite link
"""
return self._post(
"/invites/link",
email=email,
group_names=group_names,
custom_message=custom_message,
**kwargs
)
def user_by_id(self, pk):
"""
Get user from ID
Args:
pk: user id
Returns:
user
"""
return self._get("/admin/users/{0}.json".format(pk))
def user_by_email(self, email):
"""
Get user from email
Args:
email: user email
Returns:
user
"""
return self._get("/admin/users/list/all.json?email={0}".format(email))
def create_user(self, name, username, email, password, **kwargs):
"""
Create a Discourse user
@@ -88,7 +171,7 @@ class DiscourseClient(object):
????
"""
r = self._get("/users/hp.json")
r = self._get("/session/hp.json")
challenge = r["challenge"][::-1] # reverse challenge, discourse security check
confirmations = r["value"]
return self._post(
@@ -153,8 +236,11 @@ class DiscourseClient(object):
????
"""
suspend_until = (datetime.now() + timedelta(days=duration)).isoformat()
return self._put(
"/admin/users/{0}/suspend".format(userid), duration=duration, reason=reason
"/admin/users/{0}/suspend".format(userid),
suspend_until=suspend_until,
reason=reason,
)
def unsuspend(self, userid):
@@ -309,7 +395,7 @@ class DiscourseClient(object):
"""
return self._put(
"/users/{0}/preferences/username".format(username),
username=new_username,
new_username=new_username,
**kwargs
)
@@ -416,6 +502,25 @@ class DiscourseClient(object):
"/topics/private-messages-unread/{0}.json".format(username), **kwargs
)
def category_topics(self, category_id, **kwargs):
"""
Returns a list of all topics in a category.
Args:
**kwargs:
Returns:
JSON API response
"""
return self._get(
"/c/{0}.json".format(category_id),
override_request_kwargs={"allow_redirects": True},
**kwargs
)
# Doesn't work on recent Discourse versions (2014+)
# https://github.com/discourse/discourse_api/pull/204
def hot_topics(self, **kwargs):
"""
@@ -427,6 +532,15 @@ class DiscourseClient(object):
"""
return self._get("/hot.json", **kwargs)
def top_topics(self, **kwargs):
"""
Get top topics
Returns:
List of top topics
"""
return self._get("/top.json", **kwargs)
def latest_topics(self, **kwargs):
"""
@@ -462,6 +576,20 @@ class DiscourseClient(object):
"""
return self._get("/t/{0}/{1}.json".format(slug, topic_id), **kwargs)
def delete_topic(self, topic_id, **kwargs):
"""
Remove a topic
Args:
category_id:
**kwargs:
Returns:
JSON API response
"""
return self._delete(u"/t/{0}".format(topic_id), **kwargs)
def post(self, topic_id, post_id, **kwargs):
"""
@@ -475,6 +603,36 @@ class DiscourseClient(object):
"""
return self._get("/t/{0}/{1}.json".format(topic_id, post_id), **kwargs)
def post_action_users(self, post_id, post_action_type_id=None, **kwargs):
"""
Args:
post_id: int
post_action_type_id: Optional[int]
**kwargs:
Returns:
"""
# https://meta.discourse.org/t/getting-who-liked-a-post-from-the-api/103618
kwargs["id"] = post_id
if post_action_type_id is not None:
kwargs["post_action_type_id"] = post_action_type_id
return self._get("/post_action_users", **kwargs)
def post_by_id(self, post_id, **kwargs):
"""
Get a post from its id
Args:
post_id: id of the post
**kwargs:
Returns:
post
"""
return self._get("/posts/{0}.json".format(post_id), **kwargs)
def posts(self, topic_id, post_ids=None, **kwargs):
"""
Get a set of posts from a topic
@@ -491,6 +649,21 @@ class DiscourseClient(object):
kwargs["post_ids[]"] = post_ids
return self._get("/t/{0}/posts.json".format(topic_id), **kwargs)
def latest_posts(self, before=None, **kwargs):
"""
List latest posts across topics
Args:
before: Load posts with an id lower than this value. Useful for pagination.
**kwargs:
Returns:
"""
if before:
kwargs["before"] = before
return self._get("/posts.json", **kwargs)
def topic_timings(self, topic_id, time, timings={}, **kwargs):
"""
Set time spent reading a post
@@ -527,7 +700,7 @@ class DiscourseClient(object):
def update_topic(self, topic_url, title, **kwargs):
"""
Update a topic
Update a topic
Args:
topic_url:
@@ -603,6 +776,14 @@ class DiscourseClient(object):
kwargs["post[edit_reason]"] = edit_reason
return self._put("/posts/{0}".format(post_id), **kwargs)
def reset_bump_date(self, topic_id, **kwargs):
"""
Reset bump date
See https://meta.discourse.org/t/what-is-a-bump/105562
"""
return self._put("/t/{0}/reset-bump-date".format(topic_id), **kwargs)
def topics_by(self, username, **kwargs):
"""
@@ -702,7 +883,7 @@ class DiscourseClient(object):
text_color: hex color without number symbol
permissions: dict of 'everyone', 'admins', 'moderators', 'staff' with values of ???
parent: name of the category
parent_category_id:
parent_category_id:
**kwargs:
Returns:
@@ -743,21 +924,18 @@ class DiscourseClient(object):
"""
return self._get("/categories.json", **kwargs)["category_list"]["categories"]
def category(self, name, parent=None, **kwargs):
def category(self, category_id, parent=None, **kwargs):
"""
Args:
name:
parent:
category_id:
**kwargs:
Returns:
"""
if parent:
name = u"{0}/{1}".format(parent, name)
return self._get(u"/category/{0}.json".format(name), **kwargs)
return self._get(u"/c/{0}/show.json".format(category_id), **kwargs)
def delete_category(self, category_id, **kwargs):
"""
@@ -772,12 +950,32 @@ class DiscourseClient(object):
"""
return self._delete(u"/categories/{0}".format(category_id), **kwargs)
def get_site_info(self):
"""
Get site info to fetch all categories and subcategories
"""
return self._get("/site.json")
def get_site_settings(self):
"""
Get site settings
"""
return self._get("/admin/site_settings.json")
def category_latest_topics(self, name, parent=None, **kwargs):
"""
Get latest topics from a category
"""
if parent:
name = u"{0}/{1}".format(parent, name)
return self._get(u"/c/{0}/l/latest.json".format(name), **kwargs)
def site_settings(self, **kwargs):
"""
Update site settings
Args:
settings:
**kwargs:
**kwargs: key-value of properties to update
Returns:
@@ -850,13 +1048,13 @@ class DiscourseClient(object):
]
"""
return self._get("/admin/groups.json", **kwargs)
return self._get("/groups/search.json", **kwargs)
def group(self, group_name):
"""
Get all infos of a group by group name
"""
return self._get("/groups/{0}/members.json".format(group_name))
return self._get("/groups/{0}.json".format(group_name))
def create_group(
self,
@@ -936,7 +1134,24 @@ class DiscourseClient(object):
"""
return self._put(
"/admin/groups/{0}/owners.json".format(groupid), usernames=username
"/admin/groups/{0}/owners.json".format(groupid), **{"group[usernames]": username}
)
def add_group_owners(self, groupid, usernames):
"""
Add a list of owners to a group by usernames
Args:
groupid: the ID of the group
username: the list of new owner usernames
Returns:
JSON API response
"""
usernames = ",".join(usernames)
return self._put(
"/admin/groups/{0}/owners.json".format(groupid), **{"group[usernames]": usernames}
)
def delete_group_owner(self, groupid, userid):
@@ -1192,7 +1407,29 @@ class DiscourseClient(object):
kwargs["parent_tag_name"] = parent_tag_name
return self._post("/tag_groups", json=True, **kwargs)["tag_group"]
def _get(self, path, **kwargs):
def data_explorer_query(self, query_id, **kwargs):
"""
Run a query with database explorer plugin.
Requires discourse-data-explorer installed
https://github.com/discourse/discourse-data-explorer
"""
return self._post(
"/admin/plugins/explorer/queries/{}/run".format(query_id), **kwargs
)
def notifications(self, category_id, **kwargs):
"""
Get notifications
Args:
category_id
**kwargs:
notification_level=(int)
"""
return self._post("/category/{}/notifications".format(category_id), **kwargs)
def _get(self, path, override_request_kwargs=None, **kwargs):
"""
Args:
@@ -1202,9 +1439,11 @@ class DiscourseClient(object):
Returns:
"""
return self._request(GET, path, params=kwargs)
return self._request(
GET, path, params=kwargs, override_request_kwargs=override_request_kwargs
)
def _put(self, path, json=False, **kwargs):
def _put(self, path, json=False, override_request_kwargs=None, **kwargs):
"""
Args:
@@ -1215,12 +1454,18 @@ class DiscourseClient(object):
"""
if not json:
return self._request(PUT, path, data=kwargs)
return self._request(
PUT, path, data=kwargs, override_request_kwargs=override_request_kwargs
)
else:
return self._request(PUT, path, json=kwargs)
return self._request(
PUT, path, json=kwargs, override_request_kwargs=override_request_kwargs
)
def _post(self, path, files={}, json=False, **kwargs):
def _post(
self, path, files=None, json=False, override_request_kwargs=None, **kwargs
):
"""
Args:
@@ -1231,12 +1476,24 @@ class DiscourseClient(object):
"""
if not json:
return self._request(POST, path, files=files, data=kwargs)
return self._request(
POST,
path,
files=files,
data=kwargs,
override_request_kwargs=override_request_kwargs,
)
else:
return self._request(POST, path, files=files, json=kwargs)
return self._request(
POST,
path,
files=files,
json=kwargs,
override_request_kwargs=override_request_kwargs,
)
def _delete(self, path, **kwargs):
def _delete(self, path, override_request_kwargs=None, **kwargs):
"""
Args:
@@ -1246,9 +1503,20 @@ class DiscourseClient(object):
Returns:
"""
return self._request(DELETE, path, params=kwargs)
return self._request(
DELETE, path, params=kwargs, override_request_kwargs=override_request_kwargs
)
def _request(self, verb, path, params={}, files={}, data={}, json={}):
def _request(
self,
verb,
path,
params=None,
files=None,
data=None,
json=None,
override_request_kwargs=None,
):
"""
Executes HTTP request to API and handles response
@@ -1256,42 +1524,93 @@ class DiscourseClient(object):
verb: HTTP verb as string: GET, DELETE, PUT, POST
path: the path on the Discourse API
params: dictionary of parameters to include to the API
override_request_kwargs: dictionary of requests.request
keyword arguments to override defaults
Returns:
dictionary of response body data or None
"""
params["api_key"] = self.api_key
if "api_username" not in params:
params["api_username"] = self.api_username
override_request_kwargs = override_request_kwargs or {}
url = self.host + path
headers = {"Accept": "application/json; charset=utf-8"}
response = requests.request(
verb,
url,
allow_redirects=False,
params=params,
files=files,
data=data,
json=json,
headers=headers,
timeout=self.timeout,
)
headers = {
"Accept": "application/json; charset=utf-8",
"Api-Key": self.api_key,
"Api-Username": self.api_username,
}
log.debug("response %s: %s", response.status_code, repr(response.text))
if not response.ok:
try:
msg = u",".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)
# How many times should we retry if rate limited
retry_count = 4
# Extra time (on top of that required by API) to wait on a retry.
retry_backoff = 1
if 400 <= response.status_code < 500:
raise DiscourseClientError(msg, response=response)
while retry_count > 0:
request_kwargs = dict(
allow_redirects=False,
params=params,
files=files,
data=data,
json=json,
headers=headers,
timeout=self.timeout,
)
raise DiscourseServerError(msg, response=response)
request_kwargs.update(override_request_kwargs)
response = requests.request(verb, url, **request_kwargs)
log.debug("response %s: %s", response.status_code, repr(response.text))
if response.ok:
break
if not response.ok:
try:
msg = u",".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)
if 400 <= response.status_code < 500:
if 429 == response.status_code:
# This codepath relies on wait_seconds from Discourse v2.0.0.beta3 / v1.9.3 or higher.
content_type = response.headers.get("Content-Type")
if content_type is not None and "application/json" in content_type:
ret = response.json()
wait_delay = (
retry_backoff + ret["extras"]["wait_seconds"]
) # how long to back off for.
else:
# We got an early 429 error without a proper JSON body
ret = response.content
wait_delay = retry_backoff + 10
limit_name = response.headers.get(
"Discourse-Rate-Limit-Error-Code", "<unknown>")
log.info(
"We have been rate limited (limit: {2}) and will wait {0} seconds ({1} retries left)".format(
wait_delay, retry_count, limit_name
)
)
if retry_count > 1:
time.sleep(wait_delay)
retry_count -= 1
log.debug("API returned {0}".format(ret))
continue
else:
raise DiscourseClientError(msg, response=response)
# Any other response.ok resulting in False
raise DiscourseServerError(msg, response=response)
if retry_count == 0:
raise DiscourseRateLimitedError(
"Number of rate limit retries exceeded. Increase retry_backoff or retry_count",
response=response,
)
if response.status_code == 302:
raise DiscourseError(
@@ -1317,7 +1636,10 @@ class DiscourseClient(object):
except ValueError:
raise DiscourseError("failed to decode response", response=response)
if "errors" in decoded:
# Checking "errors" length because
# data-explorer (e.g. POST /admin/plugins/explorer/queries/{}/run)
# sends an empty errors array
if "errors" in decoded and len(decoded["errors"]) > 0:
message = decoded.get("message")
if not message:
message = u",".join(decoded["errors"])
+19
View File
@@ -0,0 +1,19 @@
"""API exceptions."""
from requests.exceptions import HTTPError
class DiscourseError(HTTPError):
"""A generic error while attempting to communicate with Discourse"""
class DiscourseServerError(DiscourseError):
"""The Discourse Server encountered an error while processing the request"""
class DiscourseClientError(DiscourseError):
"""An invalid request has been made"""
class DiscourseRateLimitedError(DiscourseError):
"""Request required more than the permissible number of retries"""
@@ -1,5 +1,7 @@
#!/usr/bin/env python
"""Simple command line interface for making Discourse API queries."""
import cmd
import json
import logging
@@ -12,15 +14,19 @@ from pydiscourse.client import DiscourseClient, DiscourseError
class DiscourseCmd(cmd.Cmd):
"""Handles CLI commands"""
prompt = "discourse>"
output = sys.stdout
def __init__(self, client):
"""Initialize command"""
cmd.Cmd.__init__(self)
self.client = client
self.prompt = "%s>" % self.client.host
def __getattr__(self, attr):
"""Gets attributes with dynamic name handling"""
if attr.startswith("do_"):
method = getattr(self.client, attr[3:])
@@ -48,6 +54,7 @@ class DiscourseCmd(cmd.Cmd):
raise AttributeError
def postcmd(self, result, line):
"""Writes output of the command to console"""
try:
json.dump(
result, self.output, sort_keys=True, indent=4, separators=(",", ": ")
@@ -57,6 +64,7 @@ class DiscourseCmd(cmd.Cmd):
def main():
"""Runs the CLI application"""
op = optparse.OptionParser()
op.add_option("--host", default="http://localhost:4000")
op.add_option("--api-user", default="system")
+11 -13
View File
@@ -1,6 +1,4 @@
"""
Utilities to implement Single Sign On for Discourse with a Python managed
authentication DB
"""Implement Single Sign On for Discourse with a Python managed auth DB.
https://meta.discourse.org/t/official-single-sign-on-for-discourse/13045
@@ -26,23 +24,20 @@ from base64 import b64encode, b64decode
import hmac
import hashlib
try: # py3
from urllib.parse import unquote, urlencode, parse_qs
except ImportError:
from urllib import unquote, urlencode
from urlparse import parse_qs
from urllib.parse import unquote, urlencode, parse_qs
from pydiscourse.exceptions import DiscourseError
def sso_validate(payload, signature, secret):
"""
"""Validates SSO payload.
Args:
payload: provided by Discourse HTTP call to your SSO endpoint as sso GET param
signature: provided by Discourse HTTP call to your SSO endpoint as sig GET param
secret: the secret key you entered into Discourse sso secret
return value: The nonce used by discourse to validate the redirect URL
return value: The nonce used by discourse to validate the redirect URL
"""
if None in [payload, signature]:
raise DiscourseError("No SSO payload or signature.")
@@ -72,6 +67,7 @@ def sso_validate(payload, signature, secret):
def sso_payload(secret, **kwargs):
"""Returns an encoded SSO payload"""
return_payload = b64encode(urlencode(kwargs).encode("utf-8"))
h = hmac.new(secret.encode("utf-8"), return_payload, digestmod=hashlib.sha256)
query_string = urlencode({"sso": return_payload, "sig": h.hexdigest()})
@@ -79,14 +75,16 @@ def sso_payload(secret, **kwargs):
def sso_redirect_url(nonce, secret, email, external_id, username, **kwargs):
"""
"""Returns the Discourse redirection URL.
Args:
nonce: returned by sso_validate()
secret: the secret key you entered into Discourse sso secret
user_email: email address of the user who logged in
user_id: the internal id of the logged in user
user_username: username of the logged in user
return value: URL to redirect users back to discourse, now logged in as user_username
return value: URL to redirect users back to discourse, now logged in as user_username
"""
kwargs.update(
{
+12 -7
View File
@@ -1,10 +1,10 @@
import sys
import unittest
import mock
from unittest import mock
from pydiscourse import client
import sys
if sys.version_info < (3,):
@@ -46,12 +46,12 @@ class ClientBaseTestCase(unittest.TestCase):
self.assertEqual(args[0], verb)
self.assertEqual(args[1], self.host + url)
kwargs = kwargs["params"]
self.assertEqual(kwargs.pop("api_username"), self.api_username)
self.assertEqual(kwargs.pop("api_key"), self.api_key)
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)
self.assertEqual(kwargs["params"], params)
class TestClientRequests(ClientBaseTestCase):
@@ -179,9 +179,14 @@ class TestTopics(ClientBaseTestCase):
)
@mock.patch("requests.request")
@mock.patch("pydiscourse.client.requests.request")
class MiscellaneousTests(ClientBaseTestCase):
def test_latest_posts(self, request):
prepare_response(request)
r = self.client.latest_posts(before=54321)
self.assertRequestCalled(request, "GET", "/posts.json", before=54321)
def test_search(self, request):
prepare_response(request)
self.client.search("needle")
+3 -12
View File
@@ -1,18 +1,9 @@
from base64 import b64decode
try: # py26
import unittest2 as unittest
except ImportError:
import unittest
try: # py3
from urllib.parse import unquote
from urllib.parse import urlparse, parse_qs
except ImportError:
from urlparse import urlparse, parse_qs
from urllib import unquote
import unittest
from urllib.parse import unquote
from urllib.parse import urlparse, parse_qs
from pydiscourse import sso
from pydiscourse.exceptions import DiscourseError
+13 -4
View File
@@ -1,10 +1,19 @@
[tox]
envlist = py27, py34, py35, pypy, pypy3
envlist = py37, py38, py39, py310
[gh-actions]
python =
3.7: py37
3.8: py38
3.9: py39
3.10: py310
[testenv]
setenv =
PYTHONPATH = {toxinidir}:{toxinidir}/pydiscourse
commands = python setup.py test
commands = pytest {posargs} --cov=pydiscourse
deps =
-r{toxinidir}/requirements.txt
[testenv:flake8]
basepython=python
@@ -12,10 +21,10 @@ deps=
flake8
flake8_docstrings
commands=
flake8 pydiscourse
flake8 src/pydiscourse --docstring-convention google --ignore D415
[flake8]
ignore = E126,E128
max-line-length = 99
max-line-length = 119
exclude = .ropeproject
max-complexity = 10