Compare commits

...

293 Commits

Author SHA1 Message Date
Samuël WEBER
6ee6fc8b88 docs: rename bennylope -> pydiscourse in readme 2025-02-25 11:33:51 -05:00
Samuël WEBER
7d05b1c42c docs: add pypi badge in readme 2025-02-25 11:33:51 -05:00
Samuël WEBER
b03c9738f1 docs: readme code highlighted 2025-02-25 11:33:29 -05:00
Ben Lopatin
36f8f1e737
Merge pull request #93 from pydiscourse/v1.7
Bump version
2024-04-16 16:34:57 -04:00
Ben Lopatin
2d27cc2a3c Update History 2024-04-16 16:32:22 -04:00
Ben Lopatin
f81c1b775a Add support for Python 3.12 2024-04-16 16:31:58 -04:00
Ben Lopatin
270f4bbbdb Bump version 2024-04-16 16:31:17 -04:00
Samuël WEBER
7d981684aa add anonymize endpoint 2024-02-24 13:26:43 -05:00
Samuël WEBER
53be265b23 fix: search term moved to q 2023-09-27 11:17:01 -04:00
Samuël WEBER
a07b122c39 add about endpoint for site statistics 2023-09-07 10:57:28 -04:00
Ben Lopatin
6d54153a24
Merge pull request #90 from pydiscourse/new-version
Update version for release
2023-09-01 10:37:25 -04:00
Ben Lopatin
c30a453a6b Bump version 2023-09-01 10:35:28 -04:00
Ben Lopatin
3e87ef3849
Merge pull request #89 from pydiscourse/use-discourse-request
Replace mocks and increase coverage requirement
2023-09-01 10:23:50 -04:00
Ben Lopatin
0f1cc41967 Bump up coverage requirement 2023-09-01 10:21:48 -04:00
Ben Lopatin
7ca7f47e94 Replace requests_mock with discourse_request
Requires just slightly less upfront configuration in each test.
2023-09-01 10:21:11 -04:00
Ben Lopatin
d8ec2f61aa
Merge pull request #88 from Dettorer/add-endpoint-post-by-number
Add a wrapper for the /posts/by_number endpoint
2023-09-01 10:08:46 -04:00
Paul Dettorer Hervot
f94d861c64 Add a wrapper for the /posts/by_number endpoint 2023-09-01 11:57:11 +02:00
Ben Lopatin
565b714d81
Merge pull request #87 from pydiscourse/bennylope-patch-1
Delete .coveragerc
2023-08-31 21:10:41 -04:00
Ben Lopatin
e1a9038789
Delete .coveragerc
Inapplicable now
2023-08-31 21:08:30 -04:00
Ben Lopatin
b38f99bffe
Merge pull request #86 from pydiscourse/add-ruff
Add ruff and cleanup code
2023-08-31 18:10:24 -04:00
Ben Lopatin
4d20bfd79c Update history and version 2023-08-31 18:09:02 -04:00
Ben Lopatin
99359aa518 Ignore formatting and simple rule-based changes 2023-08-31 18:03:15 -04:00
Ben Lopatin
ea7d47c5da Add 'bad' update to test actions 2023-08-31 17:59:38 -04:00
Ben Lopatin
fcdd6ab8dd Add linting rule 2023-08-31 17:56:09 -04:00
Ben Lopatin
c850e34112 Add ruff for general use 2023-08-31 17:53:51 -04:00
Ben Lopatin
ee21957027 Add ruff and black pre-commit 2023-08-31 17:47:11 -04:00
Ben Lopatin
acb0af24d1 Handle commented code 2023-08-31 17:46:35 -04:00
Ben Lopatin
31db8017bc Remove unnecessary dict 2023-08-31 17:46:18 -04:00
Ben Lopatin
5d28665fbb Raise exception from original ValueError 2023-08-31 17:45:53 -04:00
Ben Lopatin
51771057f5 Make arguments keyword only
Applies where boolean defaults are included
2023-08-31 17:40:49 -04:00
Ben Lopatin
d7e249847a Replace shadowed builtin names 2023-08-31 17:37:51 -04:00
Ben Lopatin
b348fe13f3 Use stderr instead of print 2023-08-31 17:32:57 -04:00
Ben Lopatin
30f9b9b338 Inject fixture 2023-08-31 17:30:10 -04:00
Ben Lopatin
815bfd8af6 Remove encoding 2023-08-31 17:27:28 -04:00
Ben Lopatin
2be1a46c1d Format code 2023-08-31 17:24:12 -04:00
Ben Lopatin
2aac9a20be Use f-strings 2023-08-31 17:23:54 -04:00
Ben Lopatin
fe4f67c041 Use relative values 2023-08-31 17:22:02 -04:00
Ben Lopatin
6c1d9a9e1d Use data from fixture
Makes it explicit that this data is used here, plus makes the line
shorter.
2023-08-31 17:21:05 -04:00
Ben Lopatin
7ab58533b7 Remove unnecessary parens 2023-08-31 17:18:22 -04:00
Ben Lopatin
dc498679cc Remove explict object inheritance 2023-08-31 17:17:34 -04:00
Ben Lopatin
c49d29620d Remove unicode literal 2023-08-31 17:17:04 -04:00
Ben Lopatin
2a3036f039 Add trailing commas 2023-08-31 17:15:40 -04:00
Ben Lopatin
14b2f7a08d
Merge pull request #85 from pydiscourse/cleanup
Clean up tests, Python support
2023-08-31 16:17:55 -04:00
Ben Lopatin
5c4d0b3aed Update contributing about tests 2023-08-31 16:14:12 -04:00
Ben Lopatin
f20fc33349 Simplify mocking with default values mocker 2023-08-31 15:56:50 -04:00
Ben Lopatin
3d2a8def57 Convert remaining tests to pytest 2023-08-31 15:29:23 -04:00
Ben Lopatin
80c1a17e4b Convert topics test to pytest 2023-08-31 15:14:46 -04:00
Ben Lopatin
b5eda64a05 Convert additional tests to pytest 2023-08-31 15:06:02 -04:00
Ben Lopatin
87cf273cd6 Add coverage requirement for sso module 2023-08-31 13:58:03 -04:00
Ben Lopatin
c5466fd182 Rename fixtures 2023-08-31 13:53:34 -04:00
Ben Lopatin
fe17d3977c Remove unnecessary fixture arg 2023-08-31 13:52:35 -04:00
Ben Lopatin
f93e1cb341 Bump version 2023-08-31 13:48:33 -04:00
Ben Lopatin
2cf6c675e0 Remove old Python support 2023-08-31 13:46:56 -04:00
Ben Lopatin
991cc564dd Switch test to pytest, sso coverage = 100% 2023-08-31 13:44:54 -04:00
Ben Lopatin
7ebc08356a Remove duplicate periods 2023-08-31 13:35:30 -04:00
Ben Lopatin
a58ca74362 Update pytest version 2023-08-31 13:35:09 -04:00
Ben Lopatin
8a73f911e2 Remove Python 3.7 from test envs 2023-08-31 13:07:05 -04:00
Ben Lopatin
87891a6331 Remove Python 3.5 typing support! 2023-08-31 13:06:45 -04:00
Ben Lopatin
daab45edda
Merge pull request #84 from pydiscourse/version-update-1.5
Version 1.5 update
2023-08-31 12:55:52 -04:00
Ben Lopatin
4382449a72 Remove Python 3.7 support 2023-08-31 12:53:06 -04:00
Ben Lopatin
dfc0c4173c Update changelog 2023-08-31 12:52:02 -04:00
Ben Lopatin
aa2b7eedf6 Bump version 2023-08-31 12:49:19 -04:00
Ben Lopatin
905febc27b
Merge pull request #82 from inducer/group-members-pagination
Respect pagination when returning all group members
2023-08-31 12:45:53 -04:00
Andreas Kloeckner
16297f9206 Respect pagination when returning all group members 2023-08-31 12:42:22 -04:00
Ben Lopatin
c566377ccb
Merge pull request #77 from akhmerov/patch-1
use an up to date endpoint for group owner creation
2023-08-31 12:42:11 -04:00
Anton Akhmerov
9c07f97e87 use an up to date endpoint for group owner creation 2023-08-29 10:19:59 -04:00
Ben Lopatin
eb195bb6bc
Merge pull request #81 from Dettorer/tox-add-py311
Enable the tests for Python 3.11
2023-08-29 10:19:49 -04:00
Paul Dettorer Hervot
71f9da07c7 Enable the tests for Python 3.11 2023-08-29 14:50:35 +02:00
Ben Lopatin
ed4efd82aa
Merge pull request #80 from inducer/fix-group-membership-admin
Fix group membership admin functionality
2023-08-15 15:23:09 -04:00
Andreas Kloeckner
e33a37b8b9 Fix group membership administration endpoints 2023-08-09 19:40:14 -05:00
Ben Lopatin
50465b4502
Merge pull request #74 from themotleyfool/add-update-category
Add client endpoint for updating category
2023-02-28 20:24:46 -05:00
Max Lancaster
e9748279b8 Add client endpoint for updating category 2023-02-28 19:55:26 -05:00
Karl Goetz
7898ff3ff1
Merge pull request #69 from themotleyfool/add-latest-posts
Add endpoint to fetch latest posts across topics
2023-01-25 21:51:55 +11:00
Max Lancaster
9709744b33 Add endpoint to fetch latest posts across topics
Added endpoint for looking up latest posts across topics, see Discourse api documentation here:

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

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

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

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

