Compare commits
1 Commits
master
...
f/objectap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f53443188 |
3
.coveragerc
Normal file
3
.coveragerc
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[run]
|
||||||
|
|
||||||
|
include: pydiscourse/*
|
||||||
@ -1,11 +0,0 @@
|
|||||||
c0db7215c95dbd31770ade1fc6ea65aa426d4590
|
|
||||||
0177c46356b9d0fc4b93f09aab7a224643a3685e
|
|
||||||
f6b4c02fc0f144dffc88cdd48b8261a69228d2f0
|
|
||||||
2a3036f0395a810b0941522bfb1ca80b159525ce
|
|
||||||
c49d29620dfb867f73ebb6be84b5e1ba922fadc9
|
|
||||||
dc498679cc6769acafe19cf0083f40154ffdcff8
|
|
||||||
7ab58533b759d1ff879476a5703051b201afd835
|
|
||||||
fe4f67c04160a76948d810848ae082713ea6b5ed
|
|
||||||
2aac9a20beb19a6a052286f73f5d0f5bf76ed758
|
|
||||||
2be1a46c1da497e136818b5ef77708b8c5b69e57
|
|
||||||
31db8017bc90978b879c5caa7f1cd4777d19a27e
|
|
||||||
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -1,8 +0,0 @@
|
|||||||
### 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
33
.github/workflows/pypi.yml
vendored
@ -1,33 +0,0 @@
|
|||||||
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
47
.github/workflows/test.yml
vendored
@ -1,47 +0,0 @@
|
|||||||
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,9 +36,3 @@ coverage.xml
|
|||||||
|
|
||||||
# Sphinx documentation
|
# Sphinx documentation
|
||||||
docs/_build/
|
docs/_build/
|
||||||
|
|
||||||
# Pyenv
|
|
||||||
.python-version
|
|
||||||
|
|
||||||
# PyCharm
|
|
||||||
.idea
|
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
exclude: "docs|.git|.tox"
|
|
||||||
default_stages: [ commit ]
|
|
||||||
fail_fast: true
|
|
||||||
|
|
||||||
repos:
|
|
||||||
|
|
||||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
|
||||||
rev: "v0.0.286"
|
|
||||||
hooks:
|
|
||||||
- id: ruff
|
|
||||||
|
|
||||||
- repo: https://github.com/psf/black
|
|
||||||
rev: 23.7.0
|
|
||||||
hooks:
|
|
||||||
- id: black
|
|
||||||
args:
|
|
||||||
- --config=pyproject.toml
|
|
||||||
- src/pydiscourse
|
|
||||||
- tests
|
|
||||||
10
.travis.yml
Normal file
10
.travis.yml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
language: python
|
||||||
|
python:
|
||||||
|
- "2.7"
|
||||||
|
|
||||||
|
install:
|
||||||
|
- "pip install -r requirements.dev.txt"
|
||||||
|
- "pip install ."
|
||||||
|
|
||||||
|
|
||||||
|
script: nosetests
|
||||||
15
AUTHORS
15
AUTHORS
@ -1,15 +0,0 @@
|
|||||||
(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
106
CONTRIBUTING.rst
@ -1,106 +0,0 @@
|
|||||||
============
|
|
||||||
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
Normal file
30
DEVELOP.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
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
164
HISTORY.rst
@ -1,164 +0,0 @@
|
|||||||
.. :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
|
|
||||||
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
include setup.py
|
|
||||||
include README.rst
|
|
||||||
include MANIFEST.in
|
|
||||||
include HISTORY.rst
|
|
||||||
include LICENSE
|
|
||||||
recursive-include pydiscourse
|
|
||||||
62
Makefile
62
Makefile
@ -1,62 +0,0 @@
|
|||||||
.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
Normal file
33
README.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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
92
README.rst
@ -1,92 +0,0 @@
|
|||||||
===========
|
|
||||||
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,7 +28,9 @@ import os
|
|||||||
# Add any Sphinx extension module names here, as strings. They can be
|
# Add any Sphinx extension module names here, as strings. They can be
|
||||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||||
# ones.
|
# ones.
|
||||||
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon']
|
extensions = [
|
||||||
|
'sphinx.ext.autodoc',
|
||||||
|
]
|
||||||
|
|
||||||
# Add any paths that contain templates here, relative to this directory.
|
# Add any paths that contain templates here, relative to this directory.
|
||||||
templates_path = ['_templates']
|
templates_path = ['_templates']
|
||||||
@ -51,9 +53,9 @@ copyright = u'2014, Marc Sibson'
|
|||||||
# built documents.
|
# built documents.
|
||||||
#
|
#
|
||||||
# The short X.Y version.
|
# The short X.Y version.
|
||||||
version = '1.7'
|
version = '0.1'
|
||||||
# The full version, including alpha/beta/rc tags.
|
# The full version, including alpha/beta/rc tags.
|
||||||
release = '1.7.0'
|
release = '0.1'
|
||||||
|
|
||||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||||
# for a list of supported languages.
|
# for a list of supported languages.
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
Development
|
Development
|
||||||
===============
|
===============
|
||||||
|
.. include:: ../DEVELOP.md
|
||||||
.. include:: ../CONTRIBUTING.rst
|
:start-line: 2
|
||||||
:start-line: 3
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
Introduction
|
Introduction
|
||||||
==============
|
==============
|
||||||
|
.. include:: ../README.md
|
||||||
.. include:: ../README.rst
|
:start-line: 2
|
||||||
:start-line: 3
|
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
pydiscourse
|
|
||||||
===========
|
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 4
|
|
||||||
|
|
||||||
pydiscourse
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
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
pydiscourse/__init__.py
Normal file
1
pydiscourse/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
__version__ = '0.1.0.dev'
|
||||||
160
pydiscourse/apidev.py
Normal file
160
pydiscourse/apidev.py
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
""" A higher level API wrapper for communicating with a Discourse instance
|
||||||
|
|
||||||
|
EXPERIMENTAL, subject to complete and radical change
|
||||||
|
|
||||||
|
Goal
|
||||||
|
------
|
||||||
|
A pythonic wrapper around the discourse API that minimizes requests by lazy loading of data.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def datetime_from(date):
|
||||||
|
DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%f'
|
||||||
|
# XXX not handling timezone, but we're not using it yet
|
||||||
|
return datetime.strptime(date[:-6], DATE_FORMAT)
|
||||||
|
|
||||||
|
|
||||||
|
class DiscourseUser(object):
|
||||||
|
username = None
|
||||||
|
userid = None
|
||||||
|
avatar_template = None
|
||||||
|
default_avatar_size = 40
|
||||||
|
|
||||||
|
def avatar(self, size=None):
|
||||||
|
if size is None:
|
||||||
|
size = self.default_avatar_size
|
||||||
|
return self.avatar_template.replace(u'{size}', unicode(size))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<DiscourseUser {0} {1}>'.format(self.userid, self.username)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_summary(cls, summary):
|
||||||
|
instance = cls()
|
||||||
|
instance.username = summary['username']
|
||||||
|
instance.userid = summary['id']
|
||||||
|
instance.avatar_template = summary['avatar_template']
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class DiscourseUserSet(object):
|
||||||
|
def __init__(self, users):
|
||||||
|
self.users = users
|
||||||
|
|
||||||
|
self.byname = {u.username: u for u in users}
|
||||||
|
self.byid = {u.userid: u for u in users}
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self.users)
|
||||||
|
|
||||||
|
def __getitem__(self, item):
|
||||||
|
try:
|
||||||
|
return self.byname[item]
|
||||||
|
except KeyError:
|
||||||
|
return self.byid[item]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_response(cls, response):
|
||||||
|
users = [DiscourseUser.from_summary(u) for u in response.get('users', [])]
|
||||||
|
return cls(users)
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoursePost(object):
|
||||||
|
default_avatar_size = 40
|
||||||
|
|
||||||
|
def avatar(self, size=None):
|
||||||
|
if size is None:
|
||||||
|
size = self.default_avatar_size
|
||||||
|
return self.avatar_template.replace(u'{size}', unicode(size))
|
||||||
|
|
||||||
|
def date_created(self):
|
||||||
|
return datetime_from(self.data['created_at'])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data):
|
||||||
|
instance = cls()
|
||||||
|
instance.data = data
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
return self.data[attr]
|
||||||
|
|
||||||
|
|
||||||
|
class DiscourseTopic(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.posts = None
|
||||||
|
self.data = None
|
||||||
|
self.raw_response = None
|
||||||
|
|
||||||
|
def created_by(self):
|
||||||
|
return DiscourseUser.from_summary(self.data['details']['created_by'])
|
||||||
|
|
||||||
|
def created_at(self):
|
||||||
|
return datetime_from(self.data['created_at'])
|
||||||
|
|
||||||
|
def last_posted_at(self):
|
||||||
|
return datetime_from(self.data['last_posted_at'])
|
||||||
|
|
||||||
|
def num_unread(self):
|
||||||
|
if self.data['unseen']:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return self.data['new_posts']
|
||||||
|
|
||||||
|
def participants(self):
|
||||||
|
return [DiscourseUser.from_summary(u) for u in self.data['participants']]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_response(cls, response):
|
||||||
|
instance = DiscourseTopic.from_dict(response)
|
||||||
|
instance.raw_response = response
|
||||||
|
instance.posts = []
|
||||||
|
for post in response['post_stream']['posts']:
|
||||||
|
instance.posts.append(DiscoursePost.from_dict(post))
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data):
|
||||||
|
instance = cls()
|
||||||
|
instance.data = data
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def fetch_remaining_posts(self, discourse):
|
||||||
|
""" The initial topic response is paginated, this makes another request to get additional posts
|
||||||
|
"""
|
||||||
|
if self.data['posts_count'] > len(self.posts):
|
||||||
|
missing = self.data['post_stream']['stream'][len(self.posts):]
|
||||||
|
response = discourse.posts(self.id, missing)
|
||||||
|
for post in response['post_stream']['posts']:
|
||||||
|
self.posts.append(DiscoursePost.from_dict(post))
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
return self.data[attr]
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return u'<Topic {0}>'.format(self.title)
|
||||||
|
|
||||||
|
|
||||||
|
class DiscourseTopicSet(object):
|
||||||
|
def __init__(self, topics):
|
||||||
|
self.topics = topics
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return self.topics
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self.topics)
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.topics)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_response(cls, response):
|
||||||
|
topics = response.get('topic_list', {}).get('topics', [])
|
||||||
|
topics = [DiscourseTopic.from_dict(t) for t in topics]
|
||||||
|
return cls(topics)
|
||||||
208
pydiscourse/client.py
Executable file
208
pydiscourse/client.py
Executable file
@ -0,0 +1,208 @@
|
|||||||
|
#!/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
|
||||||
13
pydiscourse/exceptions.py
Normal file
13
pydiscourse/exceptions.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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 """
|
||||||
@ -1,49 +1,40 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
|
|
||||||
"""Simple command line interface for making Discourse API queries."""
|
|
||||||
|
|
||||||
import cmd
|
import cmd
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import optparse
|
import optparse
|
||||||
import os
|
|
||||||
import pydoc
|
import pydoc
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
from pydiscourse.client import DiscourseClient, DiscourseError
|
from pydiscourse.client import DiscourseClient, DiscourseError
|
||||||
|
|
||||||
|
|
||||||
class DiscourseCmd(cmd.Cmd):
|
class DiscourseCmd(cmd.Cmd):
|
||||||
"""Handles CLI commands"""
|
prompt = 'discourse>'
|
||||||
|
|
||||||
prompt = "discourse>"
|
|
||||||
output = sys.stdout
|
output = sys.stdout
|
||||||
|
|
||||||
def __init__(self, client):
|
def __init__(self, client):
|
||||||
"""Initialize command"""
|
|
||||||
cmd.Cmd.__init__(self)
|
cmd.Cmd.__init__(self)
|
||||||
self.client = client
|
self.client = client
|
||||||
self.prompt = "%s>" % self.client.host
|
self.prompt = '%s>' % self.client.host
|
||||||
|
|
||||||
def __getattr__(self, attr):
|
def __getattr__(self, attr):
|
||||||
"""Gets attributes with dynamic name handling"""
|
if attr.startswith('do_'):
|
||||||
if attr.startswith("do_"):
|
|
||||||
method = getattr(self.client, attr[3:])
|
method = getattr(self.client, attr[3:])
|
||||||
|
|
||||||
def wrapper(arg):
|
def wrapper(arg):
|
||||||
args = arg.split()
|
args = arg.split()
|
||||||
kwargs = dict(a.split("=") for a in args if "=" in a)
|
kwargs = dict(a.split('=') for a in args if '=' in a)
|
||||||
args = [a for a in args if "=" not in a]
|
args = [a for a in args if '=' not in a]
|
||||||
try:
|
try:
|
||||||
return method(*args, **kwargs)
|
return method(*args, **kwargs)
|
||||||
|
|
||||||
except DiscourseError as e:
|
except DiscourseError as e:
|
||||||
sys.stderr.write(f"{e}, {e.response.text}\n")
|
print e, e.response.text
|
||||||
return e.response
|
return e.response
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
elif attr.startswith("help_"):
|
elif attr.startswith('help_'):
|
||||||
method = getattr(self.client, attr[5:])
|
method = getattr(self.client, attr[5:])
|
||||||
|
|
||||||
def wrapper():
|
def wrapper():
|
||||||
@ -54,34 +45,20 @@ class DiscourseCmd(cmd.Cmd):
|
|||||||
raise AttributeError
|
raise AttributeError
|
||||||
|
|
||||||
def postcmd(self, result, line):
|
def postcmd(self, result, line):
|
||||||
"""Writes output of the command to console"""
|
|
||||||
try:
|
try:
|
||||||
json.dump(
|
json.dump(result, self.output, sort_keys=True, indent=4, separators=(',', ': '))
|
||||||
result,
|
|
||||||
self.output,
|
|
||||||
sort_keys=True,
|
|
||||||
indent=4,
|
|
||||||
separators=(",", ": "),
|
|
||||||
)
|
|
||||||
except TypeError:
|
except TypeError:
|
||||||
self.output.write(result.text)
|
self.output.write(result.text)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Runs the CLI application"""
|
|
||||||
op = optparse.OptionParser()
|
op = optparse.OptionParser()
|
||||||
op.add_option("--host", default="http://localhost:4000")
|
op.add_option('--host', default='http://localhost:4000')
|
||||||
op.add_option("--api-user", default="system")
|
op.add_option('--api-user', default='system')
|
||||||
op.add_option("-v", "--verbose", action="store_true")
|
op.add_option('-v', '--verbose', action='store_true')
|
||||||
|
|
||||||
|
api_key = os.environ['DISCOURSE_API_KEY']
|
||||||
options, args = op.parse_args()
|
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)
|
client = DiscourseClient(options.host, options.api_user, api_key)
|
||||||
|
|
||||||
if options.verbose:
|
if options.verbose:
|
||||||
@ -90,12 +67,12 @@ def main():
|
|||||||
|
|
||||||
c = DiscourseCmd(client)
|
c = DiscourseCmd(client)
|
||||||
if args:
|
if args:
|
||||||
line = " ".join(args)
|
line = ' '.join(args)
|
||||||
result = c.onecmd(line)
|
result = c.onecmd(line)
|
||||||
c.postcmd(result, line)
|
c.postcmd(result, line)
|
||||||
else:
|
else:
|
||||||
c.cmdloop()
|
c.cmdloop()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
@ -1,49 +0,0 @@
|
|||||||
[tool.black]
|
|
||||||
line-length=120
|
|
||||||
target-version = ["py311"]
|
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
exclude = [
|
|
||||||
".tox",
|
|
||||||
".git",
|
|
||||||
"build",
|
|
||||||
"dist",
|
|
||||||
"docs",
|
|
||||||
".ropeproject",
|
|
||||||
]
|
|
||||||
ignore = [
|
|
||||||
"S101", # Assertions good, actually
|
|
||||||
"TRY003", # For now not worth back tracking
|
|
||||||
]
|
|
||||||
line-length = 240
|
|
||||||
select = [
|
|
||||||
"S", # Security (formerly B when using Bandit directly)
|
|
||||||
"E",
|
|
||||||
"F",
|
|
||||||
"N",
|
|
||||||
"W",
|
|
||||||
"COM", # commas
|
|
||||||
"PT", # pytest
|
|
||||||
"UP", # Upgrade Python syntax
|
|
||||||
"T",
|
|
||||||
"A", # built-in shadowing
|
|
||||||
"FBT", # Boolean traps
|
|
||||||
"BLE", # Blind exceptions
|
|
||||||
"PIE",
|
|
||||||
"TRY",
|
|
||||||
"ERA", # eradicate commented out code
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.ruff.flake8-pytest-style]
|
|
||||||
fixture-parentheses = false
|
|
||||||
mark-parentheses = true
|
|
||||||
parametrize-names-type = "tuple"
|
|
||||||
parametrize-values-row-type = "tuple"
|
|
||||||
|
|
||||||
[tool.ruff.mccabe]
|
|
||||||
# Unlike Flake8, default to a complexity level of 10.
|
|
||||||
max-complexity = 10
|
|
||||||
|
|
||||||
[tool.coverage.run]
|
|
||||||
include = ["src/pydiscourse/*"]
|
|
||||||
|
|
||||||
3
requirements.dev.txt
Normal file
3
requirements.dev.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
requests
|
||||||
|
nose
|
||||||
|
mock
|
||||||
@ -1,9 +0,0 @@
|
|||||||
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
47
setup.cfg
@ -1,47 +0,0 @@
|
|||||||
[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,6 +1,52 @@
|
|||||||
"""
|
import codecs
|
||||||
See setup.cfg for packaging settings
|
import os
|
||||||
"""
|
|
||||||
|
|
||||||
from setuptools import setup
|
from setuptools import setup, find_packages
|
||||||
setup()
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
"""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
@ -1,19 +0,0 @@
|
|||||||
"""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,100 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@ -1,136 +0,0 @@
|
|||||||
"""Test fixtures."""
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from pydiscourse import client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def sso_secret():
|
|
||||||
return "d836444a9e4084d5b224a60c208dce14"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def sso_nonce():
|
|
||||||
return "cb68251eefb5211e58c00ff1395f0c0b"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def sso_payload():
|
|
||||||
return "bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGI%3D%0A"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def sso_signature():
|
|
||||||
return "2828aa29899722b35a2f191d34ef9b3ce695e0e6eeec47deb46d588d70c7cb56"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def name():
|
|
||||||
return "sam"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def username():
|
|
||||||
return "samsam"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def external_id():
|
|
||||||
return "hello123"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def email():
|
|
||||||
return "test@test.com"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def redirect_url(sso_payload):
|
|
||||||
return f"/session/sso_login?sso={sso_payload}YW0mdXNlcm5hbWU9c2Ftc2FtJmVtYWlsPXRlc3QlNDB0ZXN0LmNvbSZleHRl%0Acm5hbF9pZD1oZWxsbzEyMw%3D%3D%0A&sig=1c884222282f3feacd76802a9dd94e8bc8deba5d619b292bed75d63eb3152c0b"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def discourse_host():
|
|
||||||
return "http://testhost"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def discourse_api_username():
|
|
||||||
return "testuser"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def discourse_api_key():
|
|
||||||
return "testkey"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def discourse_client(discourse_host, discourse_api_username, discourse_api_key):
|
|
||||||
return client.DiscourseClient(
|
|
||||||
discourse_host,
|
|
||||||
discourse_api_username,
|
|
||||||
discourse_api_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def _frozen_time(mocker):
|
|
||||||
now = mocker.patch("pydiscourse.client.now")
|
|
||||||
now.return_value = datetime.datetime(
|
|
||||||
2023,
|
|
||||||
8,
|
|
||||||
13,
|
|
||||||
12,
|
|
||||||
30,
|
|
||||||
15,
|
|
||||||
tzinfo=datetime.timezone.utc,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def discourse_request(discourse_host, discourse_client, requests_mock):
|
|
||||||
"""Fixture for mocking Discourse API requests.
|
|
||||||
|
|
||||||
The only request arguments are the method and the path.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
>>> def test_something(discourse_request):
|
|
||||||
>>> request = discourse_request(
|
|
||||||
>>> "put", # the method, case-insensitive
|
|
||||||
>>> "/the-path.json?q=4", # the absolute path with query, NO host
|
|
||||||
>>> headers={'content-type': 'text/plain'}, # override default headers
|
|
||||||
>>> content=b"ERROR", # override bytestring response
|
|
||||||
>>> )
|
|
||||||
|
|
||||||
If `content` is provided, that will be used as the response body.
|
|
||||||
If `json` is provided, then the body will return the given JSON-
|
|
||||||
compatable Python structure (e.g. dictionary).
|
|
||||||
If neither is given then the return `json` will be an empty
|
|
||||||
dictionary (`{}`).
|
|
||||||
|
|
||||||
Returns a function for inserting sensible default values.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def inner(method, path, headers=None, json=None, content=None):
|
|
||||||
full_path = f"{discourse_host}{path}"
|
|
||||||
if not headers:
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
|
||||||
"Api-Key": discourse_client.api_key,
|
|
||||||
"Api-Username": discourse_client.api_username,
|
|
||||||
}
|
|
||||||
|
|
||||||
kwargs = {}
|
|
||||||
if content:
|
|
||||||
kwargs["content"] = content
|
|
||||||
elif json:
|
|
||||||
kwargs["json"] = json
|
|
||||||
else:
|
|
||||||
kwargs["json"] = {}
|
|
||||||
|
|
||||||
return requests_mock.request(method, full_path, headers=headers, **kwargs)
|
|
||||||
|
|
||||||
return inner
|
|
||||||
@ -1,220 +1,113 @@
|
|||||||
"""Tests for the client methods."""
|
import unittest
|
||||||
|
import mock
|
||||||
|
|
||||||
import urllib.parse
|
from pydiscourse import client
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_content_http_ok(discourse_host, discourse_client, discourse_request):
|
def prepare_response(request):
|
||||||
"""Empty content should not raise error
|
# 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", {})
|
|
||||||
|
|
||||||
assert resp is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestUserManagement:
|
class ClientBaseTestCase(unittest.TestCase):
|
||||||
def test_get_user(self, discourse_host, discourse_client, discourse_request):
|
def setUp(self):
|
||||||
request = discourse_request(
|
self.host = 'testhost'
|
||||||
"get",
|
self.api_username = 'testuser'
|
||||||
"/users/someuser.json",
|
self.api_key = 'testkey'
|
||||||
json={"user": "someuser"},
|
|
||||||
)
|
|
||||||
discourse_client.user("someuser")
|
|
||||||
|
|
||||||
assert request.called_once
|
self.client = client.DiscourseClient(self.host, self.api_username, self.api_key)
|
||||||
|
|
||||||
def test_users(self, discourse_client, discourse_request):
|
def assertRequestCalled(self, request, verb, url, **params):
|
||||||
request = discourse_request("get", "/admin/users/list/active.json")
|
self.assertTrue(request.called)
|
||||||
discourse_client.users()
|
|
||||||
assert request.called_once
|
|
||||||
|
|
||||||
def test_create_user(self, discourse_host, discourse_client, discourse_request):
|
args, kwargs = request.call_args
|
||||||
session_request = discourse_request(
|
|
||||||
"get",
|
|
||||||
"/session/hp.json",
|
|
||||||
json={"challenge": "challenge", "value": "value"},
|
|
||||||
)
|
|
||||||
user_request = discourse_request("post", "/users")
|
|
||||||
discourse_client.create_user(
|
|
||||||
"Test User",
|
|
||||||
"testuser",
|
|
||||||
"test@example.com",
|
|
||||||
"notapassword",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert session_request.called_once
|
self.assertEqual(args[0], verb)
|
||||||
assert user_request.called_once
|
self.assertEqual(args[1], self.host + url)
|
||||||
|
|
||||||
def test_update_email(self, discourse_host, discourse_client, discourse_request):
|
kwargs = kwargs['params']
|
||||||
request = discourse_request("put", "/users/someuser/preferences/email")
|
self.assertEqual(kwargs.pop('api_username'), self.api_username)
|
||||||
discourse_client.update_email("someuser", "newmeail@example.com")
|
self.assertEqual(kwargs.pop('api_key'), self.api_key)
|
||||||
|
self.assertEqual(kwargs, params)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class TestTopics:
|
@mock.patch('requests.request')
|
||||||
def test_hot_topics(self, discourse_client, discourse_request):
|
class TestUser(ClientBaseTestCase):
|
||||||
request = discourse_request("get", "/hot.json")
|
|
||||||
discourse_client.hot_topics()
|
|
||||||
assert request.called_once
|
|
||||||
|
|
||||||
def test_latest_topics(self, discourse_client, discourse_request):
|
def test_user(self, request):
|
||||||
request = discourse_request("get", "/latest.json")
|
prepare_response(request)
|
||||||
discourse_client.latest_topics()
|
self.client.user('someuser')
|
||||||
|
self.assertRequestCalled(request, 'GET', '/users/someuser.json')
|
||||||
|
|
||||||
assert request.called_once
|
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
|
||||||
|
|
||||||
def test_new_topics(self, discourse_client, discourse_request):
|
def test_update_email(self, request):
|
||||||
request = discourse_request("get", "/new.json")
|
prepare_response(request)
|
||||||
discourse_client.new_topics()
|
email = 'test@example.com'
|
||||||
assert request.called_once
|
self.client.update_email('someuser', email)
|
||||||
|
self.assertRequestCalled(request, 'PUT', '/users/someuser/preferences/email', email=email)
|
||||||
|
|
||||||
def test_topic(self, discourse_client, discourse_request):
|
def test_update_user(self, request):
|
||||||
request = discourse_request("get", "/t/some-test-slug/22.json")
|
prepare_response(request)
|
||||||
discourse_client.topic("some-test-slug", 22)
|
self.client.update_user('someuser', a='a', b='b')
|
||||||
assert request.called_once
|
self.assertRequestCalled(request, 'PUT', '/users/someuser', a='a', b='b')
|
||||||
|
|
||||||
def test_topics_by(self, discourse_client, discourse_request):
|
def test_update_username(self, request):
|
||||||
request = discourse_request(
|
prepare_response(request)
|
||||||
"get",
|
self.client.update_username('someuser', 'newname')
|
||||||
"/topics/created-by/someuser.json",
|
self.assertRequestCalled(request, 'PUT', '/users/someuser/preferences/username', username='newname')
|
||||||
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"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestPosts:
|
@mock.patch('requests.request')
|
||||||
def test_latest_posts(self, discourse_client, discourse_request):
|
class TestTopics(ClientBaseTestCase):
|
||||||
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):
|
def test_hot_topics(self, request):
|
||||||
request = discourse_request("get", "/posts/by_number/8796/5")
|
prepare_response(request)
|
||||||
discourse_client.post_by_number(8796, 5)
|
self.client.hot_topics()
|
||||||
assert request.called_once
|
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)
|
||||||
|
|
||||||
|
|
||||||
class TestSearch:
|
@mock.patch('requests.request')
|
||||||
def test_search(self, discourse_client, discourse_request):
|
class MiscellaneousTests(ClientBaseTestCase):
|
||||||
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')
|
||||||
|
|
||||||
class TestCategories:
|
def test_categories(self, request):
|
||||||
def test_categories(self, discourse_client, discourse_request):
|
prepare_response(request)
|
||||||
request = discourse_request(
|
r = self.client.categories()
|
||||||
"get",
|
self.assertRequestCalled(request, 'GET', '/categories.json')
|
||||||
"/categories.json",
|
self.assertEqual(r, request().json()['category_list']['categories'])
|
||||||
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
|
|
||||||
|
|||||||
@ -1,95 +0,0 @@
|
|||||||
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
28
tox.ini
@ -1,28 +0,0 @@
|
|||||||
[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