Compare commits

...

157 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
Ben Lopatin
5c4d0b3aed Update contributing about tests 2023-08-31 16:14:12 -04:00
Ben Lopatin
f20fc33349 Simplify mocking with default values mocker 2023-08-31 15:56:50 -04:00
Ben Lopatin
3d2a8def57 Convert remaining tests to pytest 2023-08-31 15:29:23 -04:00
Ben Lopatin
80c1a17e4b Convert topics test to pytest 2023-08-31 15:14:46 -04:00
Ben Lopatin
b5eda64a05 Convert additional tests to pytest 2023-08-31 15:06:02 -04:00
Ben Lopatin
87cf273cd6 Add coverage requirement for sso module 2023-08-31 13:58:03 -04:00
Ben Lopatin
c5466fd182 Rename fixtures 2023-08-31 13:53:34 -04:00
Ben Lopatin
fe17d3977c Remove unnecessary fixture arg 2023-08-31 13:52:35 -04:00
Ben Lopatin
f93e1cb341 Bump version 2023-08-31 13:48:33 -04:00
Ben Lopatin
2cf6c675e0 Remove old Python support 2023-08-31 13:46:56 -04:00
Ben Lopatin
991cc564dd Switch test to pytest, sso coverage = 100% 2023-08-31 13:44:54 -04:00
Ben Lopatin
7ebc08356a Remove duplicate periods 2023-08-31 13:35:30 -04:00
Ben Lopatin
a58ca74362 Update pytest version 2023-08-31 13:35:09 -04:00
Ben Lopatin
8a73f911e2 Remove Python 3.7 from test envs 2023-08-31 13:07:05 -04:00
Ben Lopatin
87891a6331 Remove Python 3.5 typing support! 2023-08-31 13:06:45 -04:00
Ben Lopatin
daab45edda
Merge pull request #84 from pydiscourse/version-update-1.5
Version 1.5 update
2023-08-31 12:55:52 -04:00
Ben Lopatin
4382449a72 Remove Python 3.7 support 2023-08-31 12:53:06 -04:00
Ben Lopatin
dfc0c4173c Update changelog 2023-08-31 12:52:02 -04:00
Ben Lopatin
aa2b7eedf6 Bump version 2023-08-31 12:49:19 -04:00
Ben Lopatin
905febc27b
Merge pull request #82 from inducer/group-members-pagination
Respect pagination when returning all group members
2023-08-31 12:45:53 -04:00
Andreas Kloeckner
16297f9206 Respect pagination when returning all group members 2023-08-31 12:42:22 -04:00
Ben Lopatin
c566377ccb
Merge pull request #77 from akhmerov/patch-1
use an up to date endpoint for group owner creation
2023-08-31 12:42:11 -04:00
Anton Akhmerov
9c07f97e87 use an up to date endpoint for group owner creation 2023-08-29 10:19:59 -04:00
Ben Lopatin
eb195bb6bc
Merge pull request #81 from Dettorer/tox-add-py311
Enable the tests for Python 3.11
2023-08-29 10:19:49 -04:00
Paul Dettorer Hervot
71f9da07c7 Enable the tests for Python 3.11 2023-08-29 14:50:35 +02:00
Ben Lopatin
ed4efd82aa
Merge pull request #80 from inducer/fix-group-membership-admin
Fix group membership admin functionality
2023-08-15 15:23:09 -04:00
Andreas Kloeckner
e33a37b8b9 Fix group membership administration endpoints 2023-08-09 19:40:14 -05:00
Ben Lopatin
50465b4502
Merge pull request #74 from themotleyfool/add-update-category
Add client endpoint for updating category
2023-02-28 20:24:46 -05:00
Max Lancaster
e9748279b8 Add client endpoint for updating category 2023-02-28 19:55:26 -05:00
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
27 changed files with 1298 additions and 565 deletions

View File

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

11
.git-blame-ignore-revs Normal file
View File

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