* Update named Python versions and dev status

* Use unittest.mock as default mock source

Fall back to package for Python 2.7

* Try installing mock outside of setup

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

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

Closes: #11

----

Added by Maintainer:

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

> Recent Python branches require OpenSSL 1.0.2+. As this library is not
> available for Trusty, 3.7, 3.7-dev, 3.8-dev, and nightly do not work (or
> use outdated archive).
2018-10-29 18:35:43 -04:00
Ben Lopatin
0f2efa8e74 Update Python versions supported 2018-10-29 18:31:33 -04:00
Ben Lopatin
44cf317aa9 Fix version cleaning 2018-10-29 18:15:41 -04:00
Ben Lopatin
468f6b58cd Version bump 0.8.0
Closes gh-14
2018-10-29 18:11:35 -04:00
Ben Lopatin
c0db7215c9 Format with black 2018-10-29 18:02:58 -04:00
Ben Lopatin
b0b277c917 Add PR template 2018-10-29 18:02:19 -04:00
Ben Lopatin
7793f3ae54
Merge pull request #12 from goetzk/patch-1
Increase minimum requests version
2018-02-14 19:25:47 -05:00
Karl Goetz
c900fad726
Increase minimum requests version
With the addition of json support in PR #9, the required version of requests has increased to that which introduced json={} syntax.
2018-02-15 11:03:19 +11:00
Ben Lopatin
6c4c40d93c
Merge pull request #10 from goetzk/new-api-methods
New api methods
2017-12-13 20:27:58 -05:00
Karl Goetz
ff49cc7219
Merge branch 'master' into new-api-methods 2017-11-30 22:25:00 +11:00
Karl Goetz
3e391c38ec
Merge branch 'master' into new-api-methods 2017-11-30 22:22:36 +11:00
Karl Goetz
010bfa624c Add docstrings to methods
Requested in #10
2017-11-09 09:25:38 +11:00
Karl Goetz
006b7d416a Revert change to site_settings
Adding a settings arg changed the interface, something I was trying to avoid on
this branch.
Picked up by @bennylope when reviewing #10.
2017-11-09 08:40:16 +11:00
Ben Lopatin
7b3733ca8e
Merge pull request #9 from goetzk/json-support
Json support
2017-11-08 12:35:31 -05:00
Alvaro Molina Alvarez
a87503eec3 adding support over topics 2017-10-21 16:08:26 +11:00
Alvaro Molina Alvarez
f0dd191b58 getting user emails 2017-10-21 16:07:52 +11:00
Alvaro Molina Alvarez
6cffef4e49 Change group_members to handle paging 2017-10-21 16:06:13 +11:00
Alvaro Molina Alvarez
12356819ea adding tags support 2017-10-21 16:04:55 +11:00
Alvaro Molina Alvarez
84016afbc5 adding new client's methods 2017-10-21 16:04:23 +11:00
Alvaro Molina Alvarez
e5fe47d0a6 updating client methods 2017-10-21 16:01:59 +11:00
Alvaro Molina Alvarez
2fde21b51f adding trust level lock method 2017-10-21 16:01:38 +11:00
Alvaro Molina Alvarez
2d2e8d1695 adding upload image client method 2017-10-21 16:00:42 +11:00
Alvaro Molina Alvarez
217b606ee7 adding avatar support when a group is added 2017-10-21 15:58:47 +11:00
Alvaro Molina Alvarez
fd815ac97b adding color schemes method 2017-10-21 15:58:02 +11:00
Alvaro Molina Alvarez
9fbfa39060 adding new functions to the client 2017-10-21 15:56:44 +11:00
Alvaro Molina Alvarez
3ab8689b6e Add JSON support to more methods
This includes proper support for JSON being added to _put, originally in [1]

