Compare commits
293 Commits
f/objectap
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ee6fc8b88 | ||
|
|
7d05b1c42c | ||
|
|
b03c9738f1 | ||
|
|
36f8f1e737 | ||
|
|
2d27cc2a3c | ||
|
|
f81c1b775a | ||
|
|
270f4bbbdb | ||
|
|
7d981684aa | ||
|
|
53be265b23 | ||
|
|
a07b122c39 | ||
|
|
6d54153a24 | ||
|
|
c30a453a6b | ||
|
|
3e87ef3849 | ||
|
|
0f1cc41967 | ||
|
|
7ca7f47e94 | ||
|
|
d8ec2f61aa | ||
|
|
f94d861c64 | ||
|
|
565b714d81 | ||
|
|
e1a9038789 | ||
|
|
b38f99bffe | ||
|
|
4d20bfd79c | ||
|
|
99359aa518 | ||
|
|
ea7d47c5da | ||
|
|
fcdd6ab8dd | ||
|
|
c850e34112 | ||
|
|
ee21957027 | ||
|
|
acb0af24d1 | ||
|
|
31db8017bc | ||
|
|
5d28665fbb | ||
|
|
51771057f5 | ||
|
|
d7e249847a | ||
|
|
b348fe13f3 | ||
|
|
30f9b9b338 | ||
|
|
815bfd8af6 | ||
|
|
2be1a46c1d | ||
|
|
2aac9a20be | ||
|
|
fe4f67c041 | ||
|
|
6c1d9a9e1d | ||
|
|
7ab58533b7 | ||
|
|
dc498679cc | ||
|
|
c49d29620d | ||
|
|
2a3036f039 | ||
|
|
14b2f7a08d | ||
|
|
5c4d0b3aed | ||
|
|
f20fc33349 | ||
|
|
3d2a8def57 | ||
|
|
80c1a17e4b | ||
|
|
b5eda64a05 | ||
|
|
87cf273cd6 | ||
|
|
c5466fd182 | ||
|
|
fe17d3977c | ||
|
|
f93e1cb341 | ||
|
|
2cf6c675e0 | ||
|
|
991cc564dd | ||
|
|
7ebc08356a | ||
|
|
a58ca74362 | ||
|
|
8a73f911e2 | ||
|
|
87891a6331 | ||
|
|
daab45edda | ||
|
|
4382449a72 | ||
|
|
dfc0c4173c | ||
|
|
aa2b7eedf6 | ||
|
|
905febc27b | ||
|
|
16297f9206 | ||
|
|
c566377ccb | ||
|
|
9c07f97e87 | ||
|
|
eb195bb6bc | ||
|
|
71f9da07c7 | ||
|
|
ed4efd82aa | ||
|
|
e33a37b8b9 | ||
|
|
50465b4502 | ||
|
|
e9748279b8 | ||
|
|
7898ff3ff1 | ||
|
|
9709744b33 | ||
|
|
22e236a009 | ||
|
|
0857a9cfe7 | ||
|
|
30e2068b4d | ||
|
|
227924f098 | ||
|
|
201ff3d717 | ||
|
|
393422f964 | ||
|
|
d9e0af7e59 | ||
|
|
227c3fb469 | ||
|
|
0378a38d87 | ||
|
|
5c12e06e58 | ||
|
|
27363e5fa7 | ||
|
|
8be16c34ff | ||
|
|
8971629bcb | ||
|
|
7e237c6b68 | ||
|
|
062904fd2a | ||
|
|
0d1eb1b816 | ||
|
|
457abc559f | ||
|
|
3b7b7d2490 | ||
|
|
1d74e2f1b7 | ||
|
|
e254926726 | ||
|
|
ff40a1c8c0 | ||
|
|
7054b9118f | ||
|
|
b5ccf244a5 | ||
|
|
901a53a10d | ||
|
|
71cb943e55 | ||
|
|
f6b4c02fc0 | ||
|
|
4eaff3a790 | ||
|
|
864b1b047f | ||
|
|
69bdc5f76f | ||
|
|
27c76de371 | ||
|
|
baaa049dc6 | ||
|
|
bbe216ef8c | ||
|
|
d9a5c081a9 | ||
|
|
f61ffdbcdb | ||
|
|
8a0e742abd | ||
|
|
1a22796e8e | ||
|
|
0177c46356 | ||
|
|
ef5f8523d8 | ||
|
|
96f9ea4b50 | ||
|
|
9b11c7d06a | ||
|
|
20c1915cbe | ||
|
|
d5b9aacf01 | ||
|
|
bc8a2907b9 | ||
|
|
1f595c3e7f | ||
|
|
188decb02a | ||
|
|
f4bd3e3b17 | ||
|
|
f74722dfb8 | ||
|
|
d101264391 | ||
|
|
3e94eaee05 | ||
|
|
5763ba6ee8 | ||
|
|
099993a379 | ||
|
|
d887772b30 | ||
|
|
e5d1ef2f02 | ||
|
|
ee2769d0b9 | ||
|
|
b2f6e1df96 | ||
|
|
0008bfdf0a | ||
|
|
2eb6d672a0 | ||
|
|
a68cb0244f | ||
|
|
c0566f2aad | ||
|
|
fc6a78c948 | ||
|
|
df30e1acc8 | ||
|
|
ec730ec026 | ||
|
|
7fce4dc129 | ||
|
|
2bdfdb85ec | ||
|
|
f27ed47206 | ||
|
|
cc9f35b5f3 | ||
|
|
689e0981a0 | ||
|
|
712f9282b1 | ||
|
|
b69a142811 | ||
|
|
ce7038b05d | ||
|
|
ffbd47868d | ||
|
|
5040b24dcc | ||
|
|
e7906a0568 | ||
|
|
11a82695c5 | ||
|
|
f1e7ee069c | ||
|
|
6e31953118 | ||
|
|
2ad158e195 | ||
|
|
719035e9a9 | ||
|
|
3a4af08827 | ||
|
|
5d334f1d80 | ||
|
|
361bf77949 | ||
|
|
aeb763c42c | ||
|
|
69867b3c10 | ||
|
|
c3ae5b3c76 | ||
|
|
d02ab15d3f | ||
|
|
9a8641e596 | ||
|
|
802f018519 | ||
|
|
be74c4e5b7 | ||
|
|
9198a1d549 | ||
|
|
53cc24744f | ||
|
|
9dcf5832b6 | ||
|
|
10c27d6338 | ||
|
|
89f12f707b | ||
|
|
0cef55a02f | ||
|
|
faa8895321 | ||
|
|
e434edb2ea | ||
|
|
9601d96701 | ||
|
|
d5ce2d78dc | ||
|
|
84b59d2e4f | ||
|
|
1970e53059 | ||
|
|
0c7b60fef8 | ||
|
|
de6e758be6 | ||
|
|
0f2efa8e74 | ||
|
|
44cf317aa9 | ||
|
|
468f6b58cd | ||
|
|
c0db7215c9 | ||
|
|
b0b277c917 | ||
|
|
7793f3ae54 | ||
|
|
c900fad726 | ||
|
|
6c4c40d93c | ||
|
|
ff49cc7219 | ||
|
|
3e391c38ec | ||
|
|
010bfa624c | ||
|
|
006b7d416a | ||
|
|
7b3733ca8e | ||
|
|
a87503eec3 | ||
|
|
f0dd191b58 | ||
|
|
6cffef4e49 | ||
|
|
12356819ea | ||
|
|
84016afbc5 | ||
|
|
e5fe47d0a6 | ||
|
|
2fde21b51f | ||
|
|
2d2e8d1695 | ||
|
|
217b606ee7 | ||
|
|
fd815ac97b | ||
|
|
9fbfa39060 | ||
|
|
3ab8689b6e | ||
|
|
914e22cc55 | ||
|
|
f42a457514 | ||
|
|
8faa1cfaf9 | ||
|
|
227f7a3205 | ||
|
|
17faed6fa7 | ||
|
|
b761d28494 | ||
|
|
9108939503 | ||
|
|
8555abf680 | ||
|
|
dd9b7fad19 | ||
|
|
a18203c8cb | ||
|
|
630b822a9a | ||
|
|
f0fd17c3a3 | ||
|
|
f7f1aafc64 | ||
|
|
e77074c5d4 | ||
|
|
22bf3b088e | ||
|
|
811453a129 | ||
|
|
f8d628909c | ||
|
|
507e377a37 | ||
|
|
0aac8f6628 | ||
|
|
acdcb03283 | ||
|
|
6bd73fdd5c | ||
|
|
c13b456b79 | ||
|
|
977885967d | ||
|
|
b9066ca637 | ||
|
|
77254f441c | ||
|
|
66089011f7 | ||
|
|
fe317b6be8 | ||
|
|
15e82aacd1 | ||
|
|
008f21d6fe | ||
|
|
6baf51bbe1 | ||
|
|
8304e7b2f5 | ||
|
|
5806beef34 | ||
|
|
f905a957f4 | ||
|
|
06ca2c5a58 | ||
|
|
bde4325776 | ||
|
|
6b7e570475 | ||
|
|
3659724f11 | ||
|
|
1e151fc51f | ||
|
|
63f120ddca | ||
|
|
9cb96eaf76 | ||
|
|
c5207759a8 | ||
|
|
b14cd502ce | ||
|
|
3a3bb843e5 | ||
|
|
a2f961aebb | ||
|
|
bd508cdcee | ||
|
|
46051fd248 | ||
|
|
eed4df564d | ||
|
|
6ac6a1fd2d | ||
|
|
adf3f2ddbc | ||
|
|
2daebbfa23 | ||
|
|
9a23db7e43 | ||
|
|
3be87f19dd | ||
|
|
b7d4286c44 | ||
|
|
c155c8a60d | ||
|
|
dd8d9562ff | ||
|
|
c6a43f0304 | ||
|
|
ca52920690 | ||
|
|
db934494e2 | ||
|
|
6dbbe74776 | ||
|
|
04bb9c550b | ||
|
|
65e398343b | ||
|
|
df1d274a33 | ||
|
|
9cc771f381 | ||
|
|
66cd4ab5de | ||
|
|
bd79423ba9 | ||
|
|
65abb8119f | ||
|
|
f0f3256e01 | ||
|
|
ab00eb9cf7 | ||
|
|
3ccf20212e | ||
|
|
e008d1865e | ||
|
|
5506eacaeb | ||
|
|
29cb1ce14b | ||
|
|
4e69083284 | ||
|
|
05a58b1d62 | ||
|
|
cc03b5cc08 | ||
|
|
385e35b322 | ||
|
|
9c6097a3d4 | ||
|
|
204eb1478a | ||
|
|
e64b990dc8 | ||
|
|
e358300085 | ||
|
|
b8cb201652 | ||
|
|
cba141724d | ||
|
|
d64651e655 | ||
|
|
9d5d835910 | ||
|
|
9eb5f3466b | ||
|
|
d66e636078 | ||
|
|
a381089497 | ||
|
|
aaa18ee0c6 | ||
|
|
a07280975a | ||
|
|
cb0244652d | ||
|
|
4a56a43bd5 | ||
|
|
4a366a1b97 |
@ -1,3 +0,0 @@
|
||||
[run]
|
||||
|
||||
include: pydiscourse/*
|
||||
11
.git-blame-ignore-revs
Normal file
11
.git-blame-ignore-revs
Normal file
@ -0,0 +1,11 @@
|
||||
c0db7215c95dbd31770ade1fc6ea65aa426d4590
|
||||
0177c46356b9d0fc4b93f09aab7a224643a3685e
|
||||
f6b4c02fc0f144dffc88cdd48b8261a69228d2f0
|
||||
2a3036f0395a810b0941522bfb1ca80b159525ce
|
||||
c49d29620dfb867f73ebb6be84b5e1ba922fadc9
|
||||
dc498679cc6769acafe19cf0083f40154ffdcff8
|
||||
7ab58533b759d1ff879476a5703051b201afd835
|
||||
fe4f67c04160a76948d810848ae082713ea6b5ed
|
||||
2aac9a20beb19a6a052286f73f5d0f5bf76ed758
|
||||
2be1a46c1da497e136818b5ef77708b8c5b69e57
|
||||
31db8017bc90978b879c5caa7f1cd4777d19a27e
|
||||
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
### Summary of changes
|
||||
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Changes represent a *discrete update*
|
||||
- [ ] Commit messages are meaningful and descriptive
|
||||
- [ ] Changeset does not include any extraneous changes unrelated to the discrete change
|
||||
33
.github/workflows/pypi.yml
vendored
Normal file
33
.github/workflows/pypi.yml
vendored
Normal 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/*
|
||||
47
.github/workflows/test.yml
vendored
Normal file
47
.github/workflows/test.yml
vendored
Normal file
@ -0,0 +1,47 @@
|
||||
name: Tests
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install ruff
|
||||
|
||||
- name: Lint with Ruff
|
||||
run: ruff .
|
||||
|
||||
test:
|
||||
|
||||
needs: lint
|
||||
|
||||
name: Test on Python ${{ matrix.python-version }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
|
||||
|
||||
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
.gitignore
vendored
6
.gitignore
vendored
@ -36,3 +36,9 @@ coverage.xml
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# Pyenv
|
||||
.python-version
|
||||
|
||||
# PyCharm
|
||||
.idea
|
||||
|
||||
19
.pre-commit-config.yaml
Normal file
19
.pre-commit-config.yaml
Normal 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
|
||||
10
.travis.yml
10
.travis.yml
@ -1,10 +0,0 @@
|
||||
language: python
|
||||
python:
|
||||
- "2.7"
|
||||
|
||||
install:
|
||||
- "pip install -r requirements.dev.txt"
|
||||
- "pip install ."
|
||||
|
||||
|
||||
script: nosetests
|
||||
15
AUTHORS
Normal file
15
AUTHORS
Normal file
@ -0,0 +1,15 @@
|
||||
(Based on original authors list and may be incomplete)
|
||||
|
||||
Marc Sibson
|
||||
James Potter
|
||||
Ben Lopatin
|
||||
Daniel Zohar
|
||||
Matheus Fernandes
|
||||
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>
|
||||
106
CONTRIBUTING.rst
Normal file
106
CONTRIBUTING.rst
Normal file
@ -0,0 +1,106 @@
|
||||
============
|
||||
Contributing
|
||||
============
|
||||
|
||||
For patches, please ensure that all existing tests pass, that you have adequate
|
||||
tests added as necessary, and that all code is documented! The latter is
|
||||
critical. If you add or update an existing function, class, or module, please
|
||||
ensure you add a docstring or ensure the existing docstring is up-to-date.
|
||||
|
||||
Please use `Google docstring format
|
||||
<http://sphinxcontrib-napoleon.readthedocs.org/en/latest/example_google.html>`_.
|
||||
|
||||
This *will* be enforced.
|
||||
|
||||
Pull requests
|
||||
=============
|
||||
|
||||
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 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.
|
||||
- Please do make sure your changeset is based on a branch from the current HEAD of the fork you wish to merge against. This is a general best practice. Rebase first, if need be.
|
||||
|
||||
Testing
|
||||
=======
|
||||
|
||||
Running tests
|
||||
-------------
|
||||
|
||||
The simplest way to quickly and repeatedly run tests while developing a feature or fix
|
||||
is to use `pytest` in your current Python environment.
|
||||
|
||||
After installing the test dependencies::
|
||||
|
||||
pip install -r requirements.txt
|
||||
pip install -e .
|
||||
|
||||
Your can run the tests with `pytest`::
|
||||
|
||||
pytest --cov=src/pydiscourse
|
||||
|
||||
This will ensure you get coverage reporting.
|
||||
|
||||
The most comprehensive way to run the tests is with `tox <http://tox.readthedocs.org/en/latest/>`_::
|
||||
|
||||
pip install tox
|
||||
tox
|
||||
|
||||
Or it's slightly faster cousin `detox
|
||||
<https://pypi.python.org/pypi/detox>`_ which will parallelize test runs::
|
||||
|
||||
pip install detox
|
||||
detox
|
||||
|
||||
Writing tests
|
||||
-------------
|
||||
|
||||
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
|
||||
cd discourse
|
||||
vagrant up
|
||||
vagrant ssh
|
||||
cd /vagrant
|
||||
bundle install
|
||||
bundle exec rake db:migrate
|
||||
bundle exec rails s
|
||||
|
||||
Once running you can access the Discourse install at http://localhost:4000.
|
||||
|
||||
[discoursedev]: https://github.com/discourse/discourse/blob/master/docs/VAGRANT.md "Discourse Vagrant"
|
||||
|
||||
TODO
|
||||
====
|
||||
|
||||
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.
|
||||
30
DEVELOP.md
30
DEVELOP.md
@ -1,30 +0,0 @@
|
||||
Development
|
||||
------------
|
||||
Refer to, https://github.com/discourse/discourse_api/blob/master/routes.txt for a list of all operations available in Discourse.
|
||||
|
||||
Unit tests
|
||||
--------------
|
||||
You can run the self test with the following commands::
|
||||
|
||||
pip install -r requirements.dev.txt
|
||||
pip install -e .
|
||||
nosetests
|
||||
|
||||
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
|
||||
cd discourse
|
||||
vagrant up
|
||||
vagrant ssh
|
||||
cd /vagrant
|
||||
bundle install
|
||||
bundle exec rake db:migrate
|
||||
bundle exec rails s
|
||||
|
||||
Once running you can access the Discourse install at http://localhost:4000.
|
||||
|
||||
[discoursedev]: https://github.com/discourse/discourse/blob/master/docs/VAGRANT.md "Discourse Vagrant"
|
||||
164
HISTORY.rst
Normal file
164
HISTORY.rst
Normal file
@ -0,0 +1,164 @@
|
||||
.. :changelog:
|
||||
|
||||
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
|
||||
-----
|
||||
|
||||
- Added rate limiting support
|
||||
- Added some support for user activation
|
||||
|
||||
0.8.0
|
||||
-----
|
||||
|
||||
- Add some PR guidance
|
||||
- Add support for files in the core request methods
|
||||
- Adds numerous new API controls, including:
|
||||
- tag_group
|
||||
- user_actions
|
||||
- upload_image
|
||||
- block
|
||||
- trust_level_lock
|
||||
- create_site_customization (theme)
|
||||
- create_color_scheme
|
||||
- color_schemes
|
||||
- add_group_members
|
||||
- group_members
|
||||
- group_owners
|
||||
- delete_group
|
||||
- create_group
|
||||
- group
|
||||
- customize_site_texts
|
||||
- delete_category
|
||||
- user_emails
|
||||
- update_topic_status
|
||||
- create_post
|
||||
- update_topic
|
||||
- update_avatar
|
||||
- user_all
|
||||
|
||||
|
||||
0.7.0
|
||||
-----
|
||||
|
||||
* Place request parameters in the request body for POST and PUT requests.
|
||||
Allows larger request sizes and solves for `URI Too Large` error.
|
||||
|
||||
0.6.0
|
||||
-----
|
||||
|
||||
* Adds method to add user to group by user ID
|
||||
|
||||
0.5.0
|
||||
-----
|
||||
|
||||
* Adds badges functionality
|
||||
|
||||
0.4.0
|
||||
-----
|
||||
|
||||
* Adds initial groups functionality
|
||||
|
||||
0.3.2
|
||||
-----
|
||||
|
||||
* SSO functionality fixes
|
||||
|
||||
0.3.1
|
||||
-----
|
||||
|
||||
* Fix how empty responses are handled
|
||||
|
||||
0.3.0
|
||||
-----
|
||||
|
||||
* Added method to unsuspend suspended user
|
||||
|
||||
0.2.0
|
||||
-----
|
||||
|
||||
* Inital fork, including gberaudo's changes
|
||||
* Packaging cleanup, dropping Python 2.6 support and adding Python 3.5, PyPy,
|
||||
PyPy3
|
||||
* Packaging on PyPI
|
||||
|
||||
0.1.0.dev
|
||||
---------
|
||||
|
||||
All pre-PyPI development
|
||||
|
||||
6
MANIFEST.in
Normal file
6
MANIFEST.in
Normal file
@ -0,0 +1,6 @@
|
||||
include setup.py
|
||||
include README.rst
|
||||
include MANIFEST.in
|
||||
include HISTORY.rst
|
||||
include LICENSE
|
||||
recursive-include pydiscourse
|
||||
62
Makefile
Normal file
62
Makefile
Normal file
@ -0,0 +1,62 @@
|
||||
.PHONY: clean-pyc clean-build docs clean
|
||||
|
||||
clean: clean-build clean-pyc clean-test-all
|
||||
|
||||
clean-build:
|
||||
@rm -rf build/
|
||||
@rm -rf dist/
|
||||
@rm -rf *.egg-info
|
||||
|
||||
clean-pyc:
|
||||
-@find . -name '*.pyc' -follow -print0 | xargs -0 rm -f &> /dev/null
|
||||
-@find . -name '*.pyo' -follow -print0 | xargs -0 rm -f &> /dev/null
|
||||
-@find . -name '__pycache__' -type d -follow -print0 | xargs -0 rm -rf &> /dev/null
|
||||
|
||||
clean-test:
|
||||
rm -rf .coverage coverage*
|
||||
rm -rf tests/.coverage test/coverage*
|
||||
rm -rf htmlcov/
|
||||
|
||||
clean-test-all: clean-test
|
||||
rm -rf .tox/
|
||||
|
||||
lint:
|
||||
flake8 pydiscourse
|
||||
|
||||
test: ## Run test suite against current Python path
|
||||
python setup.py test
|
||||
|
||||
test-coverage: clean-test
|
||||
-py.test ${COVER_FLAGS} ${TEST_FLAGS}
|
||||
@exit_code=$?
|
||||
@-coverage html
|
||||
@exit ${exit_code}
|
||||
|
||||
test-all: ## Run all tox test environments, parallelized
|
||||
detox
|
||||
|
||||
check: clean-build clean-pyc clean-test lint test-coverage
|
||||
|
||||
build: clean ## Create distribution files for release
|
||||
python setup.py sdist bdist_wheel
|
||||
|
||||
release: build ## Create distribution files and publish to PyPI
|
||||
python setup.py check -r -s
|
||||
twine upload dist/*
|
||||
|
||||
sdist: clean ##sdist Create source distribution only
|
||||
python setup.py sdist
|
||||
ls -l dist
|
||||
|
||||
api-docs: ## Build autodocs from docstrings
|
||||
sphinx-apidoc -f -o docs pydiscourse
|
||||
|
||||
manual-docs: ## Build written docs
|
||||
$(MAKE) -C docs clean
|
||||
$(MAKE) -C docs html
|
||||
|
||||
docs: api-docs manual-docs ## Builds and open docs
|
||||
open docs/_build/html/index.html
|
||||
|
||||
help:
|
||||
@perl -nle'print $& if m{^[a-zA-Z_-]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
33
README.md
33
README.md
@ -1,33 +0,0 @@
|
||||
pydiscourse
|
||||
------------
|
||||
A Python library for the Discourse API.
|
||||
Its pretty basic right now but you need to start somewhere.
|
||||
|
||||
Examples
|
||||
-----------
|
||||
Create a client connection to a Discourse server::
|
||||
|
||||
from pydiscourse.client import DiscourseClient
|
||||
client = DiscourseClient('http://example.com', api_username='username', api_key='areallylongstringfromdiscourse')
|
||||
|
||||
Get info about a user::
|
||||
|
||||
user = client.user('eviltrout')
|
||||
print user
|
||||
|
||||
user_topics = client.topics_by('johnsmith')
|
||||
print user_topics
|
||||
|
||||
Create a new user::
|
||||
|
||||
user = client.create_user('The Black Knight', 'blacknight', 'knight@python.org', 'justafleshwound')
|
||||
|
||||
Command line
|
||||
----------------
|
||||
|
||||
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-username=system latest_topics
|
||||
pydiscoursecli --host=http://yourhost --api-username=system topics_by johnsmith
|
||||
pydiscoursecli --host=http://yourhost --api-username=system user eviltrout
|
||||
92
README.rst
Normal file
92
README.rst
Normal file
@ -0,0 +1,92 @@
|
||||
===========
|
||||
pydiscourse
|
||||
===========
|
||||
|
||||
.. 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/pydiscourse/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.
|
||||
|
||||
This is a fork of the original Tindie version. It was forked to include fixes,
|
||||
additional functionality, and to distribute a package on PyPI.
|
||||
|
||||
Goals
|
||||
=====
|
||||
|
||||
* Exceptional documentation
|
||||
* Support all supported Python versions
|
||||
* Provide functional parity with the Discourse API, for the currently supported
|
||||
version of Discourse (something of a moving target)
|
||||
|
||||
The order here is important. The Discourse API is itself poorly documented so
|
||||
the level of documentation in the Python client is critical.
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
::
|
||||
|
||||
pip install pydiscourse
|
||||
|
||||
Examples
|
||||
========
|
||||
|
||||
Create a client connection to a Discourse server:
|
||||
|
||||
.. code:: python
|
||||
|
||||
from pydiscourse import DiscourseClient
|
||||
client = DiscourseClient(
|
||||
'http://example.com',
|
||||
api_username='username',
|
||||
api_key='areallylongstringfromdiscourse')
|
||||
|
||||
Get info about a user:
|
||||
|
||||
.. code:: python
|
||||
|
||||
user = client.user('eviltrout')
|
||||
print user
|
||||
|
||||
user_topics = client.topics_by('johnsmith')
|
||||
print user_topics
|
||||
|
||||
Create a new user:
|
||||
|
||||
.. 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:
|
||||
|
||||
.. 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
|
||||
@ -28,9 +28,7 @@ import os
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
]
|
||||
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon']
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
@ -53,9 +51,9 @@ copyright = u'2014, Marc Sibson'
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = '0.1'
|
||||
version = '1.7'
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = '0.1'
|
||||
release = '1.7.0'
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
Development
|
||||
===============
|
||||
.. include:: ../DEVELOP.md
|
||||
:start-line: 2
|
||||
|
||||
.. include:: ../CONTRIBUTING.rst
|
||||
:start-line: 3
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
Introduction
|
||||
==============
|
||||
.. include:: ../README.md
|
||||
:start-line: 2
|
||||
|
||||
.. include:: ../README.rst
|
||||
:start-line: 3
|
||||
|
||||
7
docs/modules.rst
Normal file
7
docs/modules.rst
Normal file
@ -0,0 +1,7 @@
|
||||
pydiscourse
|
||||
===========
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 4
|
||||
|
||||
pydiscourse
|
||||
46
docs/pydiscourse.rst
Normal file
46
docs/pydiscourse.rst
Normal file
@ -0,0 +1,46 @@
|
||||
pydiscourse package
|
||||
===================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
pydiscourse.client module
|
||||
-------------------------
|
||||
|
||||
.. automodule:: pydiscourse.client
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pydiscourse.exceptions module
|
||||
-----------------------------
|
||||
|
||||
.. automodule:: pydiscourse.exceptions
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pydiscourse.main module
|
||||
-----------------------
|
||||
|
||||
.. automodule:: pydiscourse.main
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
pydiscourse.sso module
|
||||
----------------------
|
||||
|
||||
.. automodule:: pydiscourse.sso
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: pydiscourse
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@ -1 +0,0 @@
|
||||
__version__ = '0.1.0.dev'
|
||||
@ -1,208 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from pydiscourse.exceptions import DiscourseError, DiscourseServerError, DiscourseClientError
|
||||
|
||||
|
||||
log = logging.getLogger('pydiscourse.client')
|
||||
|
||||
|
||||
class DiscourseClient(object):
|
||||
""" A basic client for the Discourse API that implements the raw API
|
||||
|
||||
This class will attempt to remain roughly similar to the discourse_api rails API
|
||||
"""
|
||||
def __init__(self, host, api_username, api_key, timeout=None):
|
||||
self.host = host
|
||||
self.api_username = api_username
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
|
||||
def user(self, username):
|
||||
return self._get('/users/{0}.json'.format(username))['user']
|
||||
|
||||
def create_user(self, name, username, email, password, **kwargs):
|
||||
""" active='true', to avoid sending activation emails
|
||||
"""
|
||||
r = self._get('/users/hp.json')
|
||||
challenge = r['challenge'][::-1] # reverse challenge, discourse security check
|
||||
confirmations = r['value']
|
||||
return self._post('/users', name=name, username=username, email=email,
|
||||
password=password, password_confirmation=confirmations, challenge=challenge, **kwargs)
|
||||
|
||||
def trust_level(self, userid, level):
|
||||
return self._put('/admin/users/{0}/trust_level'.format(userid), level=level)
|
||||
|
||||
def update_avatar_from_url(self, username, url, **kwargs):
|
||||
return self._post('/users/{0}/preferences/avatar'.format(username), file=url, **kwargs)
|
||||
|
||||
def update_avatar_image(self, username, img, **kwargs):
|
||||
files = {'file': img}
|
||||
return self._post('/users/{0}/preferences/avatar'.format(username), files=files, **kwargs)
|
||||
|
||||
def toggle_gravatar(self, username, state=True, **kwargs):
|
||||
url = '/users/{0}/preferences/avatar/toggle'.format(username)
|
||||
|
||||
if bool(state):
|
||||
kwargs['use_uploaded_avatar'] = 'true'
|
||||
else:
|
||||
kwargs['use_uploaded_avatar'] = 'false'
|
||||
return self._put(url, **kwargs)
|
||||
|
||||
def update_email(self, username, email, **kwargs):
|
||||
return self._put('/users/{0}/preferences/email'.format(username), email=email, **kwargs)
|
||||
|
||||
def update_user(self, username, **kwargs):
|
||||
return self._put('/users/{0}'.format(username), **kwargs)
|
||||
|
||||
def update_username(self, username, new_username, **kwargs):
|
||||
return self._put('/users/{0}/preferences/username'.format(username), username=new_username, **kwargs)
|
||||
|
||||
def generate_api_key(self, userid, **kwargs):
|
||||
return self._post('/admin/users/{0}/generate_api_key'.format(userid), **kwargs)
|
||||
|
||||
def delete_user(self, userid, **kwargs):
|
||||
"""
|
||||
block_email='true'
|
||||
block_ip='false'
|
||||
block_urls='false'
|
||||
"""
|
||||
return self._delete('/admin/users/{0}.json'.format(userid), **kwargs)
|
||||
|
||||
def private_messages(self, username=None, **kwargs):
|
||||
if username is None:
|
||||
username = self.api_username
|
||||
return self._get('/topics/private-messages/{0}.json'.format(username), **kwargs)
|
||||
|
||||
def hot_topics(self, **kwargs):
|
||||
return self._get('/hot.json', **kwargs)
|
||||
|
||||
def latest_topics(self, **kwargs):
|
||||
return self._get('/latest.json', **kwargs)
|
||||
|
||||
def new_topics(self, **kwargs):
|
||||
return self._get('/new.json', **kwargs)
|
||||
|
||||
def topic(self, topic_id, **kwargs):
|
||||
return self._get('/t/{0}.json'.format(topic_id), **kwargs)
|
||||
|
||||
def post(self, topic_id, post_id, **kwargs):
|
||||
return self._get('/t/{0}/{1}.json'.format(topic_id, post_id), **kwargs)
|
||||
|
||||
def posts(self, topic_id, post_ids=None, **kwargs):
|
||||
""" Get a set of posts from a topic
|
||||
|
||||
post_ids: a list of post ids from the topic stream
|
||||
"""
|
||||
if post_ids:
|
||||
kwargs['post_ids[]'] = post_ids
|
||||
return self._get('/t/{0}/posts.json'.format(topic_id), **kwargs)
|
||||
|
||||
def topic_timings(self, topic_id, time, timings={}, **kwargs):
|
||||
""" Set time spent reading a post
|
||||
|
||||
time: overall time for the topic
|
||||
timings = { post_number: ms }
|
||||
|
||||
A side effect of this is to mark the post as read
|
||||
"""
|
||||
kwargs['topic_id'] = topic_id
|
||||
kwargs['topic_time'] = time
|
||||
for post_num, timing in timings.items():
|
||||
kwargs['timings[{0}]'.format(post_num)] = timing
|
||||
|
||||
return self._post('/topics/timings', **kwargs)
|
||||
|
||||
def topic_posts(self, topic_id, **kwargs):
|
||||
return self._get('/t/{0}/posts.json'.format(topic_id), **kwargs)
|
||||
|
||||
def create_post(self, content, **kwargs):
|
||||
""" int: topic_id the topic to reply too
|
||||
"""
|
||||
return self._post('/posts', raw=content, **kwargs)
|
||||
|
||||
def topics_by(self, username, **kwargs):
|
||||
url = '/topics/created-by/{0}.json'.format(username)
|
||||
return self._get(url, **kwargs)['topic_list']['topics']
|
||||
|
||||
def invite_user_to_topic(self, user_email, topic_id):
|
||||
kwargs = {
|
||||
'email': user_email,
|
||||
'topic_id': topic_id,
|
||||
}
|
||||
return self._post('/t/{0}/invite.json'.format(topic_id), **kwargs)
|
||||
|
||||
def search(self, term, **kwargs):
|
||||
kwargs['term'] = term
|
||||
return self._get('/search.json', **kwargs)
|
||||
|
||||
def categories(self, **kwargs):
|
||||
return self._get('/categories.json', **kwargs)['category_list']['categories']
|
||||
|
||||
def site_settings(self, **kwargs):
|
||||
for setting, value in kwargs.items():
|
||||
setting = setting.replace(' ', '_')
|
||||
self._request('PUT', '/admin/site_settings/{0}'.format(setting), {setting: value})
|
||||
|
||||
def _get(self, path, **kwargs):
|
||||
return self._request('GET', path, kwargs)
|
||||
|
||||
def _put(self, path, **kwargs):
|
||||
return self._request('PUT', path, kwargs)
|
||||
|
||||
def _post(self, path, **kwargs):
|
||||
return self._request('POST', path, kwargs)
|
||||
|
||||
def _delete(self, path, **kwargs):
|
||||
return self._request('DELETE', path, kwargs)
|
||||
|
||||
def _request(self, verb, path, params):
|
||||
params['api_key'] = self.api_key
|
||||
if 'api_username' not in params:
|
||||
params['api_username'] = self.api_username
|
||||
url = self.host + path
|
||||
|
||||
response = requests.request(verb, url, allow_redirects=False, params=params, timeout=self.timeout)
|
||||
|
||||
log.debug('response %s: %s', response.status_code, repr(response.text))
|
||||
if not response.ok:
|
||||
if response.reason:
|
||||
msg = response.reason
|
||||
else:
|
||||
try:
|
||||
msg = u','.join(response.json()['errors'])
|
||||
except (ValueError, TypeError, KeyError):
|
||||
msg = u'{0}: {1}'.format(response.status_code, response.text)
|
||||
|
||||
if 400 <= response.status_code < 500:
|
||||
raise DiscourseClientError(msg, response=response)
|
||||
|
||||
raise DiscourseServerError(msg, response=response)
|
||||
|
||||
if response.status_code == 302:
|
||||
raise DiscourseError('Unexpected Redirect, invalid api key or host?', response=response)
|
||||
|
||||
json_content = 'application/json; charset=utf-8'
|
||||
content_type = response.headers['content-type']
|
||||
if content_type != json_content:
|
||||
# some calls return empty html documents
|
||||
if response.content == ' ':
|
||||
return None
|
||||
|
||||
raise DiscourseError('Invalid Response, expecting "{0}" got "{1}"'.format(
|
||||
json_content, content_type), response=response)
|
||||
|
||||
try:
|
||||
decoded = response.json()
|
||||
except ValueError:
|
||||
raise DiscourseError('failed to decode response', response=response)
|
||||
|
||||
if 'errors' in decoded:
|
||||
message = decoded.get('message')
|
||||
if not message:
|
||||
message = u','.join(decoded['errors'])
|
||||
raise DiscourseError(message, response=response)
|
||||
|
||||
return decoded
|
||||
@ -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 """
|
||||
49
pyproject.toml
Normal file
49
pyproject.toml
Normal 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/*"]
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
requests
|
||||
nose
|
||||
mock
|
||||
9
requirements.txt
Normal file
9
requirements.txt
Normal 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/
|
||||
47
setup.cfg
Normal file
47
setup.cfg
Normal file
@ -0,0 +1,47 @@
|
||||
[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
|
||||
56
setup.py
56
setup.py
@ -1,52 +1,6 @@
|
||||
import codecs
|
||||
import os
|
||||
"""
|
||||
See setup.cfg for packaging settings
|
||||
"""
|
||||
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
|
||||
def read(fname):
|
||||
return codecs.open(os.path.join(os.path.dirname(__file__), fname)).read()
|
||||
|
||||
|
||||
# Provided as an attribute, so you can append to these instead
|
||||
# of replicating them:
|
||||
standard_exclude = ["*.py", "*.pyc", "*$py.class", "*~", ".*", "*.bak"]
|
||||
standard_exclude_directories = [
|
||||
".*", "CVS", "_darcs", "./build", "./dist", "EGG-INFO", "*.egg-info"
|
||||
]
|
||||
|
||||
|
||||
NAME = "pydiscourse"
|
||||
DESCRIPTION = "A Python library for the Discourse API"
|
||||
AUTHOR = "Marc Sibson"
|
||||
AUTHOR_EMAIL = "sibson@gmail.com"
|
||||
URL = "https://github.com/tindie/pydiscourse"
|
||||
PACKAGE = "pydiscourse"
|
||||
VERSION = __import__(PACKAGE).__version__
|
||||
|
||||
|
||||
setup(
|
||||
name=NAME,
|
||||
version=VERSION,
|
||||
description=DESCRIPTION,
|
||||
long_description=read("README.md"),
|
||||
author=AUTHOR,
|
||||
author_email=AUTHOR_EMAIL,
|
||||
license="BSD",
|
||||
url=URL,
|
||||
packages=find_packages(exclude=["tests.*", "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",
|
||||
],
|
||||
zip_safe=False,
|
||||
)
|
||||
from setuptools import setup
|
||||
setup()
|
||||
|
||||
8
src/pydiscourse/__init__.py
Normal file
8
src/pydiscourse/__init__.py
Normal file
@ -0,0 +1,8 @@
|
||||
"""Python client for the Discourse API."""
|
||||
|
||||
__version__ = "1.7.0"
|
||||
|
||||
from pydiscourse.client import DiscourseClient
|
||||
|
||||
|
||||
__all__ = ["DiscourseClient"]
|
||||
1748
src/pydiscourse/client.py
Normal file
1748
src/pydiscourse/client.py
Normal file
File diff suppressed because it is too large
Load Diff
19
src/pydiscourse/exceptions.py
Normal file
19
src/pydiscourse/exceptions.py
Normal 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,40 +1,49 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""Simple command line interface for making Discourse API queries."""
|
||||
|
||||
import cmd
|
||||
import json
|
||||
import logging
|
||||
import optparse
|
||||
import os
|
||||
import pydoc
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
from pydiscourse.client import DiscourseClient, DiscourseError
|
||||
|
||||
|
||||
class DiscourseCmd(cmd.Cmd):
|
||||
prompt = 'discourse>'
|
||||
"""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
|
||||
self.prompt = "%s>" % self.client.host
|
||||
|
||||
def __getattr__(self, attr):
|
||||
if attr.startswith('do_'):
|
||||
"""Gets attributes with dynamic name handling"""
|
||||
if attr.startswith("do_"):
|
||||
method = getattr(self.client, attr[3:])
|
||||
|
||||
def wrapper(arg):
|
||||
args = arg.split()
|
||||
kwargs = dict(a.split('=') for a in args if '=' in a)
|
||||
args = [a for a in args if '=' not in a]
|
||||
kwargs = dict(a.split("=") for a in args if "=" in a)
|
||||
args = [a for a in args if "=" not in a]
|
||||
try:
|
||||
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
|
||||
|
||||
elif attr.startswith('help_'):
|
||||
elif attr.startswith("help_"):
|
||||
method = getattr(self.client, attr[5:])
|
||||
|
||||
def wrapper():
|
||||
@ -45,20 +54,34 @@ 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=(',', ': '))
|
||||
json.dump(
|
||||
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')
|
||||
op.add_option('-v', '--verbose', action='store_true')
|
||||
op.add_option("--host", default="http://localhost:4000")
|
||||
op.add_option("--api-user", default="system")
|
||||
op.add_option("-v", "--verbose", action="store_true")
|
||||
|
||||
api_key = os.environ['DISCOURSE_API_KEY']
|
||||
options, args = op.parse_args()
|
||||
if not options.host.startswith("http"):
|
||||
op.error("host must include protocol, eg http://")
|
||||
|
||||
api_key = os.environ.get("DISCOURSE_API_KEY")
|
||||
if not api_key:
|
||||
op.error("please set DISCOURSE_API_KEY")
|
||||
|
||||
client = DiscourseClient(options.host, options.api_user, api_key)
|
||||
|
||||
if options.verbose:
|
||||
@ -67,12 +90,12 @@ def main():
|
||||
|
||||
c = DiscourseCmd(client)
|
||||
if args:
|
||||
line = ' '.join(args)
|
||||
line = " ".join(args)
|
||||
result = c.onecmd(line)
|
||||
c.postcmd(result, line)
|
||||
else:
|
||||
c.cmdloop()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
100
src/pydiscourse/sso.py
Normal file
100
src/pydiscourse/sso.py
Normal file
@ -0,0 +1,100 @@
|
||||
"""Implement Single Sign On for Discourse with a Python managed auth DB.
|
||||
|
||||
https://meta.discourse.org/t/official-single-sign-on-for-discourse/13045
|
||||
|
||||
Thanks to James Potter for the heavy lifting, detailed at
|
||||
https://meta.discourse.org/t/sso-example-for-django/14258
|
||||
|
||||
A SSO request handler might look something like
|
||||
|
||||
@login_required
|
||||
def discourse_sso_view(request):
|
||||
payload = request.GET.get('sso')
|
||||
signature = request.GET.get('sig')
|
||||
try:
|
||||
nonce = sso_validate(payload, signature, SECRET)
|
||||
except DiscourseError as e:
|
||||
return HTTP400(e.args[0])
|
||||
|
||||
url = sso_redirect_url(nonce, SECRET, request.user.email,
|
||||
request.user.id, request.user.username)
|
||||
return redirect('http://discuss.example.com' + url)
|
||||
"""
|
||||
from base64 import b64encode, b64decode
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
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
|
||||
"""
|
||||
if None in [payload, signature]:
|
||||
raise DiscourseError("No SSO payload or signature.")
|
||||
|
||||
if not secret:
|
||||
raise DiscourseError("Invalid secret.")
|
||||
|
||||
payload = unquote(payload)
|
||||
if not payload:
|
||||
raise DiscourseError("Invalid payload.")
|
||||
|
||||
decoded = b64decode(payload.encode("utf-8")).decode("utf-8")
|
||||
if "nonce" not in decoded:
|
||||
raise DiscourseError("Invalid payload.")
|
||||
|
||||
h = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
payload.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
)
|
||||
this_signature = h.hexdigest()
|
||||
|
||||
if this_signature != signature:
|
||||
raise DiscourseError("Payload does not match signature.")
|
||||
|
||||
# Discourse returns querystring encoded value. We only need `nonce`
|
||||
qs = parse_qs(decoded)
|
||||
return qs["nonce"][0]
|
||||
|
||||
|
||||
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()})
|
||||
return query_string
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
kwargs.update(
|
||||
{
|
||||
"nonce": nonce,
|
||||
"email": email,
|
||||
"external_id": external_id,
|
||||
"username": username,
|
||||
},
|
||||
)
|
||||
|
||||
return "/session/sso_login?%s" % sso_payload(secret, **kwargs)
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
136
tests/conftest.py
Normal file
136
tests/conftest.py
Normal 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
|
||||
@ -1,113 +1,220 @@
|
||||
import unittest
|
||||
import mock
|
||||
"""Tests for the client methods."""
|
||||
|
||||
from pydiscourse import client
|
||||
import urllib.parse
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
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'})
|
||||
def test_empty_content_http_ok(discourse_host, discourse_client, discourse_request):
|
||||
"""Empty content should not raise error
|
||||
|
||||
Critical to test against *bytestrings* rather than unicode
|
||||
"""
|
||||
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", {})
|
||||
|
||||
assert resp is None
|
||||
|
||||
|
||||
class ClientBaseTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.host = 'testhost'
|
||||
self.api_username = 'testuser'
|
||||
self.api_key = 'testkey'
|
||||
class TestUserManagement:
|
||||
def test_get_user(self, discourse_host, discourse_client, discourse_request):
|
||||
request = discourse_request(
|
||||
"get",
|
||||
"/users/someuser.json",
|
||||
json={"user": "someuser"},
|
||||
)
|
||||
discourse_client.user("someuser")
|
||||
|
||||
self.client = client.DiscourseClient(self.host, self.api_username, self.api_key)
|
||||
assert request.called_once
|
||||
|
||||
def assertRequestCalled(self, request, verb, url, **params):
|
||||
self.assertTrue(request.called)
|
||||
def test_users(self, discourse_client, discourse_request):
|
||||
request = discourse_request("get", "/admin/users/list/active.json")
|
||||
discourse_client.users()
|
||||
assert request.called_once
|
||||
|
||||
args, kwargs = request.call_args
|
||||
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",
|
||||
)
|
||||
|
||||
self.assertEqual(args[0], verb)
|
||||
self.assertEqual(args[1], self.host + url)
|
||||
assert session_request.called_once
|
||||
assert user_request.called_once
|
||||
|
||||
kwargs = kwargs['params']
|
||||
self.assertEqual(kwargs.pop('api_username'), self.api_username)
|
||||
self.assertEqual(kwargs.pop('api_key'), self.api_key)
|
||||
self.assertEqual(kwargs, params)
|
||||
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)
|
||||
|
||||
assert request.called_once
|
||||
|
||||
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
|
||||
|
||||
|
||||
@mock.patch('requests.request')
|
||||
class TestUser(ClientBaseTestCase):
|
||||
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_user(self, request):
|
||||
prepare_response(request)
|
||||
self.client.user('someuser')
|
||||
self.assertRequestCalled(request, 'GET', '/users/someuser.json')
|
||||
def test_latest_topics(self, discourse_client, discourse_request):
|
||||
request = discourse_request("get", "/latest.json")
|
||||
discourse_client.latest_topics()
|
||||
|
||||
def test_create_user(self, request):
|
||||
prepare_response(request)
|
||||
self.client.create_user('Test User', 'testuser', 'test@example.com', 'notapassword')
|
||||
self.assertEqual(request.call_count, 2)
|
||||
# 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_new_topics(self, discourse_client, discourse_request):
|
||||
request = discourse_request("get", "/new.json")
|
||||
discourse_client.new_topics()
|
||||
assert request.called_once
|
||||
|
||||
def test_update_user(self, request):
|
||||
prepare_response(request)
|
||||
self.client.update_user('someuser', a='a', b='b')
|
||||
self.assertRequestCalled(request, 'PUT', '/users/someuser', a='a', b='b')
|
||||
def test_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_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_topics_by(self, discourse_client, discourse_request):
|
||||
request = discourse_request(
|
||||
"get",
|
||||
"/topics/created-by/someuser.json",
|
||||
json={"topic_list": {"topics": []}},
|
||||
)
|
||||
discourse_client.topics_by("someuser")
|
||||
|
||||
assert request.called_once
|
||||
|
||||
def test_invite_user_to_topic(self, discourse_client, 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"]
|
||||
|
||||
|
||||
@mock.patch('requests.request')
|
||||
class TestTopics(ClientBaseTestCase):
|
||||
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_hot_topics(self, request):
|
||||
prepare_response(request)
|
||||
self.client.hot_topics()
|
||||
self.assertRequestCalled(request, 'GET', '/hot.json')
|
||||
|
||||
def test_latest_topics(self, request):
|
||||
prepare_response(request)
|
||||
self.client.latest_topics()
|
||||
self.assertRequestCalled(request, 'GET', '/latest.json')
|
||||
|
||||
def test_new_topics(self, request):
|
||||
prepare_response(request)
|
||||
self.client.new_topics()
|
||||
self.assertRequestCalled(request, 'GET', '/new.json')
|
||||
|
||||
def test_topic(self, request):
|
||||
prepare_response(request)
|
||||
self.client.topic(22)
|
||||
self.assertRequestCalled(request, 'GET', '/t/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)
|
||||
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
|
||||
|
||||
|
||||
@mock.patch('requests.request')
|
||||
class MiscellaneousTests(ClientBaseTestCase):
|
||||
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
|
||||
|
||||
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'])
|
||||
class TestCategories:
|
||||
def test_categories(self, discourse_client, discourse_request):
|
||||
request = discourse_request(
|
||||
"get",
|
||||
"/categories.json",
|
||||
json={"category_list": {"categories": []}},
|
||||
)
|
||||
discourse_client.categories()
|
||||
assert request.called_once
|
||||
|
||||
def test_update_category(self, discourse_client, discourse_request):
|
||||
request = discourse_request("put", "/categories/123")
|
||||
discourse_client.update_category(123, a="a", b="b")
|
||||
|
||||
request_payload = request.last_request.json()
|
||||
|
||||
assert request_payload["a"] == "a"
|
||||
assert request_payload["b"] == "b"
|
||||
|
||||
|
||||
class TestBadges:
|
||||
def test_badges(self, discourse_client, discourse_request):
|
||||
request = discourse_request("get", "/admin/badges.json")
|
||||
discourse_client.badges()
|
||||
assert request.called_once
|
||||
|
||||
def test_grant_badge_to(self, discourse_client, discourse_request):
|
||||
request = discourse_request("post", "/user_badges")
|
||||
discourse_client.grant_badge_to("username", 1)
|
||||
|
||||
request_payload = urllib.parse.parse_qs(request.last_request.text)
|
||||
|
||||
assert request_payload["username"] == ["username"]
|
||||
assert request_payload["badge_id"] == ["1"]
|
||||
|
||||
|
||||
class TestAbout:
|
||||
def test_about(self, discourse_client, discourse_request):
|
||||
request = discourse_request("get", "/about.json")
|
||||
discourse_client.about()
|
||||
assert request.called_once
|
||||
|
||||
95
tests/test_sso.py
Normal file
95
tests/test_sso.py
Normal file
@ -0,0 +1,95 @@
|
||||
from base64 import b64decode
|
||||
from urllib.parse import unquote
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
import pytest
|
||||
|
||||
from pydiscourse import sso
|
||||
from pydiscourse.exceptions import DiscourseError
|
||||
|
||||
|
||||
def test_sso_validate_missing_payload():
|
||||
with pytest.raises(DiscourseError) as excinfo:
|
||||
sso.sso_validate(None, "abc", "123")
|
||||
|
||||
assert excinfo.value.args[0] == "No SSO payload or signature."
|
||||
|
||||
|
||||
def test_sso_validate_empty_payload():
|
||||
with pytest.raises(DiscourseError) as excinfo:
|
||||
sso.sso_validate("", "abc", "123")
|
||||
|
||||
assert excinfo.value.args[0] == "Invalid payload."
|
||||
|
||||
|
||||
def test_sso_validate_missing_signature():
|
||||
with pytest.raises(DiscourseError) as excinfo:
|
||||
sso.sso_validate("sig", None, "123")
|
||||
|
||||
assert excinfo.value.args[0] == "No SSO payload or signature."
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
assert excinfo.value.args[0] == "Invalid secret."
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
28
tox.ini
Normal file
28
tox.ini
Normal file
@ -0,0 +1,28 @@
|
||||
[tox]
|
||||
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 =
|
||||
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:ruff]
|
||||
basepython=python
|
||||
skip_install=true
|
||||
deps=
|
||||
ruff
|
||||
commands=
|
||||
ruff .
|
||||
Loading…
x
Reference in New Issue
Block a user