33
.github/workflows/pypi.yml vendored Normal file
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/*

View File

@ -1,23 +1,47 @@
name: Tests
on: [push]
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:
name: Test on Python ${{ matrix.py_version }}
needs: lint
name: Test on Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
matrix:
py_version: [2.7, 3.5, 3.6, 3.7]
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
steps:
- uses: actions/checkout@v1
- name: Set up Python ${{ matrix.py_version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.py_version }}
- name: Install mock for Python 2.7
run: pip install mock
- name: Run tests
run: python setup.py test
- uses: actions/checkout@v1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install tox tox-gh-actions
- name: Test with tox
run: tox

6
.gitignore vendored
View File

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

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

@ -1,11 +0,0 @@
sudo: false
language: python
python:
- "2.7"
- "3.4"
- "3.5"
- "3.6"
- "pypy"
- "pypy3"
script: python setup.py test

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>

View File

@ -19,9 +19,9 @@ Reviewing and merging pull requests is work, so whatever you can do to make this
easier for the package maintainer not only speed up the process of getting your
changes merged but also ensure they are. These few guidelines help significantly.
If they are confusing or you need help understanding how to accomplish them,
please ask for help in an issue.
please ask for help in an issue.
- Please do make sure your 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 your changeset represents a *discrete update*. If you would like to fix formatting, by all means, but don't mix that up with a bug fix. Those are separate PRs.
- Please do make sure that both your pull request description and your commits are meaningful and descriptive. Rebase first, if need be.
- Please do make sure your changeset does not include more commits than necessary. Rebase first, if need be.
- Please do make sure the changeset is not very big. If you have a large change propose it in an issue first.
@ -30,10 +30,27 @@ please ask for help in an issue.
Testing
=======
The best way to run the tests is with `tox <http://tox.readthedocs.org/en/latest/>`_::
Running tests
-------------
The simplest way to quickly and repeatedly run tests while developing a feature or fix
is to use `pytest` in your current Python environment.
After installing the test dependencies::
pip install -r requirements.txt
pip install -e .
Your can run the tests with `pytest`::
pytest --cov=src/pydiscourse
This will ensure you get coverage reporting.
The most comprehensive way to run the tests is with `tox <http://tox.readthedocs.org/en/latest/>`_::
pip install tox
detox
tox
Or it's slightly faster cousin `detox
<https://pypi.python.org/pypi/detox>`_ which will parallelize test runs::
@ -41,16 +58,29 @@ Or it's slightly faster cousin `detox
pip install detox
detox
Alternatively, you can run the self test with the following commands::
Writing tests
-------------
pip install -r requirements.dev.txt
pip install -e .
python setup.py test
The primary modules of the library have coverage requirements, so you should
write a test or tests when you add a new feature.
**At a bare minimum a test should show which Discourse API endpoint is called,
using which HTTP method, and returning any necessary data for the new function/method.**
In most cases this can be accomplished quite simply by using the `discourse_request`
fixture, which allows for mocking the HTTP request in the `requests` library. In some cases
this may be insufficient, and you may want to directly use the `requests_mock` mocking
fixture.
If in the course of writing your test you see a `requests_mock.NoMockAddress` exception
raised then either the *method* or the *path* (including querystring) - or both! - in
either your mock OR your new API client method is incorrect.
Live Testing
============
You can test against a Discourse instance by following the [Official Discourse developement instructions][discoursedev].
For the impatient here is the quick and dirty version::
git clone git@github.com:discourse/discourse.git

View File

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

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

View File

@ -1,5 +0,0 @@
# -*- coding: utf-8 -*-
__version__ = "0.9.0"
from pydiscourse.client import DiscourseClient

View File

@ -1,17 +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 """
class DiscourseRateLimitedError(DiscourseError):
""" Request required more than the permissible number of retries """

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

9
requirements.txt Normal file
View File

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

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
classifiers =
Development Status :: 5 - Production/Stable
Environment :: Web Environment
Intended Audience :: Developers
License :: OSI Approved :: MIT License
Operating System :: OS Independent
Programming Language :: Python
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Programming Language :: Python :: 3.12
[options.packages.find]
where=src
[options.entry_points]
console_scripts =
pydiscoursecli = pydiscourse.main:main
[bdist_wheel]
universal = 1
[build-system]
requires =
setuptools >= "40.9.0"
wheel

View File

@ -1,50 +1,6 @@
from setuptools import setup, find_packages
"""
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 :: 5 - Production/Stable"
"Environment :: Web Environment",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
'Programming Language :: Python :: Implementation :: PyPy',
],
zip_safe=False,
)
from setuptools import setup
setup()

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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"""

View File

@ -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:])
@ -32,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
@ -48,15 +54,21 @@ 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=(",", ": ")
result,
self.output,
sort_keys=True,
indent=4,
separators=(",", ": "),
)
except TypeError:
self.output.write(result.text)
def main():
"""Runs the CLI application"""
op = optparse.OptionParser()
op.add_option("--host", default="http://localhost:4000")
op.add_option("--api-user", default="system")

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,40 +24,39 @@ 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.")
if not secret:
raise DiscourseError("Invalid secret..")
raise DiscourseError("Invalid secret.")
payload = unquote(payload)
if not payload:
raise DiscourseError("Invalid payload..")
raise DiscourseError("Invalid payload.")
decoded = b64decode(payload.encode("utf-8")).decode("utf-8")
if "nonce" not in decoded:
raise DiscourseError("Invalid payload..")
raise DiscourseError("Invalid payload.")
h = hmac.new(
secret.encode("utf-8"), payload.encode("utf-8"), digestmod=hashlib.sha256
secret.encode("utf-8"),
payload.encode("utf-8"),
digestmod=hashlib.sha256,
)
this_signature = h.hexdigest()
@ -72,6 +69,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 +77,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(
{
@ -94,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)

136
tests/conftest.py Normal file
View File

@ -0,0 +1,136 @@
"""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,214 +1,220 @@
import sys
import unittest
"""Tests for the client methods."""
try:
from unittest import mock
except ImportError:
import mock
import urllib.parse
from pydiscourse import client
import pytest
if sys.version_info < (3,):
def test_empty_content_http_ok(discourse_host, discourse_client, discourse_request):
"""Empty content should not raise error
def b(x):
return x
else:
import codecs
def b(x):
return codecs.latin_1_encode(x)[0]
def prepare_response(request):
# we need to mocked response to look a little more real
request.return_value = mock.MagicMock(
headers={"content-type": "application/json; charset=utf-8"}
Critical to test against *bytestrings* rather than unicode
"""
discourse_request(
"get",
"/users/admin/1/unsuspend",
headers={"Content-Type": "text/plain; charset=utf-8"},
content=b" ",
)
resp = discourse_client._request("GET", "/users/admin/1/unsuspend", {})
class ClientBaseTestCase(unittest.TestCase):
"""
"""
def setUp(self):
self.host = "http://testhost"
self.api_username = "testuser"
self.api_key = "testkey"
self.client = client.DiscourseClient(self.host, self.api_username, self.api_key)
def assertRequestCalled(self, request, verb, url, **params):
self.assertTrue(request.called)
args, kwargs = request.call_args
self.assertEqual(args[0], verb)
self.assertEqual(args[1], self.host + url)
kwargs = kwargs["params"]
self.assertEqual(kwargs.pop("api_username"), self.api_username)
self.assertEqual(kwargs.pop("api_key"), self.api_key)
if verb == "GET":
self.assertEqual(kwargs, params)
assert resp is None
class TestClientRequests(ClientBaseTestCase):
"""
Tests for common request handling
"""
@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"
class TestUserManagement:
def test_get_user(self, discourse_host, discourse_client, discourse_request):
request = discourse_request(
"get",
"/users/someuser.json",
json={"user": "someuser"},
)
self.assertEqual(request.call_count, 2)
discourse_client.user("someuser")
# XXX incomplete
assert request.called_once
def test_update_email(self, request):
prepare_response(request)
email = "test@example.com"
self.client.update_email("someuser", email)
self.assertRequestCalled(
request, "PUT", "/users/someuser/preferences/email", email=email
def test_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 = discourse_request("post", "/users")
discourse_client.create_user(
"Test User",
"testuser",
"test@example.com",
"notapassword",
)
def test_update_user(self, request):
prepare_response(request)
self.client.update_user("someuser", a="a", b="b")
self.assertRequestCalled(request, "PUT", "/users/someuser", a="a", b="b")
assert session_request.called_once
assert user_request.called_once
def test_update_username(self, request):
prepare_response(request)
self.client.update_username("someuser", "newname")
self.assertRequestCalled(
request, "PUT", "/users/someuser/preferences/username", username="newname"
def test_update_email(self, discourse_host, discourse_client, discourse_request):
request = discourse_request("put", "/users/someuser/preferences/email")
discourse_client.update_email("someuser", "newmeail@example.com")
assert request.called_once
def test_update_user(self, discourse_client, discourse_request):
request = discourse_request("put", "/users/someuser")
discourse_client.update_user("someuser", a="a", b="b")
assert request.called_once
def test_update_username(self, discourse_client, discourse_request):
request = discourse_request("put", "/users/someuser/preferences/username")
discourse_client.update_username("someuser", "newname")
assert request.called_once
def test_by_external_id(self, discourse_client, discourse_request):
request = discourse_request(
"get",
"/users/by-external/123",
json={"user": "123"},
)
discourse_client.by_external_id(123)
def test_by_external_id(self, request):
prepare_response(request)
self.client.by_external_id(123)
self.assertRequestCalled(request, "GET", "/users/by-external/123")
assert request.called_once
def test_suspend_user(self, request):
prepare_response(request)
self.client.suspend(123, 1, "Testing")
self.assertRequestCalled(
request, "PUT", "/admin/users/123/suspend", duration=1, reason="Testing"
def test_anonymize(self, discourse_client, discourse_request):
request = discourse_request("put", "/admin/users/123/anonymize")
discourse_client.anonymize(123)
assert request.called_once
@pytest.mark.usefixtures("_frozen_time")
def test_suspend_user(self, discourse_client, discourse_request):
request = discourse_request("put", "/admin/users/123/suspend")
discourse_client.suspend(123, 1, "Testing")
assert request.called_once
assert request.last_request.method == "PUT"
request_payload = urllib.parse.parse_qs(request.last_request.text)
assert request_payload["reason"] == ["Testing"]
assert request_payload["suspend_until"] == ["2023-08-14T12:30:15+00:00"]
def test_unsuspend_user(self, discourse_client, discourse_request):
request = discourse_request("put", "/admin/users/123/unsuspend")
discourse_client.unsuspend(123)
assert request.called_once
def test_user_bagdes(self, discourse_client, discourse_request):
request = discourse_request("get", "/user-badges/myusername.json")
discourse_client.user_badges("myusername")
assert request.called_once
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")
def test_unsuspend_user(self, request):
prepare_response(request)
self.client.unsuspend(123)
self.assertRequestCalled(request, "PUT", "/admin/users/123/unsuspend")
assert request.called_once
def test_user_bagdes(self, request):
prepare_response(request)
self.client.user_badges("username")
self.assertRequestCalled(
request, "GET", "/user-badges/{}.json".format("username")
def test_invite_user_to_topic(self, discourse_client, discourse_request):
request = discourse_request("post", "/t/22/invite.json")
discourse_client.invite_user_to_topic("test@example.com", 22)
assert request.called_once
request_payload = urllib.parse.parse_qs(request.last_request.text)
assert request_payload["email"] == ["test@example.com"]
assert request_payload["topic_id"] == ["22"]
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"
@mock.patch("requests.request")
class TestTopics(ClientBaseTestCase):
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_hot_topics(self, request):
prepare_response(request)
self.client.hot_topics()
self.assertRequestCalled(request, "GET", "/hot.json")
def test_grant_badge_to(self, discourse_client, discourse_request):
request = discourse_request("post", "/user_badges")
discourse_client.grant_badge_to("username", 1)
def test_latest_topics(self, request):
prepare_response(request)
self.client.latest_topics()
self.assertRequestCalled(request, "GET", "/latest.json")
request_payload = urllib.parse.parse_qs(request.last_request.text)
def test_new_topics(self, request):
prepare_response(request)
self.client.new_topics()
self.assertRequestCalled(request, "GET", "/new.json")
def test_topic(self, request):
prepare_response(request)
self.client.topic("some-test-slug", 22)
self.assertRequestCalled(request, "GET", "/t/some-test-slug/22.json")
def test_topics_by(self, request):
prepare_response(request)
r = self.client.topics_by("someuser")
self.assertRequestCalled(request, "GET", "/topics/created-by/someuser.json")
self.assertEqual(r, request().json()["topic_list"]["topics"])
def invite_user_to_topic(self, request):
prepare_response(request)
email = "test@example.com"
self.client.invite_user_to_topic(email, 22)
self.assertRequestCalled(
request, "POST", "/t/22/invite.json", email=email, topic_id=22
)
assert request_payload["username"] == ["username"]
assert request_payload["badge_id"] == ["1"]
@mock.patch("pydiscourse.client.requests.request")
class MiscellaneousTests(ClientBaseTestCase):
def test_search(self, request):
prepare_response(request)
self.client.search("needle")
self.assertRequestCalled(request, "GET", "/search.json", term="needle")
def test_categories(self, request):
prepare_response(request)
r = self.client.categories()
self.assertRequestCalled(request, "GET", "/categories.json")
self.assertEqual(r, request().json()["category_list"]["categories"])
def test_users(self, request):
prepare_response(request)
self.client.users()
self.assertRequestCalled(request, "GET", "/admin/users/list/active.json")
def test_badges(self, request):
prepare_response(request)
self.client.badges()
self.assertRequestCalled(request, "GET", "/admin/badges.json")
def test_grant_badge_to(self, request):
prepare_response(request)
self.client.grant_badge_to("username", 1)
self.assertRequestCalled(
request, "POST", "/user_badges", username="username", badge_id=1
)
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

@ -1,85 +1,95 @@
from base64 import b64decode
from urllib.parse import unquote
from urllib.parse import urlparse, parse_qs
try: # py26
import unittest2 as unittest
except ImportError:
import unittest
try: # py3
from urllib.parse import unquote
from urllib.parse import urlparse, parse_qs
except ImportError:
from urlparse import urlparse, parse_qs
from urllib import unquote
import pytest
from pydiscourse import sso
from pydiscourse.exceptions import DiscourseError
class SSOTestCase(unittest.TestCase):
def test_sso_validate_missing_payload():
with pytest.raises(DiscourseError) as excinfo:
sso.sso_validate(None, "abc", "123")
def setUp(self):
# values from https://meta.discourse.org/t/official-single-sign-on-for-discourse/13045
self.secret = "d836444a9e4084d5b224a60c208dce14"
self.nonce = "cb68251eefb5211e58c00ff1395f0c0b"
self.payload = "bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGI%3D%0A"
self.signature = "2828aa29899722b35a2f191d34ef9b3ce695e0e6eeec47deb46d588d70c7cb56"
assert excinfo.value.args[0] == "No SSO payload or signature."
self.name = u"sam"
self.username = u"samsam"
self.external_id = u"hello123"
self.email = u"test@test.com"
self.redirect_url = u"/session/sso_login?sso=bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGImbmFtZT1z%0AYW0mdXNlcm5hbWU9c2Ftc2FtJmVtYWlsPXRlc3QlNDB0ZXN0LmNvbSZleHRl%0Acm5hbF9pZD1oZWxsbzEyMw%3D%3D%0A&sig=1c884222282f3feacd76802a9dd94e8bc8deba5d619b292bed75d63eb3152c0b"
def test_missing_args(self):
with self.assertRaises(DiscourseError):
sso.sso_validate(None, self.signature, self.secret)
def test_sso_validate_empty_payload():
with pytest.raises(DiscourseError) as excinfo:
sso.sso_validate("", "abc", "123")
with self.assertRaises(DiscourseError):
sso.sso_validate("", self.signature, self.secret)
assert excinfo.value.args[0] == "Invalid payload."
with self.assertRaises(DiscourseError):
sso.sso_validate(self.payload, None, self.secret)
def test_invalid_signature(self):
with self.assertRaises(DiscourseError):
sso.sso_validate(self.payload, "notavalidsignature", self.secret)
def test_sso_validate_missing_signature():
with pytest.raises(DiscourseError) as excinfo:
sso.sso_validate("sig", None, "123")
def test_valid_nonce(self):
nonce = sso.sso_validate(self.payload, self.signature, self.secret)
self.assertEqual(nonce, self.nonce)
assert excinfo.value.args[0] == "No SSO payload or signature."
def test_valid_redirect_url(self):
url = sso.sso_redirect_url(
self.nonce,
self.secret,
self.email,
self.external_id,
self.username,
name="sam",
)
self.assertIn("/session/sso_login", url[:20])
@pytest.mark.parametrize("bad_secret", [None, ""])
def test_sso_validate_missing_secret(bad_secret):
with pytest.raises(DiscourseError) as excinfo:
sso.sso_validate("payload", "signature", bad_secret)
# check 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)
assert excinfo.value.args[0] == "Invalid 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("&")))
self.assertEqual(
payload,
{
"username": self.username,
"nonce": self.nonce,
"external_id": self.external_id,
"name": self.name,
"email": self.email,
},
)
def test_sso_validate_invalid_signature(sso_payload, sso_signature, sso_secret):
with pytest.raises(DiscourseError) as excinfo:
sso.sso_validate("Ym9i", sso_signature, sso_secret)
assert excinfo.value.args[0] == "Invalid payload."
def test_sso_validate_invalid_payload_nonce(sso_payload, sso_secret):
with pytest.raises(DiscourseError) as excinfo:
sso.sso_validate(sso_payload, "notavalidsignature", sso_secret)
assert excinfo.value.args[0] == "Payload does not match signature."
def test_valid_nonce(sso_payload, sso_signature, sso_secret, sso_nonce):
generated_nonce = sso.sso_validate(sso_payload, sso_signature, sso_secret)
assert generated_nonce == sso_nonce
def test_valid_redirect_url(
sso_secret,
sso_nonce,
name,
email,
username,
external_id,
redirect_url,
):
url = sso.sso_redirect_url(
sso_nonce,
sso_secret,
email,
external_id,
username,
name="sam",
)
assert "/session/sso_login" in url[:20]
# check its valid, using our own handy validator
params = parse_qs(urlparse(url).query)
payload = params["sso"][0]
sso.sso_validate(payload, params["sig"][0], sso_secret)
# check the params have all the data we expect
payload = b64decode(payload.encode("utf-8")).decode("utf-8")
payload = unquote(payload)
payload = dict(p.split("=") for p in payload.split("&"))
assert payload == {
"username": username,
"nonce": sso_nonce,
"external_id": external_id,
"name": name,
"email": email,
}

31
tox.ini
View File

@ -1,21 +1,28 @@
[tox]
envlist = py27, py34, py35, py36, py37, pypy, pypy3
envlist = py38, py39, py310, py311
[gh-actions]
python =
3.8: py38
3.9: py39
3.10: py310
3.11: py311
3.12: py312
[testenv]
setenv =
PYTHONPATH = {toxinidir}:{toxinidir}/pydiscourse
commands = python setup.py test
commands =
pytest {posargs} --cov=pydiscourse
coverage report -m --include='**/pydiscourse/client.py' --fail-under=46
coverage report -m --include='**/pydiscourse/sso.py' --fail-under=100
deps =
-r{toxinidir}/requirements.txt
[testenv:flake8]
[testenv:ruff]
basepython=python
skip_install=true
deps=
flake8
flake8_docstrings
ruff
commands=
flake8 pydiscourse
[flake8]
ignore = E126,E128
max-line-length = 99
exclude = .ropeproject
max-complexity = 10
ruff .