[1] a1ac18e8524f921aff9e2dcf454251b4593da628
2017-10-21 15:52:56 +11:00
Alvaro Molina Alvarez
914e22cc55 adding update_avatar client method 2017-10-21 15:47:39 +11:00
Karl Goetz
f42a457514 New add_group_members API call
Taken from [1] by Alvaro Molina Alvarez, split to keep merges on a single
topic.
[1] 74414d14294a3c21ca6b5d1701f177be3f8d4447
2017-10-21 15:45:03 +11:00
Karl Goetz
8faa1cfaf9 Group kwargs for Discourse API v1.7
Taken from [1] by Alvaro Molina Alvarez
[1] 74414d14294a3c21ca6b5d1701f177be3f8d4447
2017-10-21 15:42:41 +11:00
Karl Goetz
227f7a3205 Add JSON support to client library
Newer versions of Discourse API use JSON PUTs and POSTs extensively.

This is a modified cherry pick of [1] by Alvaro Molina Alvarez
[1] 74414d14294a3c21ca6b5d1701f177be3f8d4447
2017-10-21 15:40:29 +11:00
Ben Lopatin
17faed6fa7 Update CONTRIBUTING.rst 2017-10-06 09:41:18 -04:00
Ben Lopatin
b761d28494 Update CONTRIBUTING.rst 2017-10-06 09:39:36 -04:00
Ben Lopatin
9108939503 Merge pull request #7 from citadelgrad/patch-1
Update client.py
2017-01-17 10:17:31 -05:00
Scott Nixon
8555abf680 Update client.py
Updated create_category method documentation.
2017-01-15 14:03:23 -08:00
Ben Lopatin
dd9b7fad19 Merge pull request #6 from Polytechnique-org/master
Adding some API calls, notably on groups and users
2016-10-17 15:53:04 -04:00
Pierre-Alain Dupont
a18203c8cb
Adds the possibility of deleting a group 2016-10-16 15:38:09 +02:00
wilhelmhb
630b822a9a add method for getting all the available informations about a user 2016-10-15 19:34:02 +02:00
wilhelmhb
f0fd17c3a3 fonctions to get data on group members/owners 2016-10-15 17:58:30 +02:00
wilhelmhb
f7f1aafc64 add forgotten argument 2016-10-15 16:06:18 +02:00
wilhelmhb
e77074c5d4 add possibility to create a group 2016-10-15 15:49:22 +02:00
Ben Lopatin
22bf3b088e Version bump 0.7.0 2016-09-09 10:09:22 -04:00
Ben Lopatin
811453a129 Merge pull request #5 from jdorweiler/master
pass params in data
2016-09-09 10:04:24 -04:00
jddorweiler
f8d628909c update test 2016-09-08 14:38:22 -04:00
jddorweiler
507e377a37 use data for posts and put 2016-09-08 14:23:38 -04:00
jddorweiler
0aac8f6628 pass params in data 2016-09-07 15:27:40 -04:00
Ben Lopatin
acdcb03283 Version bump 0.6.0 2016-07-22 08:48:57 -04:00
Ben Lopatin
6bd73fdd5c Merge pull request #4 from Meal-Mentor/master
Added method to add group to the user
2016-07-22 08:46:22 -04:00
Scott Nixon
c13b456b79 Added method to add group to the user 2016-07-19 11:33:27 -07:00
Ben Lopatin
977885967d Update makefile 2016-06-13 11:41:28 -04:00
Ben Lopatin
b9066ca637 Version bump 0.5.0 2016-06-13 11:37:12 -04:00
Ben Lopatin
77254f441c Merge pull request #3 from msfernandes/badges_endpoint
Added badges endpoint to pydiscourse
2016-06-10 11:12:26 -04:00
Matheus Fernandes
66089011f7 Added 'user-badges' endpoint
Signed-off-by: Matheus Fernandes <matheus.souza.fernandes@gmail.com>
2016-06-10 11:46:56 -03:00
Matheus Fernandes
fe317b6be8 Added badges endpoint to pydiscourse
Signed-off-by: Matheus Fernandes <matheus.souza.fernandes@gmail.com>
2016-06-10 10:59:03 -03:00
Ben Lopatin
15e82aacd1 Add specific methods for interface to groups
Hide 'verbs' from users.
2016-05-04 08:28:44 -04:00
Ben Lopatin
008f21d6fe Merge pull request #2 from citadelgrad/master
Added partial groups support
2016-05-04 07:36:59 -04:00
Scott Nixon
6baf51bbe1 Added partial groups support
Groups method returns the list of all groups.
Created group_owners which allows you to add and delete owners.
Created group_members which allows you to add and delete members.
2016-05-03 16:11:10 -07:00
Ben Lopatin
8304e7b2f5 Version bump 0.3.2 2016-04-17 11:36:09 -04:00
Ben Lopatin
5806beef34 Merge pull request #1 from danielzohar/master
Only return `nonce` from given payload
2016-04-17 11:32:41 -04:00
Daniel Zohar
f905a957f4 Only return nonce from given payload 2016-04-17 16:00:35 +01:00
Ben Lopatin
06ca2c5a58 Update changelog to include in package description 2016-04-11 11:36:25 -04:00
Ben Lopatin
bde4325776 Add autodoc generation 2016-04-08 18:45:57 -04:00
Ben Lopatin
6b7e570475 Update README
[ci skip]
2016-04-08 18:27:02 -04:00
Ben Lopatin
3659724f11 Remove duplicate requests dependency in tox config 2016-04-08 18:03:10 -04:00
Ben Lopatin
1e151fc51f Remove module import from setup.py
Get the version number from the file, not by importing and thus trying
to import requests
2016-04-08 17:57:19 -04:00
Ben Lopatin
63f120ddca Require requests for tests 2016-04-08 17:47:26 -04:00
Ben Lopatin
9cb96eaf76 Version bump 2016-04-08 17:38:16 -04:00
Ben Lopatin
c5207759a8 Fix empty response handling 2016-04-08 17:36:18 -04:00
Ben Lopatin
b14cd502ce Allow client import from top level module 2016-04-08 17:35:25 -04:00
Ben Lopatin
3a3bb843e5 Check for docstrings with flake8
[ci skip]
2016-04-08 13:59:18 -04:00
Ben Lopatin
a2f961aebb Formatting cleanup and flake8 configuration
[ci skip]
2016-04-08 13:56:41 -04:00
Ben Lopatin
bd508cdcee Version bump 0.3.0 2016-04-08 13:50:46 -04:00
Ben Lopatin
46051fd248 Fix unsuspend test 2016-04-08 13:47:43 -04:00
Ben Lopatin
eed4df564d Add unsuspend method and un/suspend method tests 2016-04-08 13:43:05 -04:00
Ben Lopatin
6ac6a1fd2d Add client module docstring stubs 2016-04-08 13:43:05 -04:00
Ben Lopatin
adf3f2ddbc Order imports 2016-04-08 13:43:05 -04:00
Ben Lopatin
2daebbfa23 Add test for external_id method 2016-04-08 13:43:05 -04:00
Ben Lopatin
9a23db7e43 Consolidate SSO tests
No good reason to separate these for now
2016-04-08 13:43:05 -04:00
Ben Lopatin
3be87f19dd Add Travis badge
[ci skip]
2016-04-07 17:50:27 -04:00
Ben Lopatin
b7d4286c44 Remove requirements file from travis.yml 2016-04-07 17:46:28 -04:00
Ben Lopatin
c155c8a60d Cleanup for packaging 2016-04-07 17:41:54 -04:00
Ben Lopatin
dd8d9562ff Remove Python 2.6 support
Not worth including if it poses *any* problems whatsoever
2016-04-07 17:08:00 -04:00
Ben Lopatin
c6a43f0304 Add pypy, pypy3, and add Python 2.6 back to test matrix 2016-04-07 17:02:56 -04:00
Ben Lopatin
ca52920690 Read README as setup.py long description
Previous code caused serious problems building package
2016-04-07 16:57:44 -04:00
Ben Lopatin
db934494e2 Update documentation including README 2016-04-07 16:56:12 -04:00
Ben Lopatin
6dbbe74776 Switch fully to reStructuredText in README 2016-04-07 16:48:01 -04:00
Ben Lopatin
04bb9c550b Add Python 3.5, drop Python 2.6 2016-04-07 16:45:57 -04:00
Guillaume Beraudo
65e398343b Add sync_sso method 2016-01-19 18:06:03 +01:00
Guillaume Beraudo
df1d274a33 Add sso_payload helper method 2016-01-19 18:06:03 +01:00
Guillaume Beraudo
9cc771f381 Fix pydiscoursecli option in README 2016-01-19 15:53:20 +01:00
Guillaume Beraudo
66cd4ab5de Add by_external_id method
Allow retrieving user information based on the external SSO id.
The returned internal id can be used with logout.
2016-01-19 15:53:20 +01:00
Guillaume Beraudo
bd79423ba9 Send header to accept json responses
This is necessary for by_external method.
Otherwise, an error page is returned.
2016-01-19 15:53:20 +01:00
Guillaume Beraudo
65abb8119f Add log_out method 2016-01-19 15:53:20 +01:00
Guillaume Beraudo
f0f3256e01 Fix python 3.4 support 2016-01-19 15:53:20 +01:00
Julia Grace
ab00eb9cf7 Merge pull request #12 from tindie/added_method_private_msgs_unread
added method to get unread private messages
2015-04-22 11:27:00 -07:00
Julia Grace
3ccf20212e added method to get unread private messages 2015-04-22 11:22:51 -07:00
Julia Grace
e008d1865e Merge pull request #9 from citadelgrad/master
Added two admin methods. Account suspension and user listing with search
2015-04-22 11:14:09 -07:00
Julia Grace
5506eacaeb Merge pull request #10 from tindie/fix_topic_test
fixed test_topic()'
2015-04-22 11:05:09 -07:00
Julia Grace
29cb1ce14b fixed test_topic()' 2015-04-22 10:51:39 -07:00
Julia Grace
4e69083284 Merge pull request #8 from RauchF/master
Python 3 compatibility, new GET method (/admin/users/list/)
2015-03-31 10:36:28 -07:00
Scott Nixon
05a58b1d62 Added two admin methods. Account suspension and user listing with search 2015-02-25 13:18:36 -08:00
Felix Rauch
cc03b5cc08 Add method to GET list of users (/admin)
This allows to grab all users, as used in the Discourse admin UI at
/admin/users/list. By default grabs all active users, but also accepts
the other views like "staff", "suspended", etc.
2014-12-19 01:22:13 +01:00
Felix Rauch
385e35b322 Add parentheses around print statement for Python 3.x
Python 3.x actually requires parentheses around the arguments to
print().
This shouldn't break compatibility with Python 2.4/2.6, but fixes a
syntax error with Python 3.x.
2014-12-19 00:52:53 +01:00
Julia Grace
9c6097a3d4 Merge pull request #6 from kportertx/patch-1
Potential bug in update_post
2014-12-05 14:10:16 -08:00
Kevin Porter
204eb1478a Potential bug in update_post 2014-12-03 18:48:57 -08:00
Marc Sibson
e64b990dc8 Merge branch 'master' of github.com:tindie/pydiscourse 2014-08-06 15:25:15 -07:00
Marc Sibson
e358300085 fix topic to avoid redirect 2014-08-06 15:25:05 -07:00
Marc Sibson
b8cb201652 Merge pull request #3 from tindie/more-good-errors
try harder to give verbose error messages
2014-07-16 14:29:01 -07:00
Marc Sibson
cba141724d try harder to give verbose error messages 2014-07-16 14:26:11 -07:00
Marc Sibson
d64651e655 add post update 2014-06-23 22:27:28 -07:00
Marc Sibson
9d5d835910 update docs for SSO 2014-06-13 15:37:42 +00:00
Marc Sibson
9eb5f3466b Merge branch 'master' of github.com:tindie/pydiscourse 2014-06-13 15:34:15 +00:00
Marc Sibson
d66e636078 improve category support 2014-06-13 15:34:02 +00:00
Marc Sibson
a381089497 Merge pull request #2 from tindie/f/sso
add SSO support, thanks James Potter
2014-06-06 13:12:32 -07:00
Marc Sibson
aaa18ee0c6 add user preferences 2014-06-02 11:16:14 -07:00
Marc Sibson
a07280975a fixes for py26, py34 tests 2014-05-28 22:50:18 -07:00
Marc Sibson
cb0244652d add SSO support, thanks James Potter 2014-05-28 10:18:48 -07:00
Marc Sibson
4a56a43bd5 start thinking about 3.4 support 2014-05-27 22:30:06 -07:00
Marc Sibson
4a366a1b97 tox and testing for py2.6, py2.7 2014-05-27 22:24:57 -07:00
39 changed files with 3110 additions and 466 deletions

View File

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

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

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

8
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View 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
View File

@ -0,0 +1,33 @@
name: Publish package to PyPI
on:
push:
branches: [master]
release:
types: [created]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
- name: Build and publish distribution
if: (github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags')) || github.event_name == 'release'
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
python setup.py sdist bdist_wheel
twine upload dist/*

47
.github/workflows/test.yml vendored Normal file
View 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
View File

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

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

@ -0,0 +1,19 @@
exclude: "docs|.git|.tox"
default_stages: [ commit ]
fail_fast: true
repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: "v0.0.286"
hooks:
- id: ruff
- repo: https://github.com/psf/black
rev: 23.7.0
hooks:
- id: black
args:
- --config=pyproject.toml
- src/pydiscourse
- tests

View File

@ -1,10 +0,0 @@
language: python
python:
- "2.7"
install:
- "pip install -r requirements.dev.txt"
- "pip install ."
script: nosetests

15
AUTHORS Normal file
View 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
View 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.

View File

@ -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
View 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
View 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
View 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}'

View File

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

View File

@ -28,9 +28,7 @@ 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 = [ extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon']
'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']
@ -53,9 +51,9 @@ copyright = u'2014, Marc Sibson'
# built documents. # built documents.
# #
# The short X.Y version. # The short X.Y version.
version = '0.1' version = '1.7'
# The full version, including alpha/beta/rc tags. # 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 # The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages. # for a list of supported languages.

View File

@ -1,4 +1,5 @@
Development Development
=============== ===============
.. include:: ../DEVELOP.md
:start-line: 2 .. include:: ../CONTRIBUTING.rst
:start-line: 3

View File

@ -1,4 +1,5 @@
Introduction Introduction
============== ==============
.. include:: ../README.md
:start-line: 2 .. include:: ../README.rst
:start-line: 3

7
docs/modules.rst Normal file
View File

@ -0,0 +1,7 @@
pydiscourse
===========
.. toctree::
:maxdepth: 4
pydiscourse

46
docs/pydiscourse.rst Normal file
View 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:

View File

@ -1 +0,0 @@
__version__ = '0.1.0.dev'

View File

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

View File

@ -1,13 +0,0 @@
from requests.exceptions import HTTPError
class DiscourseError(HTTPError):
""" A generic error while attempting to communicate with Discourse """
class DiscourseServerError(DiscourseError):
""" The Discourse Server encountered an error while processing the request """
class DiscourseClientError(DiscourseError):
""" An invalid request has been made """

49
pyproject.toml Normal file
View File

@ -0,0 +1,49 @@
[tool.black]
line-length=120
target-version = ["py311"]
[tool.ruff]
exclude = [
".tox",
".git",
"build",
"dist",
"docs",
".ropeproject",
]
ignore = [
"S101", # Assertions good, actually
"TRY003", # For now not worth back tracking
]
line-length = 240
select = [
"S", # Security (formerly B when using Bandit directly)
"E",
"F",
"N",
"W",
"COM", # commas
"PT", # pytest
"UP", # Upgrade Python syntax
"T",
"A", # built-in shadowing
"FBT", # Boolean traps
"BLE", # Blind exceptions
"PIE",
"TRY",
"ERA", # eradicate commented out code
]
[tool.ruff.flake8-pytest-style]
fixture-parentheses = false
mark-parentheses = true
parametrize-names-type = "tuple"
parametrize-values-row-type = "tuple"
[tool.ruff.mccabe]
# Unlike Flake8, default to a complexity level of 10.
max-complexity = 10
[tool.coverage.run]
include = ["src/pydiscourse/*"]

View File

@ -1,3 +0,0 @@
requests
nose
mock

9
requirements.txt Normal file
View File

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

47
setup.cfg Normal file
View 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

View File

@ -1,52 +1,6 @@
import codecs """
import os See setup.cfg for packaging settings
"""
from setuptools import setup, find_packages from setuptools import setup
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,
)

View 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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
"""API exceptions."""
from requests.exceptions import HTTPError
class DiscourseError(HTTPError):
"""A generic error while attempting to communicate with Discourse"""
class DiscourseServerError(DiscourseError):
"""The Discourse Server encountered an error while processing the request"""
class DiscourseClientError(DiscourseError):
"""An invalid request has been made"""
class DiscourseRateLimitedError(DiscourseError):
"""Request required more than the permissible number of retries"""

View File

@ -1,40 +1,49 @@
#!/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):
prompt = 'discourse>' """Handles CLI commands"""
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):
if attr.startswith('do_'): """Gets attributes with dynamic name handling"""
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:
print e, e.response.text sys.stderr.write(f"{e}, {e.response.text}\n")
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():
@ -45,20 +54,34 @@ 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(result, self.output, sort_keys=True, indent=4, separators=(',', ': ')) json.dump(
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:
@ -67,12 +90,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()

100
src/pydiscourse/sso.py Normal file
View 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
View File

136
tests/conftest.py Normal file
View File

@ -0,0 +1,136 @@
"""Test fixtures."""
import datetime
import pytest
from pydiscourse import client
@pytest.fixture(scope="session")
def sso_secret():
return "d836444a9e4084d5b224a60c208dce14"
@pytest.fixture(scope="session")
def sso_nonce():
return "cb68251eefb5211e58c00ff1395f0c0b"
@pytest.fixture(scope="session")
def sso_payload():
return "bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGI%3D%0A"
@pytest.fixture(scope="session")
def sso_signature():
return "2828aa29899722b35a2f191d34ef9b3ce695e0e6eeec47deb46d588d70c7cb56"
@pytest.fixture(scope="session")
def name():
return "sam"
@pytest.fixture(scope="session")
def username():
return "samsam"
@pytest.fixture(scope="session")
def external_id():
return "hello123"
@pytest.fixture(scope="session")
def email():
return "test@test.com"
@pytest.fixture(scope="session")
def redirect_url(sso_payload):
return f"/session/sso_login?sso={sso_payload}YW0mdXNlcm5hbWU9c2Ftc2FtJmVtYWlsPXRlc3QlNDB0ZXN0LmNvbSZleHRl%0Acm5hbF9pZD1oZWxsbzEyMw%3D%3D%0A&sig=1c884222282f3feacd76802a9dd94e8bc8deba5d619b292bed75d63eb3152c0b"
@pytest.fixture(scope="session")
def discourse_host():
return "http://testhost"
@pytest.fixture(scope="session")
def discourse_api_username():
return "testuser"
@pytest.fixture(scope="session")
def discourse_api_key():
return "testkey"
@pytest.fixture(scope="session")
def discourse_client(discourse_host, discourse_api_username, discourse_api_key):
return client.DiscourseClient(
discourse_host,
discourse_api_username,
discourse_api_key,
)
@pytest.fixture
def _frozen_time(mocker):
now = mocker.patch("pydiscourse.client.now")
now.return_value = datetime.datetime(
2023,
8,
13,
12,
30,
15,
tzinfo=datetime.timezone.utc,
)
@pytest.fixture
def discourse_request(discourse_host, discourse_client, requests_mock):
"""Fixture for mocking Discourse API requests.
The only request arguments are the method and the path.
Example:
>>> def test_something(discourse_request):
>>> request = discourse_request(
>>> "put", # the method, case-insensitive
>>> "/the-path.json?q=4", # the absolute path with query, NO host
>>> headers={'content-type': 'text/plain'}, # override default headers
>>> content=b"ERROR", # override bytestring response
>>> )
If `content` is provided, that will be used as the response body.
If `json` is provided, then the body will return the given JSON-
compatable Python structure (e.g. dictionary).
If neither is given then the return `json` will be an empty
dictionary (`{}`).
Returns a function for inserting sensible default values.
"""
def inner(method, path, headers=None, json=None, content=None):
full_path = f"{discourse_host}{path}"
if not headers:
headers = {
"Content-Type": "application/json; charset=utf-8",
"Api-Key": discourse_client.api_key,
"Api-Username": discourse_client.api_username,
}
kwargs = {}
if content:
kwargs["content"] = content
elif json:
kwargs["json"] = json
else:
kwargs["json"] = {}
return requests_mock.request(method, full_path, headers=headers, **kwargs)
return inner

View File

@ -1,113 +1,220 @@
import unittest """Tests for the client methods."""
import mock
from pydiscourse import client import urllib.parse
import pytest
def prepare_response(request): def test_empty_content_http_ok(discourse_host, discourse_client, discourse_request):
# we need to mocked response to look a little more real """Empty content should not raise error
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 ClientBaseTestCase(unittest.TestCase): class TestUserManagement:
def setUp(self): def test_get_user(self, discourse_host, discourse_client, discourse_request):
self.host = 'testhost' request = discourse_request(
self.api_username = 'testuser' "get",
self.api_key = 'testkey' "/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): def test_users(self, discourse_client, discourse_request):
self.assertTrue(request.called) 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) assert session_request.called_once
self.assertEqual(args[1], self.host + url) assert user_request.called_once
kwargs = kwargs['params'] def test_update_email(self, discourse_host, discourse_client, discourse_request):
self.assertEqual(kwargs.pop('api_username'), self.api_username) request = discourse_request("put", "/users/someuser/preferences/email")
self.assertEqual(kwargs.pop('api_key'), self.api_key) discourse_client.update_email("someuser", "newmeail@example.com")
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
@mock.patch('requests.request') class TestTopics:
class TestUser(ClientBaseTestCase): 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): def test_latest_topics(self, discourse_client, discourse_request):
prepare_response(request) request = discourse_request("get", "/latest.json")
self.client.user('someuser') discourse_client.latest_topics()
self.assertRequestCalled(request, 'GET', '/users/someuser.json')
def test_create_user(self, request): assert request.called_once
prepare_response(request)
self.client.create_user('Test User', 'testuser', 'test@example.com', 'notapassword')
self.assertEqual(request.call_count, 2)
# XXX incomplete
def test_update_email(self, request): def test_new_topics(self, discourse_client, discourse_request):
prepare_response(request) request = discourse_request("get", "/new.json")
email = 'test@example.com' discourse_client.new_topics()
self.client.update_email('someuser', email) assert request.called_once
self.assertRequestCalled(request, 'PUT', '/users/someuser/preferences/email', email=email)
def test_update_user(self, request): def test_topic(self, discourse_client, discourse_request):
prepare_response(request) request = discourse_request("get", "/t/some-test-slug/22.json")
self.client.update_user('someuser', a='a', b='b') discourse_client.topic("some-test-slug", 22)
self.assertRequestCalled(request, 'PUT', '/users/someuser', a='a', b='b') assert request.called_once
def test_update_username(self, request): def test_topics_by(self, discourse_client, discourse_request):
prepare_response(request) request = discourse_request(
self.client.update_username('someuser', 'newname') "get",
self.assertRequestCalled(request, 'PUT', '/users/someuser/preferences/username', username='newname') "/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 TestPosts:
class TestTopics(ClientBaseTestCase): 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): def test_post_by_number(self, discourse_client, discourse_request):
prepare_response(request) request = discourse_request("get", "/posts/by_number/8796/5")
self.client.hot_topics() discourse_client.post_by_number(8796, 5)
self.assertRequestCalled(request, 'GET', '/hot.json') assert request.called_once
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)
@mock.patch('requests.request') class TestSearch:
class MiscellaneousTests(ClientBaseTestCase): 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): class TestCategories:
prepare_response(request) def test_categories(self, discourse_client, discourse_request):
r = self.client.categories() request = discourse_request(
self.assertRequestCalled(request, 'GET', '/categories.json') "get",
self.assertEqual(r, request().json()['category_list']['categories']) "/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
View 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
View 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 .