diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml new file mode 100644 index 0000000..eb8762b --- /dev/null +++ b/.github/workflows/CI.yaml @@ -0,0 +1,234 @@ +# This file is autogenerated by maturin v1.7.0 +# To update, run +# +# maturin generate-ci github +# +name: CI + +on: + push: + branches: + - main + - master + tags: + - '*' + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + unit_test: + name: Unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - run: cargo test --all-features + rust_integration_test: + name: Rust Integration test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: cd tests/rust-test && ./test.sh + python_integration_test: + name: Python Integration test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: getsentry/action-setup-venv@v2.1.0 + id: venv + with: + python-version: 3.10.7 + - run: cd tests/python-sync && ./test.sh + linux: + runs-on: ${{ matrix.platform.runner }} + defaults: + run: + working-directory: ./szurubooru-client + strategy: + matrix: + platform: + - runner: ubuntu-latest + target: x86_64 + - runner: ubuntu-latest + target: x86 + - runner: ubuntu-latest + target: aarch64 + - runner: ubuntu-latest + target: armv7 + - runner: ubuntu-latest + target: ppc64le + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: -F python --release --out dist --find-interpreter -m szurubooru-client/Cargo.toml + sccache: 'true' + manylinux: auto + before-script-linux: | + # If we're running on rhel centos, install needed packages. + if command -v yum &> /dev/null; then + yum update -y && yum install -y perl-core openssl openssl-devel pkgconfig libatomic + + # If we're running on i686 we need to symlink libatomic + # in order to build openssl with -latomic flag. + if [[ ! -d "/usr/lib64" ]]; then + ln -s /usr/lib/libatomic.so.1 /usr/lib/libatomic.so + fi + else + # If we're running on debian-based system. + apt update -y && apt-get install -y libssl-dev openssl pkg-config + fi + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.platform.target }} + path: dist + + musllinux: + runs-on: ${{ matrix.platform.runner }} + defaults: + run: + working-directory: ./szurubooru-client + strategy: + matrix: + platform: + - runner: ubuntu-latest + target: x86_64 + - runner: ubuntu-latest + target: x86 + - runner: ubuntu-latest + target: aarch64 + - runner: ubuntu-latest + target: armv7 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: -F python --release --out dist --find-interpreter -m szurubooru-client/Cargo.toml + sccache: 'true' + manylinux: musllinux_1_2 + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-musllinux-${{ matrix.platform.target }} + path: dist + + windows: + runs-on: ${{ matrix.platform.runner }} + defaults: + run: + working-directory: ./szurubooru-client + strategy: + matrix: + platform: + - runner: windows-latest + target: x64 + - runner: windows-latest + target: x86 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + architecture: ${{ matrix.platform.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: -F python --release --out dist --find-interpreter -m szurubooru-client/Cargo.toml + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.platform.target }} + path: dist + + macos: + runs-on: ${{ matrix.platform.runner }} + defaults: + run: + working-directory: ./szurubooru-client + strategy: + matrix: + platform: + - runner: macos-12 + target: x86_64 + - runner: macos-14 + target: aarch64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: -F python --release --out dist --find-interpreter -m szurubooru-client/Cargo.toml + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.platform.target }} + path: dist + + sdist: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./szurubooru-client + steps: + - uses: actions/checkout@v4 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist -m szurubooru-client/Cargo.toml + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist + + python_release: + name: Python Release + runs-on: ubuntu-latest + if: "startsWith(github.ref, 'refs/tags/')" + needs: [linux, musllinux, windows, macos, sdist] + steps: + - uses: actions/download-artifact@v4 + - name: Publish to PyPI + uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + with: + command: upload + args: --non-interactive --skip-existing wheels-*/* + crate_release: + name: Crates.io Release + runs-on: ubuntu-latest + if: "startsWith(github.ref, 'refs/tags/')" + needs: [linux, musllinux, windows, macos, sdist] + steps: + - uses: actions/checkout@v4 + - name: Cargo publish + run: cargo publish --token ${CRATES_TOKEN} + env: + CRATES_TOKEN: ${{ secrets.CRATES_TOKEN }} diff --git a/.gitignore b/.gitignore index 753e9e3..878ae2c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ szurubooru-integration-test/szurubooru/data/* szurubooru-integration-test/szurubooru/pgsql/* szurubooru-integration-test/szurubooru/server/* **/.DS_Store +**/target +*.so +*.pyc +/docs/_build/** diff --git a/CHANGELOG.md b/CHANGELOG.md index 79d6c55..45e9863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +## v0.5.0 (2024-09-05) + +### Feat + +- **all**: adding python library wrapper + +### Fix + +- **python**: refactoring python client +- **cargo**: adding vendored openssl to help with build +- **python-client**: fixing paged search results to use a PyList instead + +### Refactor + +- **python**: adding custom python modules to possibly help with documentation +- **models**: removing unnecessary python classes +- **models**: saving some changes in prep of python library + ## v0.4.0 (2024-08-12) ### Feat diff --git a/Cargo.lock b/Cargo.lock index acceafc..74a8f6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,6 +150,28 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + [[package]] name = "colored" version = "2.1.0" @@ -632,6 +654,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + [[package]] name = "ipnet" version = "2.9.0" @@ -702,6 +730,15 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -846,6 +883,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +[[package]] +name = "openssl-src" +version = "300.3.1+3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7259953d42a81bf137fbbd73bd30a8e1914d6dce43c2b90ed575783a22608b91" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" version = "0.9.103" @@ -854,6 +900,7 @@ checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] @@ -887,12 +934,59 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + [[package]] name = "percent-encoding" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.5" @@ -931,6 +1025,12 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "portable-atomic" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da544ee218f0d287a911e9c99a39a8c9bc8fcad3cb8db5959940044ecfc67265" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -949,6 +1049,72 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pyo3" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831e8e819a138c36e212f3af3fd9eeffed6bf1510a805af35b0edee5ffa59433" +dependencies = [ + "cfg-if", + "chrono", + "chrono-tz", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "serde", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8730e591b14492a8945cdff32f089250b05f5accecf74aeddf9e8272ce1fa8" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e97e919d2df92eb88ca80a037969f44e5e70356559654962cbb3316d00300c6" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb57983022ad41f9e683a599f2fd13c3664d7063a3ac5714cae4b7bee7d3f206" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec480c0c51ddec81019531705acac51bcdbeae563557c982aa8263bb96880372" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.36" @@ -1102,6 +1268,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rust-test" +version = "0.1.0" +dependencies = [ + "chrono", + "sha1", + "szurubooru-client", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -1213,18 +1392,28 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.205" +version = "1.0.208" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e33aedb1a7135da52b7c21791455563facbbcc43d0f0f66165b42c21b3dfb150" +checksum = "cff085d2cb684faa248efb494c39b68e522822ac0de72ccf08109abde717cfb2" dependencies = [ "serde_derive", ] [[package]] -name = "serde_derive" -version = "1.0.205" +name = "serde-pyobject" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692d6f5ac90220161d6774db30c662202721e64aed9058d2c394f451261420c1" +checksum = "ca4b0aad8b225845739a0030a0d5cc2ae949c56a86a7daf9226c7df7c2016d16" +dependencies = [ + "pyo3", + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.208" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24008e81ff7613ed8e5ba0cfaf24e2c2f1e5b8a0495711e44fcd4882fca62bcf" dependencies = [ "proc-macro2", "quote", @@ -1290,6 +1479,12 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "slab" version = "0.4.9" @@ -1357,9 +1552,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.72" +version = "2.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af" +checksum = "f6af063034fc1935ede7be0122941bafa9bacb949334d090b77ca98b5817c7d9" dependencies = [ "proc-macro2", "quote", @@ -1395,7 +1590,7 @@ dependencies = [ [[package]] name = "szurubooru-client" -version = "0.4.0" +version = "0.5.0" dependencies = [ "base64", "bytes", @@ -1404,8 +1599,11 @@ dependencies = [ "futures-util", "hex", "mockito", + "openssl", + "pyo3", "reqwest", "serde", + "serde-pyobject", "serde_json", "sha1", "strum", @@ -1418,17 +1616,10 @@ dependencies = [ ] [[package]] -name = "szurubooru-integration-test" -version = "0.1.0" -dependencies = [ - "chrono", - "sha1", - "szurubooru-client", - "tempfile", - "tokio", - "tracing", - "tracing-subscriber", -] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" @@ -1682,6 +1873,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unindent" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 772ecea..af1c440 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,5 +2,7 @@ resolver = "2" members = [ "szurubooru-client", - "szurubooru-integration-test" + #"szurubooru-integration-test", + "tests/rust-test" ] +default-members = ["szurubooru-client"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5164762 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2024 Scott Lyons + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..bfea5fd --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,28 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +serve: + python -m http.server -d $(BUILDDIR)/html + +clean: + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + pip uninstall -y szurubooru_client + maturin develop -F python -m ../szurubooru-client/Cargo.toml + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/client.rst b/docs/client.rst new file mode 100644 index 0000000..4c9588a --- /dev/null +++ b/docs/client.rst @@ -0,0 +1,63 @@ +Szurubooru Clients +================== + +These are the clients available for interacting with Szurubooru. There are two clients, ``SzurubooruSyncClient`` and ``SzurubooruAsyncClient`` . The only difference +between the two of them is that ``SzurubooruAsyncClient`` is compatible with ``asyncio`` for use in ``async`` scripts. + +.. contents:: Table of Contents + :depth: 3 + :local: + +Sync Client +----------- + +.. autoclass:: szurubooru_client.SzurubooruSyncClient + + +Async Client +------------ + +.. autoclass:: szurubooru_client.SzurubooruAsyncClient + + +Exceptions +---------- + +All exceptions that are thrown by the client are instances of the ``SzuruClientError`` class. It's a tuple of two items: An exception type (as a string) and a string with more details +about the exception. + +.. autoexception:: szurubooru_client.SzuruClientError + +.. _rver: + +Resource Versioning +------------------- + +Many of the ``update`` and other methods on the client classes accept a ``version`` argument. This is part of Szurubooru's optimistic locking. Each resource has its ``version`` retuned +to the client through any of the ``get`` methods. This value must be provided to any of the ``update`` or ``delete`` methods. If the version doesn't match at the time +(due to a modified resource) then the call will fail. + +Pagination +---------- + +Most of the ``list`` methods on the clients return a paged object that holds the results along with other details about the query: + +.. autoclass:: szurubooru_client.PagedResult + +Paging through the results of ``list`` methods are done using two parameters: + +.. _limits: + +Result Limits +^^^^^^^^^^^^^ + +Most of the ``list`` methods on the clients support a ``limit`` parameter. This parameter limits the number of resources returned by the method as part of +a :class:`~szurubooru_client.PagedResult` object + +.. _offsets: + +Result Offsets +^^^^^^^^^^^^^^ + +Most of the ``list`` methods on the clients support a ``offset`` parameter. This parameter skips through the results returned by the method as part of +a :class:`~szurubooru_client.PagedResult` object diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..8b907ad --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,41 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'Szurubooru Client' +copyright = '2024, Scott Lyons' +author = 'Scott Lyons' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'myst_parser', + 'sphinx.ext.autodoc', + "sphinx_codefence" +] + +autodoc_default_options = { + 'members': True, + 'show-inheritance': True, +} +autodoc_typehints = "description" + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +source_suffix = { + '.rst': 'restructuredtext', + '.txt': 'markdown', + '.md': 'markdown', +} + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'alabaster' +html_static_path = ['_static'] diff --git a/docs/examples.rst b/docs/examples.rst new file mode 100644 index 0000000..c1cec75 --- /dev/null +++ b/docs/examples.rst @@ -0,0 +1,113 @@ +======== +Examples +======== + +.. contents:: Table of Contents + :depth: 3 + :local: + +All of these examples are taken from the ``python-sync`` script in the ``tests`` directory of the repo. + +Before any of the examples will work, you need to import the right classes and functions: + +```python +from szurubooru_client import * +from szurubooru_client.tokens import * +from szurubooru_client.models import * +``` + +Creating a client +^^^^^^^^^^^^^^^^^ + +```python +client = SzurubooruSyncClient("http://localhost:9802", username="integration_user", + password="integration_password", allow_insecure=True) +``` + +Creating a new tag +^^^^^^^^^^^^^^^^^^ + +```python +foo_tag = client.create_tag("foo", category="default", description="The foo tag") +assert foo_tag.names == ["foo"] +``` + +Returning only a subset of fields +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +```python +# Omit the "description" field +tags = client.list_tags(fields=["version", "names", "category"]) +assert tags.results[0].description is None +``` + +Uploading from a file path +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +```python +folly1 = client.create_post(file_path="../folly1.jpg", + tags=["maine_coon", "cat", "folly1"], + safety=PostSafety.Safe) +``` + +Searching for an existing post using an image +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +```python +folly1_search = client.post_for_image("../folly1.jpg") +assert folly1_search is not None +``` + +Querying by an anonymous tag +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +```python +cat_posts = client.list_posts(query=[anonymous_token("cat")]) +``` + +Querying by a named tag +^^^^^^^^^^^^^^^^^^^^^^^ + +```python +mc_posts = client.list_posts(query=[named_token(PostNamedToken.Tag, "maine_coon")]) +``` + +Pagination +^^^^^^^^^^ + +```python +posts = client.list_posts(limit=1) +assert posts.total == 4 +assert len(posts.results) == 1 + +posts2 = client.list_posts(limit=1, offset=1) +assert posts.results != posts2.results +``` + +Commenting on a post +^^^^^^^^^^^^^^^^^^^^ + +```python +cat_results = client.list_posts([anonymous_token("cat")]) +post_id = cat_results.results[0].id + +comment = client.create_comment("Excellent cat!", post_id) +``` + +Getting all comments for a post +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +```python +comment_list = client.list_comments([named_token(CommentNamedToken.Post, post_id)]) +assert len(comment_list.results) != 0 +``` + +Downloading an image to a local path +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +```python +cat_results = client.list_posts([anonymous_token("cat")]) +post_id = cat_results.results[0].id + +client.download_image_to_path(post_id, "/tmp/cat.jpg") +``` diff --git a/docs/fields.rst b/docs/fields.rst new file mode 100644 index 0000000..34ae948 --- /dev/null +++ b/docs/fields.rst @@ -0,0 +1,15 @@ +Field selection +=============== + +The Szurubooru API supports *Field Selection*. This allows you to select only a subset of fields to return from the API. By default all fields are selected and returned, but if you +pass in a list of field names to any of the client methods that support it, only those fields will be populated in the models. + +Example +------- + +```python +tags = client.list_tags(fields=["version", "names", "category"]) +assert tags.results[0].description is None +``` + +Because the ``description`` field was not specified, it's set to ``None`` diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..65c9ffc --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,15 @@ +Szurubooru Client documentation +=============================== + +Welcome to the documentation for ``szurubooru_client`` . This library is a wrapper around a Rust library for interacting with the self-hosted +image site `Szurubooru `_ + +.. toctree:: + :maxdepth: 1 + :caption: Contents: + + client + tokens + models + fields + examples diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..32bb245 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/models.rst b/docs/models.rst new file mode 100644 index 0000000..adabf70 --- /dev/null +++ b/docs/models.rst @@ -0,0 +1,10 @@ +Szurubooru Models +================= + +.. contents:: Table of Contents + :depth: 3 + :local: + +.. automodule:: szurubooru_client.models + :members: + :undoc-members: diff --git a/docs/tokens.rst b/docs/tokens.rst new file mode 100644 index 0000000..d11ae2d --- /dev/null +++ b/docs/tokens.rst @@ -0,0 +1,830 @@ +Szurubooru Query Tokens +======================= + +.. contents:: Table of Contents + :depth: 3 + :local: + +Most of the ``list`` methods on the client support a ``query`` parameter. This parameter (a ``list`` of tokens) allows you to filter the results returned by the API. + +There's four kinds of tokens: ``Named``, ``Sort``, ``Anonymous`` and ``Special``. +Because this library is a wrapper around a Rust library, there's a group of type-safe ``Enum`` objects that allow you to +make sure you get the right field names when constructing the tokens. + +All tokens can be mixed and matched to filter the results how you'd prefer. + + +.. _named-tokens: + + +Named tokens +^^^^^^^^^^^^ + +.. autofunction:: szurubooru_client.tokens.named_token + +.. _sort-tokens: + + +Sort tokens +^^^^^^^^^^^ + +.. autofunction:: szurubooru_client.tokens.sort_token + + +Anonymous tokens +^^^^^^^^^^^^^^^^ + +.. autofunction:: szurubooru_client.tokens.anonymous_token + + +Special tokens +^^^^^^^^^^^^^^ + +.. autofunction:: szurubooru_client.tokens.special_token + + +Negating tokens +^^^^^^^^^^^^^^^ + +All query tokens can be negated using either ``.negate`` or by prefixing with the ``-`` operator. Negated tokens mean to *not* match that particular token. + +For example, to match the anonymous tag ``konosuba``, we would do the following: + +```python +client.list_posts(query=[anonymous_token("konosuba")]) +``` + +Now to match all posts that *don't* have the ``konosuba`` tag: + +```python +client.list_posts(query=[-anonymous_token("konosuba")]) +# or +client.list_posts(query=[anonymous_token("konosuba").negate()]) +``` + +.. _named-tokens-enums: + +Token Enums +^^^^^^^^^^^ + +----------------- +Named Token Enums +----------------- + +.. py:class:: szurubooru_client.tokens.TagNamedToken + + .. py:attribute:: Name + + Having given name (accepts wildcards) + + + .. py:attribute:: Category + + Having given category (accepts wildcards) + + + .. py:attribute:: CreationDate + + Created at given date + + + .. py:attribute:: LastEditDate + + Edited at given date + + + .. py:attribute:: LastEditTime + + Alias of :attr:`LastEditTime ` + + + .. py:attribute:: EditDate + + Alias of :attr:`LastEditTime ` + + + .. py:attribute:: EditTime + + Alias of :attr:`LastEditTime ` + + + .. py:attribute:: Usages + + Used in given number of posts + + + .. py:attribute:: UsageCount + + Alias of :attr:`Usages ` + + + .. py:attribute:: PostCount + + Alias of :attr:`Usages ` + + + .. py:attribute:: SuggestionCount + + With given number of suggestions + + + .. py:attribute:: ImplicationCount + + With given number of implications + +.. py:class:: szurubooru_client.tokens.PostNamedToken + + .. py:attribute:: Id + + Having given post number + + .. py:attribute:: Tag + + Having given tag (accepts wildcards) + + + .. py:attribute:: Score + + Having given score + + + .. py:attribute:: Uploader + + Uploaded by given user (accepts wildcards) + + + .. py:attribute:: Upload + + Alias of :attr:`Uploader ` + + + .. py:attribute:: Submit + + Alias of :attr:`Uploader ` + + + .. py:attribute:: Comment + + Commented by given user (accepts wildcards) + + + .. py:attribute:: Fav + + Favorited by given user (accepts wildcards) + + + .. py:attribute:: Pool + + Belonging to the pool with the given id + + + .. py:attribute:: TagCount + + Having given number of tags + + + .. py:attribute:: CommentCount + + Having given number of comments + + + .. py:attribute:: FavCount + + Favorited by given number of users + + + .. py:attribute:: NoteCount + + Having given number of annotations + + + .. py:attribute:: NoteText + + Having given note text (accepts wildcards) + + + .. py:attribute:: RelationCount + + Having given number of relations + + + .. py:attribute:: FeatureCount + + Having been featured given number of times + + + .. py:attribute:: Type + + ``flash`` (or ``swf``) or ``video`` (or ``webm``). Use :attr:`models.posttype` for type-safe values + + + .. py:attribute:: ContentChecksum + + Having given sha1 checksum + + + .. py:attribute:: FileSize + + Having given file size (in bytes) + + + .. py:attribute:: ImageWidth + + Having given image width (where applicable) + + + .. py:attribute:: ImageHeight + + Having given image height (where applicable) + + + .. py:attribute:: ImageArea + + Having given number of pixels (image width * image height) + + + .. py:attribute:: ImageAspectRatio + + Having given aspect ratio (image width / image height) + + + .. py:attribute:: ImageAr + + Alias of :attr:`ImageAspectRatio ` + + + .. py:attribute:: Width + + Alias of :attr:`ImageWidth ` + + + .. py:attribute:: Height + + Alias of :attr:`ImageHeight ` + + + .. py:attribute:: Ar + + Alias of :attr:`ImageAspectRatio ` + + + .. py:attribute:: AspectRatio + + Alias of :attr:`ImageAspectRatio ` + + + .. py:attribute:: CreationDate + + Posted at given date + + + .. py:attribute:: CreationTime + + Alias of :attr:`CreationDate ` + + + .. py:attribute:: Date + + Alias of :attr:`CreationDate ` + + + .. py:attribute:: Time + + Alias of :attr:`CreationDate ` + + + .. py:attribute:: LastEditDate + + Edited at given date + + + .. py:attribute:: LastEditTime + + Alias of :attr:`LastEditDate ` + + + .. py:attribute:: EditDate + + Alias of :attr:`LastEditDate ` + + + .. py:attribute:: EditTime + + Alias of :attr:`LastEditDate ` + + + .. py:attribute:: CommentDate + + Commented at given date + + + .. py:attribute:: CommentTime + + Alias of :attr:`CommentDate ` + + + .. py:attribute:: FavDate + + Last favorited at given time + + + .. py:attribute:: FavTime + + Alias of :attr:`FavDate ` + + + .. py:attribute:: FeatureDate + + Featured at given date + + + .. py:attribute:: FeatureTime + + Alias of :attr:`FeatureDate ` + + + .. py:attribute:: Safety + + Use :class:`PostSafety ` for the type-safe version + + + .. py:attribute:: Rating + + Alias of :attr:`Safety ` + +.. py:class:: szurubooru_client.tokens.CommentNamedToken + + .. py:attribute:: Id + + Specific comment id + + + .. py:attribute:: Post + + Specific post id + + + .. py:attribute:: User + + Created by given user (accepts wildcards) + + + .. py:attribute:: Author + + Alias of user + + + .. py:attribute:: Text + + Containing given text (accepts wildcards) + + + .. py:attribute:: CreationDate + + Created at given date + + + .. py:attribute:: CreationTime + + Alias of :attr:`CreationDate ` + + + .. py:attribute:: LastEditDate + + Whose most recent edit date matches given date + + + .. py:attribute:: LastEditTime + + Alias of :attr:`LastEditDate ` + + + .. py:attribute:: EditDate + + Alias of :attr:`LastEditDate ` + + + .. py:attribute:: EditTime + + Alias of :attr:`LastEditDate ` + +.. py:class:: szurubooru_client.tokens.UserNamedToken + + + .. py:attribute:: Name + + Having given name (accepts wildcards) + + + .. py:attribute:: CreationDate + + Registered at given date + + + .. py:attribute:: CreationTime + + Alias of :attr:`CreationDate ` + + + .. py:attribute:: LastLoginDate + + Whose most recent login date matches given date + + + .. py:attribute:: LastLoginTime + + Alias of :attr:`LastLoginDate ` + + + .. py:attribute:: LoginDate + + Alias of :attr:`LastLoginDate ` + + + .. py:attribute:: LoginTime + + Alias of :attr:`LastLoginDate ` + +.. py:class:: szurubooru_client.tokens.SnapshotNamedToken + + + .. py:attribute:: Type + + Involving given resource type + + + .. py:attribute:: Id + + Involving given resource id + + + .. py:attribute:: Date + + Created at given date + + + .. py:attribute:: Time + + Alias of :attr:`Date ` + + + .. py:attribute:: Operation + + ``modified``, ``created``, ``deleted`` or ``merged``. Use :attr:`SnapshotType ` for type-safe values + + + .. py:attribute:: User + + Name of the user that created given snapshot (accepts wildcards) + +.. _sort-tokens-enums: + +---------------- +Sort Token Enums +---------------- + +.. py:class:: szurubooru_client.tokens.PostSortToken + + .. py:attribute:: Random + + As random as it can get + + + .. py:attribute:: Id + + Highest to lowest post number + + + .. py:attribute:: Score + + Highest scored + + + .. py:attribute:: TagCount + + With most tags + + + .. py:attribute:: CommentCount + + Most commented first + + + .. py:attribute:: FavCount + + Loved by most + + + .. py:attribute:: NoteCount + + With most annotations + + + .. py:attribute:: RelationCount + + With most relations + + + .. py:attribute:: FeatureCount + + Most often featured + + + .. py:attribute:: FileSize + + Largest files first + + + .. py:attribute:: ImageWidth + + Widest images first + + + .. py:attribute:: ImageHeight + + Tallest images first + + + .. py:attribute:: ImageArea + + Largest images first + + + .. py:attribute:: Width + + Alias of :attr:`ImageWidth ` + + + .. py:attribute:: Height + + Alias of :attr:`ImageHeight ` + + + .. py:attribute:: Area + + Alias of :attr:`ImageArea ` + + + .. py:attribute:: CreationDate + + Newest to oldest (pretty much same as id) + + + .. py:attribute:: CreationTime + + Alias of :attr:`CreationDate ` + + + .. py:attribute:: Date + + Alias of :attr:`CreationDate ` + + + .. py:attribute:: Time + + Alias of :attr:`CreationDate ` + + + .. py:attribute:: LastEditDate + + Like :attr:`CreationDate `, only looks at last edit time instead + + + .. py:attribute:: LastEditTime + + Alias of :attr:`LastEditDate ` + + + .. py:attribute:: EditDate + + Alias of :attr:`LastEditDate ` + + + .. py:attribute:: EditTime + + Alias of :attr:`LastEditDate ` + + + .. py:attribute:: CommentDate + + Recently commented by anyone + + + .. py:attribute:: CommentTime + + Alias of :attr:`CommentDate ` + + + .. py:attribute:: FavDate + + Recently added to favorites by anyone + + + .. py:attribute:: FavTime + + Alias of :attr:`FavDate ` + + + .. py:attribute:: FeatureDate + + Recently featured + + + .. py:attribute:: FeatureTime + + Alias of :attr:`FeatureDate ` + + +.. py:class:: szurubooru_client.tokens.TagSortToken + + .. py:attribute:: Random + + As random as it can get + + + .. py:attribute:: Name + + A to Z + + + .. py:attribute:: Category + + Category (a to z) + + + .. py:attribute:: CreationDate + + Recently created first + + + .. py:attribute:: CreationTime + + Alias of :attr:`CreationDate ` + + + .. py:attribute:: LastEditDate + + Recently edited first + + + .. py:attribute:: LastEditTime + + Alias of :attr:`CreationDate ` + + + .. py:attribute:: EditDate + + Alias of :attr:`CreationDate ` + + + .. py:attribute:: EditTime + + Alias of :attr:`CreationDate ` + + + .. py:attribute:: Usages + + Used in most posts first + + + .. py:attribute:: UsageCount + + Alias of :attr:`Usages ` + + + .. py:attribute:: PostCount + + Alias of :attr:`Usages ` + + + .. py:attribute:: SuggestionCount + + With most suggestions first + + + .. py:attribute:: ImplicationCount + + With most implications first + + +.. py:class:: szurubooru_client.tokens.CommentSortToken + + + .. py:attribute:: Random + + As random as it can get + + + .. py:attribute:: User + + Author name, a to z + + + .. py:attribute:: Author + + Alias of user + + + .. py:attribute:: Post + + Post id, newest to oldest + + + .. py:attribute:: CreationDate + + Newest to oldest + + + .. py:attribute:: CreationTime + + Alias of :attr:`CreationDate ` + + + .. py:attribute:: LastEditDate + + Recently edited first + + + .. py:attribute:: LastEditTime + + Alias of :attr:`LastEditDate ` + + + .. py:attribute:: EditDate + + Alias of :attr:`LastEditDate ` + + + .. py:attribute:: EditTime + + Alias of :attr:`LastEditDate ` + + +.. py:class:: szurubooru_client.tokens.UserSortToken + + .. py:attribute:: Random + + As random as it can get + + + .. py:attribute:: Name + + A to z + + + .. py:attribute:: CreationDate + + Newest to oldest + + + .. py:attribute:: CreationTime + + Alias of :attr:`CreationDate ` + + + .. py:attribute:: LastLoginDate + + Recently active first + + + .. py:attribute:: LastLoginTime + + Alias of :attr:`LastLoginDate ` + + + .. py:attribute:: LoginDate + + Alias of :attr:`LastLoginDate ` + + + .. py:attribute:: LoginTime + + Alias of :attr:`LastLoginDate ` + +.. _special-tokens-enum: + +------------------- +Special Token Enums +------------------- + +.. py:class:: szurubooru_client.tokens.PostSpecialToken + + + .. py:attribute:: Liked + + Posts liked by currently logged-in user + + + .. py:attribute:: Disliked + + Posts disliked by currently logged in user + + + .. py:attribute:: Fav + + Posts added to favorites by currently logged-in user + + + .. py:attribute:: Tumbleweed + + Posts with score of 0, without comments and without favorites diff --git a/szurubooru-client/CHANGELOG.md b/szurubooru-client/CHANGELOG.md index 79d6c55..45e9863 100644 --- a/szurubooru-client/CHANGELOG.md +++ b/szurubooru-client/CHANGELOG.md @@ -1,3 +1,21 @@ +## v0.5.0 (2024-09-05) + +### Feat + +- **all**: adding python library wrapper + +### Fix + +- **python**: refactoring python client +- **cargo**: adding vendored openssl to help with build +- **python-client**: fixing paged search results to use a PyList instead + +### Refactor + +- **python**: adding custom python modules to possibly help with documentation +- **models**: removing unnecessary python classes +- **models**: saving some changes in prep of python library + ## v0.4.0 (2024-08-12) ### Feat diff --git a/szurubooru-client/Cargo.toml b/szurubooru-client/Cargo.toml index acf43ac..ab1e3da 100644 --- a/szurubooru-client/Cargo.toml +++ b/szurubooru-client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "szurubooru-client" -version = "0.4.0" +version = "0.5.0" edition = "2021" authors = ["Scott Lyons "] description = "A wrapper around the Szurubooru API, including type-safe Query and Sort tokens" @@ -16,13 +16,17 @@ chrono = { version = "0.4.38", features = ["serde"] } derive_builder = "0.20.0" futures-util = "0.3.30" hex = "0.4.3" +openssl = { version = "0.10.66", features = ["vendored"] } +pyo3 = { version="0.22.0", optional=true, features=["chrono-tz", "chrono", "serde", "experimental-async"] } reqwest = { version = "0.12.5", features = ["json", "multipart", "stream"] } serde = { version = "1.0.204", features = ["derive"] } +serde-pyobject = { version = "0.4.0", optional = true } serde_json = "1.0.120" sha1 = "0.10.6" strum = { version = "0.26.3", features = ["derive", "strum_macros"] } strum_macros = "0.26.4" thiserror = "1.0.63" +tokio = { version = "1.39.2", features = ["rt", "sync"], optional = true } tracing = "0.1.40" url = "2.5.2" urlencoding = "2.1.3" @@ -30,3 +34,11 @@ urlencoding = "2.1.3" [dev-dependencies] mockito = "1.4.0" tokio = { version = "1.39.2", features = ["full"] } + +[features] +python = ["dep:pyo3", "dep:tokio", "dep:serde-pyobject", "pyo3/extension-module"] +extension-module = ["pyo3/extension-module"] + +[lib] +name = "szurubooru_client" +crate-type = ["cdylib", "lib"] diff --git a/szurubooru-client/pyproject.toml b/szurubooru-client/pyproject.toml new file mode 100644 index 0000000..8c91a5d --- /dev/null +++ b/szurubooru-client/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["maturin>=1.7,<2.0"] +build-backend = "maturin" + +[project] +name = "szurubooru_client" +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Development Status :: 4 - Beta", + "Environment :: Console", + "Environment :: Web Environment", + "Intended Audience :: End Users/Desktop", + "Operating System :: Microsoft :: Windows", + "Operating System :: MacOS :: MacOS X", + "Operating System :: POSIX", + "Topic :: Multimedia :: Graphics", + +] +dynamic = ["version"] +[tool.maturin] +features = ["pyo3/extension-module", "python"] diff --git a/szurubooru-client/src/client.rs b/szurubooru-client/src/client.rs index 13130ee..e1a83d4 100644 --- a/szurubooru-client/src/client.rs +++ b/szurubooru-client/src/client.rs @@ -128,10 +128,10 @@ impl SzurubooruClient { /// Construct a new request using the existing client auth and base URL /// All requests start with the [SzurubooruClient] struct. - /// The (request)[SzurubooruClient::request], - /// (with_fields)[SzurubooruClient::fields], - /// (limit)[SzurubooruClient::limit] and - /// (offset)[SzurubooruClient::offset] methods all return a [SzurubooruRequest] struct that will + /// The [request](crate::SzurubooruClient::request), + /// [with_fields](crate::SzurubooruClient::with_fields), + /// [with_limit](crate::SzurubooruClient::with_limit) and + /// [with_offset](crate::SzurubooruClient::with_offset) methods all return a [SzurubooruRequest] struct that will /// enable you to actually make the requests. /// ```no_run /// # use szurubooru_client::SzurubooruClient; @@ -149,31 +149,36 @@ impl SzurubooruClient { /// Construct a new request while selecting only the given fields /// The Szurubooru API supports selecting a subset of fields for a given resource. - /// Most resource (models)[szurubooru_client::models] have [Option] fields because of that. + /// Most resource [models](crate::models) have [Option] fields because of that. /// The default is to return all fields for a given resource. /// See [here](https://github.com/rr-/szurubooru/blob/master/doc/API.md#field-selecting) for /// more details /// /// For example, to select only the `version`, `id` and `content_url` fields of a - /// (PostResource)[szurubooru_client::models::PostResource] + /// [PostResource] /// ```no_run /// # use szurubooru_client::SzurubooruClient; /// # #[allow(unused)] /// # async { /// let client = SzurubooruClient::new_with_token("http://localhost:5001", "myuser", "sz-123456", true).unwrap(); - /// let new_request = client.request().with_fields(vec!["version", "id", "content_url"]); + /// let new_request = client.request().with_fields(vec!["version".to_string(), "id".to_string(), "content_url".to_string()]); /// # }; /// # () /// ``` - pub fn with_fields<'a>(&'a self, fields: Vec<&'a str>) -> SzurubooruRequest { + pub fn with_fields(&self, fields: Vec) -> SzurubooruRequest { self.request().with_fields(fields) } + /// The same as [with_fields](SzurubooruClient::with_fields), but accepts an Option type instead + pub fn with_optional_fields(&self, fields: Option>) -> SzurubooruRequest { + self.request().with_optional_fields(fields) + } + /// Construct a new request with the given limit /// The Szurubooru API supports limiting the number of resources returned for Paginated /// API endpoints. /// - /// For example, to limit the number of pools returned by (list_pools)[SzurubooruRequest::list_pools] + /// For example, to limit the number of pools returned by [list_pools](SzurubooruRequest::list_pools) /// ```no_run /// # use szurubooru_client::SzurubooruClient; /// # #[allow(unused)] @@ -190,12 +195,17 @@ impl SzurubooruClient { self.request().with_limit(limit) } + /// The same as [with_limit](SzurubooruClient::with_limit), but accepts an Option type instead + pub fn with_optional_limit(&self, limit: Option) -> SzurubooruRequest { + self.request().with_optional_limit(limit) + } + /// Construct a new request starting at the given offset /// The Szurubooru API supports offsetting the results returned from Paginated API /// endpoints. Use this offset in combination with the limit to page through /// large result sets. /// - /// For example, to offset the list of pools returned by (list_pools)[SzurubooruRequest::list_pools] + /// For example, to offset the list of pools returned by [list_pools](SzurubooruRequest::list_pools) /// ```no_run /// # use szurubooru_client::SzurubooruClient; /// # #[allow(unused)] @@ -211,14 +221,23 @@ impl SzurubooruClient { pub fn with_offset(&self, offset: u32) -> SzurubooruRequest { self.request().with_offset(offset) } + + /// The same as [with_offset](SzurubooruClient::with_offset), but accepts an Option type instead + pub fn with_optional_offset(&self, offset: Option) -> SzurubooruRequest { + self.request().with_optional_offset(offset) + } } #[derive(Debug)] /// A type that represents a single Szurubooru request. pub struct SzurubooruRequest<'a> { - fields: Option>, - limit: Option, - offset: Option, + /// The currently selected fields to return (if applicable) + pub fields: Option>, + /// The maximum number of resources to return (if supported by the API endpoint) + pub limit: Option, + /// The number of resource to skip before returning any results + /// (if supported by the API endpoint) + pub offset: Option, client: &'a SzurubooruClient, } @@ -234,32 +253,40 @@ impl<'a> SzurubooruRequest<'a> { /// Select which fields to return from the query. /// The Szurubooru API supports selecting a subset of fields for a given resource. - /// Most resource (models)[szurubooru_client::models] have [Option] fields because of that. + /// Most resource [models](crate::models) have [Option] fields because of that. /// The default is to return all fields for a given resource. /// See [here](https://github.com/rr-/szurubooru/blob/master/doc/API.md#field-selecting) for /// more details /// /// For example, to select only the `version`, `id` and `content_url` fields of a - /// (PostResource)[szurubooru_client::models::PostResource] + /// [PostResource] /// ```no_run /// # use szurubooru_client::SzurubooruClient; /// # #[allow(unused)] /// # async { /// let client = SzurubooruClient::new_with_token("http://localhost:5001", "myuser", "sz-123456", true).unwrap(); - /// let new_request = client.request().with_fields(vec!["version", "id", "content_url"]); + /// let new_request = client.request().with_fields(vec!["version".to_string(), "id".to_string(), "content_url".to_string()]); /// # }; /// # () /// ``` - pub fn with_fields(mut self, fields: Vec<&'a str>) -> Self { + pub fn with_fields(mut self, fields: Vec) -> Self { self.fields = Some(fields); self } + /// The same as [with_fields](SzurubooruRequest::with_fields), but accepts an Option type instead + pub fn with_optional_fields(self, val: Option>) -> Self { + match val { + Some(f) => self.with_fields(f), + None => self, + } + } + /// Limit the number of returned results /// The Szurubooru API supports limiting the number of resources returned for Paginated /// API endpoints. /// - /// For example, to limit the number of pools returned by (list_pools)[SzurubooruRequest::list_pools] + /// For example, to limit the number of pools returned by [list_pools](SzurubooruRequest::list_pools) /// ```no_run /// # use szurubooru_client::SzurubooruClient; /// # #[allow(unused)] @@ -277,12 +304,20 @@ impl<'a> SzurubooruRequest<'a> { self } + /// The same as [with_limit](SzurubooruRequest::with_limit), but accepts an Option type instead + pub fn with_optional_limit(self, val: Option) -> Self { + match val { + Some(f) => self.with_limit(f), + None => self, + } + } + /// Skip a certain number of records /// The Szurubooru API supports offsetting the results returned from Paginated API /// endpoints. Use this offset in combination with the limit to page through /// large result sets. /// - /// For example, to offset the list of pools returned by (list_pools)[SzurubooruRequest::list_pools] + /// For example, to offset the list of pools returned by [list_pools](SzurubooruRequest::list_pools) /// ```no_run /// # use szurubooru_client::SzurubooruClient; /// # #[allow(unused)] @@ -300,6 +335,14 @@ impl<'a> SzurubooruRequest<'a> { self } + /// The same as [with_offset](SzurubooruRequest::with_offset), but accepts an Option type instead + pub fn with_optional_offset(self, val: Option) -> Self { + match val { + Some(f) => self.with_offset(f), + None => self, + } + } + #[doc(hidden)] fn prep_request( &self, @@ -381,12 +424,14 @@ impl<'a> SzurubooruRequest<'a> { async fn handle_response(&self, response: Response) -> SzurubooruResult { if response.status().is_client_error() || response.status().is_server_error() { + let status = response.status(); let resp_json = response .text() .await .map_err(SzurubooruClientError::RequestError)?; + let server_error = serde_json::from_str::(&resp_json) - .map_err(|e| SzurubooruClientError::ResponseParsingError(e, resp_json))?; + .map_err(|_e| SzurubooruClientError::ResponseError(status, resp_json))?; Err(SzurubooruClientError::SzurubooruServerError(server_error)) } else { Ok(response) @@ -406,13 +451,12 @@ impl<'a> SzurubooruRequest<'a> { let response = self .handle_response(response.map_err(SzurubooruClientError::RequestError)?) .await?; - //.error_for_status() - //.map_err(SzurubooruClientError::RequestError)?; let response_text = response .text() .await .map_err(SzurubooruClientError::RequestError)?; + serde_json::from_str::>(&response_text) .map_err(|e| SzurubooruClientError::ResponseParsingError(e, response_text))? .into_result() @@ -447,7 +491,7 @@ impl<'a> SzurubooruRequest<'a> { /// Updates an existing tag category using specified parameters. Name must match /// `tag_category_name_regex` from server's configuration. All fields except - /// [version](models::TagCategoryResource::version) are optional - update concerns only provided fields. + /// [version](crate::models::TagCategoryResource::version) are optional - update concerns only provided fields. pub async fn update_tag_category( &self, name: T, @@ -495,8 +539,9 @@ impl<'a> SzurubooruRequest<'a> { } /// Searches for tags. - /// See the (named tokens)[tokens::TagNamedToken] and (sort tokens)[tokens::TagSortToken] for - /// all possible query tokens, or use (QueryToken)[tokens::QueryToken] for a custom token + /// See the [named tokens](crate::tokens::TagNamedToken) and + /// [sort tokens](crate::tokens::TagSortToken) for all possible query tokens, or use + /// [QueryToken] for a custom token pub async fn list_tags( &self, query: Option<&Vec>, @@ -507,7 +552,7 @@ impl<'a> SzurubooruRequest<'a> { /// Creates a new tag using specified parameters. Names, suggestions and implications must /// match `tag_name_regex` from server's configuration. Category must exist and is the same - /// as the `name` field within (TagCategoryResource)[models::TagCategoryResource] resource. + /// as the `name` field within [TagCategoryResource] resource. /// Suggestions and implications are optional. If specified implied tags or suggested tags do /// not exist yet, they will be automatically created. Tags created automatically have no /// implications, no suggestions, one name and their category is set to the first tag category @@ -519,7 +564,7 @@ impl<'a> SzurubooruRequest<'a> { /// Updates an existing tag using specified parameters. Names, suggestions and implications must /// match `tag_name_regex` from server's configuration. Category must exist and is the same - /// as the `name` field within (TagCategoryResource)[models::TagCategoryResource] resource. + /// as the `name` field within [TagCategoryResource] resource. /// Suggestions and implications are optional. If specified implied tags or suggested tags do /// not exist yet, they will be automatically created. Tags created automatically have no /// implications, no suggestions, one name and their category is set to the first tag category @@ -562,13 +607,13 @@ impl<'a> SzurubooruRequest<'a> { /// Removes source tag and merges all of its usages, suggestions and implications to the /// target tag. Other tag properties such as category and aliases do not get transferred /// and are discarded. - pub async fn merge_tag(&self, merge_opts: &MergeTags) -> SzurubooruResult { + pub async fn merge_tags(&self, merge_opts: &MergeTags) -> SzurubooruResult { self.do_request(Method::POST, "/api/tag-merge", None, Some(merge_opts)) .await } /// Lists siblings of given tag, e.g. tags that were used in the same posts as the given tag. - /// The (occurrences)[models::TagSibling::occurrences] field signifies how many times a given + /// The [occurrences](crate::models::TagSibling::occurrences) field signifies how many times a given /// sibling appears with given tag. Results are sorted by occurrences count and the list is /// truncated to the first 50 elements. Doesn't use paging. pub async fn get_tag_siblings( @@ -584,9 +629,8 @@ impl<'a> SzurubooruRequest<'a> { } /// Searches for posts. - /// See (PostNamedToken)[tokens::PostNamedToken], (PostSortToken)[tokens::PostSortToken] and - /// (PostSpecialToken)[tokens::PostSpecialToken] for valid tokens to use with this method, or - /// use (QueryToken)[tokens::QueryToken] to construct a custom token + /// See [PostNamedToken], [PostSortToken] and [PostSpecialToken] for valid tokens to use with + /// this method, or use [QueryToken] to construct a custom token pub async fn list_posts( &self, query: Option<&Vec>, @@ -602,6 +646,11 @@ impl<'a> SzurubooruRequest<'a> { method: Method, cupost: &CreateUpdatePost, ) -> SzurubooruResult { + if method == Method::POST && cupost.safety.is_none() { + return Err(SzurubooruClientError::ValidationError( + "Safety must be set".to_string(), + )); + } self.do_request(method, path, None, Some(cupost)).await } @@ -609,7 +658,7 @@ impl<'a> SzurubooruRequest<'a> { /// the image. /// If specified tags do not exist yet, they will be automatically created. Tags created /// automatically have no implications, no suggestions, one name and their category is set to - /// the first tag category found. (safety)[models::CreateUpdatePost::safety] must be any of + /// the first tag category found. [safety](crate::models::CreateUpdatePost::safety) must be any of /// `safe`, `sketchy` or `unsafe`. /// Relations must contain valid post IDs. If `flag` is omitted, they will be defined by /// default (`"loop"` will be set for all video posts, and `"sound"` will be auto-detected). @@ -627,7 +676,7 @@ impl<'a> SzurubooruRequest<'a> { /// Update an existing post /// See [SzurubooruRequest::create_post_from_url] for more details about the fields in - /// (CreateUpdatePost)[models::CreateUpdatePost] + /// [CreateUpdatePost] pub async fn update_post( &self, post_id: u32, @@ -639,6 +688,23 @@ impl<'a> SzurubooruRequest<'a> { .map(|pr| self.propagate_urls(pr)) } + /// Update an existing post from a given URL + /// See [SzurubooruRequest::create_post_from_url] for more details about the fields in + /// [CreateUpdatePost] + pub async fn update_post_from_url( + &self, + post_id: u32, + update_post: &CreateUpdatePost, + ) -> SzurubooruResult { + assert!(update_post.content_url.is_some()); + let path = format!("/api/post/{post_id}"); + self.create_update_post_from_url(&path, Method::PUT, update_post) + .await + .map(|pr| self.propagate_urls(pr)) + } + + // Create function to upload by byte array in the future + fn part_from_file(&self, file: &mut File) -> SzurubooruResult { let mut bytes = vec![]; file.read_to_end(&mut bytes) @@ -686,7 +752,7 @@ impl<'a> SzurubooruRequest<'a> { /// Create a new post from a file handle /// See [SzurubooruRequest::create_post_from_url] for more details about the fields in - /// (CreateUpdatePost)[models::CreateUpdatePost] + /// [CreateUpdatePost] pub async fn create_post_from_file( &self, file: &mut File, @@ -711,7 +777,7 @@ impl<'a> SzurubooruRequest<'a> { /// Create a new post from a file path /// See [SzurubooruRequest::create_post_from_url] for more details about the fields in - /// (CreateUpdatePost)[models::CreateUpdatePost] + /// [CreateUpdatePost] pub async fn create_post_from_file_path( &self, file_path: impl AsRef, @@ -731,7 +797,7 @@ impl<'a> SzurubooruRequest<'a> { } /// Create a post from a token previously generated by - /// (upload_temporary_file_from_path)[SzurubooruRequest::upload_temporary_file_from_path] + /// [upload_temporary_file_from_path](SzurubooruRequest::upload_temporary_file_from_path) pub async fn create_post_from_token( &self, new_post: &CreateUpdatePost, @@ -752,7 +818,7 @@ impl<'a> SzurubooruRequest<'a> { /// Update an existing post from an open File handle /// See [SzurubooruRequest::create_post_from_url] for more details about the fields in - /// (CreateUpdatePost)[models::CreateUpdatePost] + /// [CreateUpdatePost] pub async fn update_post_from_file( &self, post_id: u32, @@ -776,7 +842,7 @@ impl<'a> SzurubooruRequest<'a> { /// Update an existing post from a file path /// See [SzurubooruRequest::create_post_from_url] for more details about the fields in - /// (CreateUpdatePost)[models::CreateUpdatePost] + /// [CreateUpdatePost] pub async fn update_post_from_file_path( &self, post_id: u32, @@ -825,6 +891,27 @@ impl<'a> SzurubooruRequest<'a> { .map(|pr| self.propagate_urls(pr)) } + /// Update a post from a token previously generated by + /// [upload_temporary_file_from_path](SzurubooruRequest::upload_temporary_file_from_path) + pub async fn update_post_from_token( + &self, + post_id: u32, + update_post: &CreateUpdatePost, + ) -> SzurubooruResult { + assert!(update_post.content_token.is_some()); + let url = format!("/api/post/{post_id}"); + self.create_update_post_from_file( + None, + None, + None::, + &url, + Method::PUT, + update_post, + ) + .await + .map(|pr| self.propagate_urls(pr)) + } + async fn get_post_content( &self, post_id: u32, @@ -874,7 +961,7 @@ impl<'a> SzurubooruRequest<'a> { .map(|cr| cr.bytes_stream()) } - ///Fetches the given post ID's image as a (Bytes)[bytes::Bytes] struct + ///Fetches the given post ID's image as a [Bytes](bytes::Bytes) struct pub async fn get_image_bytes(&self, post_id: u32) -> SzurubooruResult { let content_response = self.get_post_content(post_id, false).await?; @@ -884,7 +971,7 @@ impl<'a> SzurubooruRequest<'a> { .map_err(SzurubooruClientError::RequestError) } - ///Fetches the given post ID's thumbnail as a (Bytes)[bytes::Bytes] struct + ///Fetches the given post ID's thumbnail as a [Bytes](bytes::Bytes) struct pub async fn get_thumbnail_bytes(&self, post_id: u32) -> SzurubooruResult { let content_response = self.get_post_content(post_id, true).await?; @@ -934,7 +1021,12 @@ impl<'a> SzurubooruRequest<'a> { path: impl AsRef, ) -> SzurubooruResult<()> { let mut stream = self.get_image_bytestream(post_id).await?; - let mut file = File::open(path.as_ref()).map_err(SzurubooruClientError::IOError)?; + let mut file = File::options() + .write(true) + .truncate(true) + .create(true) + .open(path.as_ref()) + .map_err(SzurubooruClientError::IOError)?; self.write_content_to_file(&mut file, &mut stream).await } @@ -989,32 +1081,34 @@ impl<'a> SzurubooruRequest<'a> { .map(|isr| self.propagate_urls(isr)) } + // Need to add a reverse search for bytes + /// Searches for an exact match of a file based on the SHA1 checksum - pub async fn posts_for_file( + pub async fn post_for_file( &self, mut file: &mut File, - ) -> SzurubooruResult> { + ) -> SzurubooruResult> { let mut hasher = Sha1::new(); std::io::copy(&mut file, &mut hasher).map_err(SzurubooruClientError::IOError)?; let hash = hasher.finalize(); let hex_string = hex::encode(hash); let qt = QueryToken::token(PostNamedToken::ContentChecksum, hex_string); - self.list_posts(Some(&vec![qt])) + let psr = self + .list_posts(Some(&vec![qt])) .await - .map(|psr| self.propagate_urls(psr)) + .map(|psr| self.propagate_urls(psr))?; + Ok(psr.results.first().cloned()) } /// Searches for an exact match of a file path based on the SHA1 checksum - pub async fn posts_for_file_path( + pub async fn post_for_file_path( &self, file_path: impl AsRef, - ) -> SzurubooruResult> { + ) -> SzurubooruResult> { let mut file = File::open(file_path).map_err(SzurubooruClientError::IOError)?; - self.posts_for_file(&mut file) - .await - .map(|psr| self.propagate_urls(psr)) + self.post_for_file(&mut file).await } /// Retrieves information about an existing post. @@ -1043,7 +1137,7 @@ impl<'a> SzurubooruRequest<'a> { /// /// Removes source post and merges all of its tags, relations, scores, favorites and comments to - /// the target post. If [MergePost::replace_content] is set to `true`, content of the target post + /// the target post. If [MergePost::replace_post_content] is set to `true`, content of the target post /// is replaced using the content of the source post; otherwise it remains unchanged. Source /// post properties such as its safety, source, whether to loop the video and other scalar /// values do not get transferred and are discarded. @@ -1056,6 +1150,11 @@ impl<'a> SzurubooruRequest<'a> { /// Updates score of authenticated user for given post. Valid scores are -1, 0 and 1. pub async fn rate_post(&self, post_id: u32, score: i8) -> SzurubooruResult { + if !(-1..=1).contains(&score) { + return Err(SzurubooruClientError::ValidationError( + "Score must be -1, 0 or 1".to_string(), + )); + } let rating_obj = RateResource { score }; let path = format!("/api/post/{post_id}/score"); self.do_request(Method::PUT, &path, None, Some(&rating_obj)) @@ -1080,9 +1179,9 @@ impl<'a> SzurubooruRequest<'a> { } /// Retrieves the post that is currently featured on the main page in web client. If no post is - /// featured, is [Option::None]. Note that this method exists mostly for compatibility - /// with setting featured post - most of the time, you'd want to use query global info which - /// contains more information. + /// featured, the result will be [Option::None]. Note that this method exists mostly for + /// compatibility with setting featured post - most of the time, you'd want to use query global + /// info which contains more information. pub async fn get_featured_post(&self) -> SzurubooruResult> { self.do_request(Method::GET, "/api/featured-post", None, None::<&String>) .await @@ -1118,7 +1217,7 @@ impl<'a> SzurubooruRequest<'a> { /// Updates an existing tag category using specified parameters. Name must match /// `tag_category_name_regex` from server's configuration. All fields except the - /// [version](models::CreateUpdatePoolCategory::version) field are optional - update concerns + /// [version](crate::models::CreateUpdatePoolCategory::version) field are optional - update concerns /// only the provided fields. pub async fn update_pool_category( &self, @@ -1177,7 +1276,7 @@ impl<'a> SzurubooruRequest<'a> { } /// Searches for pools. - /// Anonymous tokens are the same as the [name](tokens::PoolNamedToken::Name) token + /// Anonymous tokens are the same as the [name](crate::tokens::PoolNamedToken::Name) token pub async fn list_pools( &self, query: Option<&Vec>, @@ -1189,8 +1288,8 @@ impl<'a> SzurubooruRequest<'a> { /// Creates a new pool using specified parameters. Names, suggestions and implications must /// match `pool_name_regex` from server's configuration. Category must exist and is the same as - /// [name](models::PoolCategoryResource::name) field. - /// [posts](models::CreateUpdatePool::posts) is an optional list of integer post IDs. If the + /// [name](crate::models::PoolCategoryResource::name) field. + /// [posts](crate::models::CreateUpdatePool::posts) is an optional list of integer post IDs. If the /// specified posts do not exist, an error will be thrown. pub async fn create_pool( &self, @@ -1201,14 +1300,14 @@ impl<'a> SzurubooruRequest<'a> { .map(|r| self.propagate_urls(r)) } - /// Updates an existing pool using specified parameters. [name](models::CreateUpdatePool::name), + /// Updates an existing pool using specified parameters. [names](crate::models::CreateUpdatePool::names), /// must match `pool_name_regex` from server's configuration. - /// [category](models::CreateUpdatePool::category) must exist and is the same as - /// [name](models::PoolCategoryResource::name) field. [posts](models::CreateUpdatePool::posts) + /// [category](crate::models::CreateUpdatePool::category) must exist and is the same as + /// [name](crate::models::PoolCategoryResource::name) field. [posts](crate::models::CreateUpdatePool::posts) /// is an optional list of integer post IDs. If the specified posts do not exist yet, an error /// will be thrown. The full list of post IDs must be provided if they are being updated, and /// the previous list of posts will be replaced with the new one. All fields except - /// [version](models::CreateUpdatePool::version) are optional - update concerns only provided + /// [version](crate::models::CreateUpdatePool::version) are optional - update concerns only provided /// fields. pub async fn update_pool( &self, @@ -1248,7 +1347,7 @@ impl<'a> SzurubooruRequest<'a> { } /// Searches for comments. - /// Anonymous tokens are the same as the [text](tokens::CommentNamedToken::text) token + /// Anonymous tokens are the same as the [text](crate::tokens::CommentNamedToken::Text) token pub async fn list_comments( &self, query: Option<&Vec>, @@ -1299,6 +1398,11 @@ impl<'a> SzurubooruRequest<'a> { comment_id: u32, score: i8, ) -> SzurubooruResult { + if !(-1..=1).contains(&score) { + return Err(SzurubooruClientError::ValidationError( + "Score must be -1, 0 or 1".to_string(), + )); + } let path = format!("/api/comment/{comment_id}/score"); let rating = RateResource { score }; self.do_request(Method::PUT, &path, None, Some(&rating)) @@ -1306,9 +1410,8 @@ impl<'a> SzurubooruRequest<'a> { } /// Searches for users - /// Anonymous tokens are the same as the [name](tokens::UserNamedToken) - /// See [UserNamedToken](tokens::UserNamedToken) and [UserSortToken](tokens::UserSortToken) - /// for type-safe tokens + /// Anonymous tokens are the same as the [name](crate::tokens::UserNamedToken::Name) token + /// See [UserNamedToken] and [UserSortToken] for type-safe tokens pub async fn list_users( &self, query: Option<&Vec>, @@ -1351,7 +1454,7 @@ impl<'a> SzurubooruRequest<'a> { /// Creates a new user using specified parameters. Names and passwords must match /// `user_name_regex` and `password_regex` from server's configuration, respectively. /// Email address, rank and avatar fields are optional. Avatar style can be either - /// [gravatar](models::UserAvatarStyle::Gravatar) or [manual](models::UserAvatarStyle::Manual). + /// [gravatar](crate::models::UserAvatarStyle::Gravatar) or [manual](crate::models::UserAvatarStyle::Manual). /// `manual` avatar style requires client to pass also the `avatar` file. /// If the rank is empty and the user happens to be the first user ever created, /// become an administrator, whereas subsequent users will be given the rank indicated by @@ -1362,7 +1465,7 @@ impl<'a> SzurubooruRequest<'a> { .map(|r| self.propagate_urls(r)) } - /// Create a [UserResource](models::UserResource) with the included Avatar file + /// Create a [UserResource] with the included Avatar file /// See [create_user](SzurubooruRequest::create_user) for other applicable fields and /// restrictions pub async fn create_user_with_avatar_file( @@ -1382,7 +1485,7 @@ impl<'a> SzurubooruRequest<'a> { .map(|r| self.propagate_urls(r)) } - /// Create a [UserResource](models::UserResource) with the included Avatar file path + /// Create a [UserResource] with the included Avatar file path /// See [create_user](SzurubooruRequest::create_user) for other applicable fields and /// restrictions pub async fn create_user_with_avatar_path( @@ -1406,9 +1509,9 @@ impl<'a> SzurubooruRequest<'a> { /// Updates user using specified parameters. Names and passwords must match /// `user_name_regex` and `password_regex` from server's configuration, respectively. /// Email address, rank and avatar fields are optional. Avatar style can be either - /// [gravatar](models::UserAvatarStyle::Gravatar) or [manual](models::UserAvatarStyle::Manual). + /// [gravatar](crate::models::UserAvatarStyle::Gravatar) or [manual](crate::models::UserAvatarStyle::Manual). /// `manual` avatar style requires client to pass also the `avatar` file. - /// All fields except the [version](models::CreateUpdateUser::version) are optional + /// All fields except the [version](crate::models::CreateUpdateUser::version) are optional /// - update concerns only provided fields. pub async fn update_user( &self, @@ -1424,7 +1527,7 @@ impl<'a> SzurubooruRequest<'a> { .map(|r| self.propagate_urls(r)) } - /// Update a [UserResource](models::UserResource) with the included Avatar file + /// Update a [UserResource] with the included Avatar file /// See [update_user](SzurubooruRequest::update_user) for other applicable fields and /// restrictions pub async fn update_user_with_avatar_file( @@ -1449,7 +1552,7 @@ impl<'a> SzurubooruRequest<'a> { .map(|r| self.propagate_urls(r)) } - /// Update a [UserResource](models::UserResource) with the included Avatar file path + /// Update a [UserResource] with the included Avatar file path /// See [update_user](SzurubooruRequest::update_user) for other applicable fields and /// restrictions pub async fn update_user_with_avatar_path( @@ -1516,20 +1619,20 @@ impl<'a> SzurubooruRequest<'a> { /// instead of a password. pub async fn create_user_token( &self, - name: T, + user_name: T, create_token: &CreateUpdateUserAuthToken, ) -> SzurubooruResult where T: AsRef + Display, { - let path = format!("/api/user-token/{name}"); + let path = format!("/api/user-token/{user_name}"); self.do_request(Method::POST, &path, None, Some(create_token)) .await .map(|r| self.propagate_urls(r)) } /// Updates an existing user token using specified parameters. All fields except the - /// [version](models::CreateUpdateUserAuthToken::version) are optional - update concerns only + /// [version](crate::models::CreateUpdateUserAuthToken::version) are optional - update concerns only /// provided fields. pub async fn update_user_token( &self, @@ -1547,7 +1650,7 @@ impl<'a> SzurubooruRequest<'a> { } /// Deletes an existing user token using specified parameters. All fields except the - /// [version](models::CreateUpdateUserAuthToken::version) are optional - update concerns only + /// [version](crate::models::CreateUpdateUserAuthToken::version) are optional - update concerns only /// provided fields. pub async fn delete_user_token( &self, @@ -1600,7 +1703,7 @@ impl<'a> SzurubooruRequest<'a> { } /// Lists recent resource snapshots. - /// See [SnapshotNamedToken](tokens::SnapshotNamedToken) for query tokens. + /// See [SnapshotNamedToken] for query tokens. /// There are no sort tokens. The snapshots are always sorted by creation time. pub async fn list_snapshots( &self, @@ -1611,11 +1714,11 @@ impl<'a> SzurubooruRequest<'a> { .map(|r| self.propagate_urls(r)) } - /// Retrieves simple statistics. [featured_post](models::GlobalInfo::featured_post) is - /// [None](Option::None) if there is no featured post yet. - /// [server_time](models::GlobalInfo::server_time) is pretty much the same as the Date HTTP + /// Retrieves simple statistics. [featured_post](crate::models::GlobalInfo::featured_post) is + /// [None] if there is no featured post yet. + /// [server_time](crate::models::GlobalInfo::server_time) is pretty much the same as the Date HTTP /// field, only formatted in a manner consistent with other dates. Values in config key are - /// taken directly from the server config, with the exception of privilege array keys being + /// taken directly from the server config, except for the privilege array keys being /// converted to lower camel case to match the API convention. pub async fn get_global_info(&self) -> SzurubooruResult { self.do_request(Method::GET, "/api/info", None, None::<&String>) diff --git a/szurubooru-client/src/errors.rs b/szurubooru-client/src/errors.rs index 2b01581..97f6353 100644 --- a/szurubooru-client/src/errors.rs +++ b/szurubooru-client/src/errors.rs @@ -3,7 +3,13 @@ use crate::models::SzuruEither; use base64::EncodeSliceError; +use derive_builder::UninitializedFieldError; +#[cfg(feature = "python")] +use pyo3::{create_exception, exceptions::PyException, prelude::*}; + +use reqwest::StatusCode; use serde::{Deserialize, Serialize}; +use strum_macros::AsRefStr; use thiserror::Error; use url::ParseError as UParseError; @@ -14,7 +20,7 @@ pub trait IntoClientResult { fn into_result(self) -> SzurubooruResult; } -#[derive(Debug, Error)] +#[derive(Debug, Error, AsRefStr)] /// Type that represents the various error states that can occur when interacting with /// Szurubooru pub enum SzurubooruClientError { @@ -35,6 +41,9 @@ pub enum SzurubooruClientError { /// Error occurred pas part of the request to the server #[error("Request error {0}")] RequestError(#[source] reqwest::Error), + /// Error response with a text response from the server + #[error("Response error {0}: Server reply: {1}")] + ResponseError(StatusCode, String), /// Error parsing the JSON response from the server #[error("Response Parsing error: {0}: {1}")] ResponseParsingError( @@ -47,6 +56,9 @@ pub enum SzurubooruClientError { /// Error serializing an object as JSON #[error("JSON Serialization error: {0}")] JSONSerializationError(#[source] serde_json::Error), + /// Error when validation fails for one of the Builder types + #[error("Validation error: {0}")] + ValidationError(String), /// Error occurred when reading a file #[error("IO Error: {0}")] IOError(#[source] std::io::Error), @@ -61,6 +73,27 @@ impl From for SzurubooruClientError { } } +impl From for SzurubooruClientError { + fn from(value: UninitializedFieldError) -> Self { + SzurubooruClientError::ValidationError(value.to_string()) + } +} + +#[cfg(feature = "python")] +create_exception!( + szurubooru_client, + SzuruClientError, + PyException, + "An exception that contains two pieces of information: The error kind and error details" +); + +#[cfg(feature = "python")] +impl std::convert::From for PyErr { + fn from(value: SzurubooruClientError) -> Self { + SzuruClientError::new_err((value.as_ref().to_string(), value.to_string())) + } +} + /// Type used to represent success or a failure of some kind pub type SzurubooruResult = Result; diff --git a/szurubooru-client/src/lib.rs b/szurubooru-client/src/lib.rs index e6eddf9..8b68296 100644 --- a/szurubooru-client/src/lib.rs +++ b/szurubooru-client/src/lib.rs @@ -22,7 +22,6 @@ //! ``` //! //! For all other methods for making the requests, see the documentation. - #![warn(missing_docs)] #![warn(rustdoc::missing_crate_level_docs)] @@ -34,5 +33,65 @@ pub use client::SzurubooruRequest; pub mod errors; pub use errors::SzurubooruResult; pub mod models; - pub mod tokens; + +#[cfg(feature = "python")] +#[doc(hidden)] +pub mod py; + +#[cfg(feature = "python")] +use pyo3::prelude::*; + +#[cfg(feature = "python")] +#[cfg_attr(feature = "python", pymodule)] +/// A Python wrapper around SzurubooruClient +mod szurubooru_client { + use pyo3::prelude::*; + + #[pymodule_export] + pub use crate::{ + errors::SzuruClientError, + /*models::{ + AroundPostResult, CommentResource, GlobalInfo, ImageSearchResult, + ImageSearchSimilarPost, MicroPoolResource, MicroPostResource, MicroTagResource, + MicroUserResource, NoteResource, PoolCategoryResource, PoolResource, PostResource, + PostSafety, PostType, SnapshotCreationDeletionData, SnapshotData, + SnapshotModificationData, SnapshotOperationType, SnapshotResource, + SnapshotResourceType, TagCategoryResource, TagResource, TagSibling, + UserAuthTokenResource, UserAvatarStyle, UserRank, UserResource, + }, + tokens::{ + anonymous_token, named_token, sort_token, special_token, CommentNamedToken, + CommentSortToken, PoolNamedToken, PoolSortToken, PostNamedToken, PostSortToken, + PostSpecialToken, QueryToken, SnapshotNamedToken, TagNamedToken, TagSortToken, + UserNamedToken, UserSortToken, + },*/ + py::asynchronous::PythonAsyncClient, py::synchronous::PythonSyncClient, + py::PyPagedSearchResult, + }; + + #[pymodule(name = "_tokens")] + mod tokens { + #[pymodule_export] + pub use crate::tokens::{ + anonymous_token, named_token, sort_token, special_token, CommentNamedToken, + CommentSortToken, PoolNamedToken, PoolSortToken, PostNamedToken, PostSortToken, + PostSpecialToken, QueryToken, SnapshotNamedToken, TagNamedToken, TagSortToken, + UserNamedToken, UserSortToken, + }; + } + + #[pymodule(name = "_models")] + mod models { + #[pymodule_export] + pub use crate::models::{ + AroundPostResult, CommentResource, GlobalInfo, ImageSearchResult, + ImageSearchSimilarPost, MicroPoolResource, MicroPostResource, MicroTagResource, + MicroUserResource, NoteResource, PoolCategoryResource, PoolResource, PostResource, + PostSafety, PostType, SnapshotCreationDeletionData, SnapshotData, + SnapshotModificationData, SnapshotOperationType, SnapshotResource, + SnapshotResourceType, TagCategoryResource, TagResource, TagSibling, + UserAuthTokenResource, UserAvatarStyle, UserRank, UserResource, + }; + } +} diff --git a/szurubooru-client/src/models.rs b/szurubooru-client/src/models.rs index fc318b4..a89013e 100644 --- a/szurubooru-client/src/models.rs +++ b/szurubooru-client/src/models.rs @@ -5,12 +5,18 @@ //! See [here](https://github.com/rr-/szurubooru/blob/master/doc/API.md#field-selecting) for //! more information. +use crate::errors::SzurubooruClientError; use chrono::{DateTime, Utc}; use derive_builder::Builder; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use strum_macros::AsRefStr; +#[cfg(feature = "python")] +use pyo3::prelude::*; +#[cfg(feature = "python")] +use serde_pyobject::to_pyobject; + #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(untagged)] /// Enum used to represent something that's either `Left` or `Right` @@ -39,16 +45,16 @@ impl WithBaseURL for UnpagedSearchResult { #[derive(Debug, Serialize, Deserialize)] /// A result of search operation that involves paging /// -/// Use (offset)[crate::SzurubooruRequest::offset] and (limit)[crate::SzurubooruRequest::limit] +/// Use [offset](crate::SzurubooruRequest::with_offset) and [limit](crate::SzurubooruRequest::with_limit) /// to fetch the next page pub struct PagedSearchResult { /// The original query for the request pub query: String, - /// The number of [T] to skip forward + /// The number of `T` to skip forward pub offset: u32, - /// The maximum number of [T] to return + /// The maximum number of `T` to return pub limit: u32, - /// The total number of [T] that match the [query](PagedSearchResult::query) + /// The total number of `T` that match the [query](PagedSearchResult::query) pub total: u32, /// The results themselves pub results: Vec, @@ -82,6 +88,10 @@ impl WithBaseURL for Vec { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, eq, module = "szurubooru_client.models") +)] /// A [tag resource](TagResource) stripped down to `names`, `category` and `usages` fields. pub struct MicroTagResource { /// The tag names and aliases @@ -92,6 +102,16 @@ pub struct MicroTagResource { pub usages: u32, } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl MicroTagResource { + /// Function that generates the representation string for this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] /// To prevent problems with concurrent resource modification, Szurubooru implements optimistic /// locks using resource versions. Each modifiable resource has its version returned to the client @@ -116,8 +136,12 @@ pub struct ResourceVersion { pub version: u32, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] /// A single tag. Tags are used to let users search for posts. pub struct TagResource { /// resource version. See [versioning](ResourceVersion) @@ -144,9 +168,19 @@ pub struct TagResource { pub description: Option, } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl TagResource { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + /// Creates or updates a tag using specified parameters. Names, suggestions and implications must /// match `tag_name_regex` from server's configuration. Category must exist and is the same as name -/// field within resource. Suggestions and implications are optional. If specified +/// field within [TagCategoryResource] resource. Suggestions and implications are optional. If specified /// implied tags or suggested tags do not exist yet, they will be automatically created. Tags /// created automatically have no implications, no suggestions, one name and their category is set /// to the first tag category found. If there are no tag categories established yet, an error @@ -161,7 +195,8 @@ pub struct TagResource { /// .expect("A new tag"); /// ``` #[derive(Debug, Clone, Serialize, Deserialize, Builder, Default)] -#[builder(setter(strip_option))] +//#[builder(pattern="owned")] +#[builder(setter(strip_option), build_fn(error = "SzurubooruClientError"))] pub struct CreateUpdateTag { #[serde(skip_serializing_if = "Option::is_none")] #[builder(default)] @@ -189,7 +224,11 @@ pub struct CreateUpdateTag { pub suggestions: Option>, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] /// A single tag category. The primary purpose of tag categories is to distinguish certain tag /// types (such as characters, media type etc.), which improves user experience. pub struct TagCategoryResource { @@ -207,8 +246,19 @@ pub struct TagCategoryResource { pub default: Option, } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl TagCategoryResource { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, Builder)] -#[builder(setter(strip_option))] +#[builder(setter(strip_option), build_fn(error = "SzurubooruClientError"))] + /// Used for creating or updating a Tag Category pub struct CreateUpdateTagCategory { /// Resource version. See [versioning](ResourceVersion) @@ -230,22 +280,29 @@ pub struct CreateUpdateTagCategory { } #[derive(Debug, Clone, Serialize, Deserialize, Builder)] +#[builder(setter(strip_option), build_fn(error = "SzurubooruClientError"))] #[serde(rename_all = "camelCase")] -#[builder(setter(into))] /// Removes source tag and merges all of its usages, suggestions and implications to the target tag. /// Other tag properties such as category and aliases do not get transferred and are discarded. pub struct MergeTags { /// Version of the tag to remove - pub remove_version: u32, + #[serde(rename = "removeVersion")] + pub remove_tag_version: u32, /// The name of the tag to remove - pub remove: String, + #[serde(rename = "remove")] + pub remove_tag: String, /// The version of the tag to merge TO pub merge_to_version: u32, /// The name of the tag to merge TO - pub merge_to: String, + #[serde(rename = "mergeTo")] + pub merge_to_tag: String, } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] /// Lists siblings of given tag, e.g. tags that were used in the same posts as the given tag pub struct TagSibling { /// The related tag @@ -254,7 +311,21 @@ pub struct TagSibling { pub occurrences: u32, } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl TagSibling { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, AsRefStr, Eq, PartialEq)] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// The type of post pub enum PostType { @@ -277,6 +348,10 @@ pub enum PostType { } #[derive(Debug, Clone, Serialize, Deserialize, AsRefStr, Eq, PartialEq)] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// How SFW/NSFW the post is pub enum PostSafety { @@ -284,13 +359,17 @@ pub enum PostSafety { Safe, /// Post is possibly NSFW Sketchy, - /// Alias of (Sketchy)[PostSafety::Sketchy] + /// Alias of [Sketchy](PostSafety::Sketchy) Questionable, /// Post is NSFW Unsafe, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// A post resource stripped down to `id` and `thumbnailUrl` fields. pub struct MicroPostResource { @@ -300,6 +379,16 @@ pub struct MicroPostResource { pub thumbnail_url: String, } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl MicroPostResource { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + impl WithBaseURL for MicroPostResource { fn with_base_url(self, url: &str) -> Self { if !self.thumbnail_url.contains(url) { @@ -320,6 +409,10 @@ pub(crate) struct PostId { } #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// A post resource pub struct PostResource { @@ -394,6 +487,16 @@ pub struct PostResource { pub pools: Option>, } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl PostResource { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + impl WithBaseURL for PostResource { fn with_base_url(self, url: &str) -> Self { let curl = self.content_url.map(|cu| { @@ -429,7 +532,7 @@ impl WithBaseURL for PostResource { } #[derive(Debug, Clone, Serialize, Deserialize, Builder)] -#[builder(setter(strip_option))] +#[builder(setter(strip_option), build_fn(error = "SzurubooruClientError"))] #[serde(rename_all = "camelCase")] /// A `struct` used to create or update a post. For updating purposes /// the [version](CreateUpdatePost::version) field is required @@ -445,6 +548,7 @@ pub struct CreateUpdatePost { #[serde(skip_serializing_if = "Option::is_none")] pub tags: Option>, /// Required field, represents the SFW/NSFW state of a post + #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub safety: Option, /// The origin of the post's content @@ -472,6 +576,10 @@ pub struct CreateUpdatePost { /// [upload_temporary_file](crate::SzurubooruRequest::upload_temporary_file) #[builder(default)] pub content_token: Option, + /// Upload the post anonymously + #[builder(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub anonymous: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -483,7 +591,7 @@ pub struct TemporaryFileUpload { } #[derive(Debug, Clone, Serialize, Deserialize, Builder)] -#[builder(setter(into))] +#[builder(build_fn(error = "SzurubooruClientError"))] #[serde(rename_all = "camelCase")] /// Removes source post and merges all of its tags, relations, scores, favorites and comments to /// the target post. If replaceContent is set to true, content of the target post is replaced using @@ -492,15 +600,19 @@ pub struct TemporaryFileUpload { /// and are discarded. pub struct MergePost { /// The version of the post to remove - pub remove_version: u32, + #[serde(rename = "removeVersion")] + pub remove_post_version: u32, /// The ID of the post to remove - pub remove: u32, + #[serde(rename = "remove")] + pub remove_post: u32, /// The version of the post to merge TO pub merge_to_version: u32, /// The post ID of the post to merge TO - pub merge_to: u32, + #[serde(rename = "mergeTo")] + pub merge_to_post: u32, /// Whether to replace the content - pub replace_content: bool, + #[serde(rename = "replaceContent")] + pub replace_post_content: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -510,6 +622,10 @@ pub struct RateResource { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// A text annotation rendered on top of the post pub struct NoteResource { @@ -522,7 +638,21 @@ pub struct NoteResource { pub text: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl NoteResource { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// The Rank of a given User pub enum UserRank { @@ -538,7 +668,11 @@ pub enum UserRank { Administrator, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// The kind of User Avatar pub enum UserAvatarStyle { @@ -548,49 +682,175 @@ pub enum UserAvatarStyle { Manual, } +// Because pyo3 get_all doesn't let you exclude fields we have to define the fields twice #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(all(feature = "python"), pyclass(module = "szurubooru_client.models"))] #[serde(rename_all = "camelCase")] /// A single user pub struct UserResource { /// Resource version. See [versioning](ResourceVersion) + #[cfg(feature = "python")] + #[pyo3(get)] pub version: Option, + + /// Resource version. See [versioning](ResourceVersion) + #[cfg(not(feature = "python"))] + pub version: Option, + /// The user's username + #[cfg(feature = "python")] + #[pyo3(get)] pub name: Option, + + /// The user's username + #[cfg(not(feature = "python"))] + pub name: Option, + /// The user email. It is available only if the request is authenticated by the same user, /// or the authenticated user can change the email. If it's unavailable, the server returns /// `false`. If the user hasn't specified an email, the server returns [None](Option::None) pub email: Option>, + /// The user rank, which effectively affects their privileges + #[cfg(feature = "python")] + #[pyo3(get)] pub rank: Option, - #[serde(rename = "last-login-time")] + + /// The user rank, which effectively affects their privileges + #[cfg(not(feature = "python"))] + pub rank: Option, + /// The last login time + #[cfg(feature = "python")] + #[pyo3(get)] + #[serde(rename = "last-login-time")] pub last_login_time: Option>, - #[serde(rename = "creation-time")] + + /// The last login time + #[cfg(not(feature = "python"))] + #[serde(rename = "last-login-time")] + pub last_login_time: Option>, + /// The user registration time + #[serde(rename = "creation-time")] + #[cfg(feature = "python")] + #[pyo3(get)] pub creation_time: Option>, + + /// The user registration time + #[serde(rename = "creation-time")] + #[cfg(not(feature = "python"))] + pub creation_time: Option>, + /// How to render the user avatar + #[cfg(feature = "python")] + #[pyo3(get)] pub avatar_style: Option, + + /// How to render the user avatar + #[cfg(not(feature = "python"))] + pub avatar_style: Option, + /// The URL to the avatar + #[cfg(feature = "python")] + #[pyo3(get)] pub avatar_url: Option, + + /// The URL to the avatar + #[cfg(not(feature = "python"))] + pub avatar_url: Option, + /// Number of comments + #[cfg(feature = "python")] + #[pyo3(get)] #[serde(rename = "comment-count")] pub comment_count: Option, + + /// Number of comments + #[cfg(not(feature = "python"))] + #[serde(rename = "comment-count")] + pub comment_count: Option, + /// Number of uploaded posts + #[cfg(feature = "python")] + #[pyo3(get)] #[serde(rename = "uploaded-post-count")] pub uploaded_post_count: Option, + + /// Number of uploaded posts + #[cfg(not(feature = "python"))] + #[serde(rename = "uploaded-post-count")] + pub uploaded_post_count: Option, + /// Number of liked posts. It is available only if the request is authenticated by the same /// user. If it's unavailable, the server returns `false` #[serde(rename = "liked-post-count")] pub liked_post_count: Option>, + /// Number of disliked posts. It is available only if the request is authenticated by the same /// user. If it's unavailable, the server returns `false`. #[serde(rename = "disliked-post-count")] pub disliked_post_count: Option>, + /// Number of favorited posts #[serde(rename = "favorite-post-count")] pub favorite_post_count: Option>, } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl UserResource { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } + + #[getter] + #[pyo3(name = "email")] + /// Returns this resource's email field, if the current user has permission to see it + pub fn email_py(&self) -> PyResult> { + match &self.email { + None => Ok(None), + Some(SzuruEither::Left(s)) => Ok(Some(s.to_string())), + Some(SzuruEither::Right(_)) => Ok(None), + } + } + + #[getter] + #[pyo3(name = "liked_post_count")] + /// Returns this resource's liked_post_count, if the current user has permission to see it + pub fn liked_post_count_py(&self) -> PyResult> { + match &self.liked_post_count { + None => Ok(None), + Some(SzuruEither::Left(s)) => Ok(Some(*s)), + Some(SzuruEither::Right(_)) => Ok(None), + } + } + + #[getter] + #[pyo3(name = "disliked_post_count")] + /// Returns this resource's disliked_post_count, if the current user has permission to see it + pub fn disliked_post_count_py(&self) -> PyResult> { + match &self.disliked_post_count { + None => Ok(None), + Some(SzuruEither::Left(s)) => Ok(Some(*s)), + Some(SzuruEither::Right(_)) => Ok(None), + } + } + + #[getter] + #[pyo3(name = "favorite_post_count")] + /// Returns this resource's favorite_post_count, if the current user has permission to see it + pub fn favorite_post_count_py(&self) -> PyResult> { + match &self.favorite_post_count { + None => Ok(None), + Some(SzuruEither::Left(s)) => Ok(Some(*s)), + Some(SzuruEither::Right(_)) => Ok(None), + } + } +} + impl WithBaseURL for UserResource { fn with_base_url(self, url: &str) -> Self { let av_url = self.avatar_url.map(|au| { @@ -608,7 +868,7 @@ impl WithBaseURL for UserResource { } #[derive(Debug, Clone, Serialize, Deserialize, Default, Builder)] -#[builder(setter(strip_option))] +#[builder(setter(strip_option), build_fn(error = "SzurubooruClientError"))] #[serde(rename_all = "camelCase")] /// `struct` used to create or update a user resource. The version field is only used when /// updating an existing resource @@ -637,6 +897,10 @@ pub struct CreateUpdateUser { } #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// A user resource stripped down to `name` and `avatarUrl` fields pub struct MicroUserResource { @@ -646,6 +910,16 @@ pub struct MicroUserResource { pub avatar_url: String, } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl MicroUserResource { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + impl WithBaseURL for MicroUserResource { fn with_base_url(self, url: &str) -> Self { if !self.avatar_url.contains(url) { @@ -660,6 +934,10 @@ impl WithBaseURL for MicroUserResource { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] #[serde(rename_all = "kebab-case")] /// A single user token pub struct UserAuthTokenResource { @@ -683,6 +961,16 @@ pub struct UserAuthTokenResource { pub last_usage_time: Option>, } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl UserAuthTokenResource { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + impl WithBaseURL for UserAuthTokenResource { fn with_base_url(self, url: &str) -> Self { Self { @@ -693,7 +981,7 @@ impl WithBaseURL for UserAuthTokenResource { } #[derive(Debug, Clone, Serialize, Deserialize, Builder, Default)] -#[builder(setter(into, strip_option))] +#[builder(setter(into, strip_option), build_fn(error = "SzurubooruClientError"))] #[serde(rename_all = "kebab-case")] /// `struct` to create or update a UserAuthToken. `version` is only required when updating an /// existing resource @@ -733,6 +1021,10 @@ pub struct TemporaryPassword { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// Simple server configuration pub struct GlobalInfoConfig { @@ -757,6 +1049,10 @@ pub struct GlobalInfoConfig { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// Simple server statistics pub struct GlobalInfo { @@ -776,7 +1072,21 @@ pub struct GlobalInfo { pub config: GlobalInfoConfig, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl GlobalInfo { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// A single pool category. The primary purpose of pool categories is to distinguish certain pool /// types (such as series, relations etc.), which improves user experience. @@ -793,8 +1103,18 @@ pub struct PoolCategoryResource { pub default: Option, } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl PoolCategoryResource { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Builder)] -#[builder(setter(strip_option))] +#[builder(setter(strip_option), build_fn(error = "SzurubooruClientError"))] /// `struct` used for creating or updating a pool category. This type uses a Builder pattern like /// so: /// @@ -823,6 +1143,10 @@ pub struct CreateUpdatePoolCategory { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// Type that represents a Pool resource pub struct PoolResource { @@ -847,6 +1171,16 @@ pub struct PoolResource { pub description: Option, } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl PoolResource { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + impl WithBaseURL for PoolResource { fn with_base_url(self, url: &str) -> Self { PoolResource { @@ -857,7 +1191,7 @@ impl WithBaseURL for PoolResource { } #[derive(Debug, Clone, Serialize, Deserialize, Builder, Default)] -#[builder(setter(strip_option))] +#[builder(setter(strip_option), build_fn(error = "SzurubooruClientError"))] #[serde(rename_all = "camelCase")] /// This type is used when creating or updating a pool object. It uses the builder pattern like so: /// @@ -897,6 +1231,7 @@ pub struct CreateUpdatePool { } #[derive(Debug, Clone, Serialize, Deserialize, Builder, Default)] +#[builder(build_fn(error = "SzurubooruClientError"))] #[serde(rename_all = "camelCase")] /// This type is used to specify which pools should be merged. Uses the builder pattern like so: /// @@ -904,25 +1239,32 @@ pub struct CreateUpdatePool { /// use szurubooru_client::models::MergePoolBuilder; /// // Merge pool ID 1 at version 1 to pool ID 3 at version 5 /// let merge_pool = MergePoolBuilder::default() -/// .remove_version(1) -/// .remove(1) +/// .remove_pool_version(1) +/// .remove_pool(1) /// .merge_to_version(5) -/// .merge_to(3) +/// .merge_to_pool(3) /// .build() /// .unwrap(); /// ``` pub struct MergePool { /// Version of the pool to remove. Must match the current Pool version - pub remove_version: u32, + #[serde(rename = "removeVersion")] + pub remove_pool_version: u32, /// Pool ID to remove - pub remove: u32, + #[serde(rename = "remove")] + pub remove_pool: u32, /// Version of the pool to merge TO pub merge_to_version: u32, /// Pool ID of the pool to merge TO - pub merge_to: u32, + #[serde(rename = "mergeTo")] + pub merge_to_pool: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// A micro resource representing a Pool. A subset of the fields of a [PoolResource]. pub struct MicroPoolResource { @@ -938,7 +1280,21 @@ pub struct MicroPoolResource { pub description: Option, } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl MicroPoolResource { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// A type representing a Comment on a post pub struct CommentResource { @@ -962,8 +1318,18 @@ pub struct CommentResource { pub own_score: Option, } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl CommentResource { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Builder, Default)] -#[builder(setter(strip_option))] +#[builder(setter(strip_option), build_fn(error = "SzurubooruClientError"))] #[serde(rename_all = "camelCase")] /// This type is used when creating or updating a comment. This type uses the builder pattern like /// so: @@ -992,7 +1358,11 @@ pub struct CreateUpdateComment { pub post_id: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// The kind of snapshot that has been recorded pub enum SnapshotOperationType { @@ -1006,7 +1376,11 @@ pub enum SnapshotOperationType { Merged, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// The kind of resource described by this snapshot pub enum SnapshotResourceType { @@ -1024,7 +1398,11 @@ pub enum SnapshotResourceType { PoolCategory, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase", untagged)] /// Data for a resource that was created #[allow(clippy::large_enum_variant)] @@ -1041,6 +1419,16 @@ pub enum SnapshotCreationDeletionData { PoolCategory(PoolCategoryResource), } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl SnapshotCreationDeletionData { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + impl WithBaseURL for SnapshotCreationDeletionData { fn with_base_url(self, url: &str) -> Self { match self { @@ -1055,13 +1443,25 @@ impl WithBaseURL for SnapshotCreationDeletionData { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// Data for a modified resource pub struct SnapshotModificationData { - /// The type of snapshot + #[cfg(feature = "python")] #[serde(rename = "type")] + #[pyo3(get)] + /// The type of snapshot pub snapshot_type: String, + + #[cfg(not(feature = "python"))] + #[serde(rename = "type")] + /// The type of snapshot + pub snapshot_type: String, + /// The JSON value for the modified resource. A dictionary diff that depends on the resource /// kind. /// @@ -1070,7 +1470,28 @@ pub struct SnapshotModificationData { pub value: serde_json::Value, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl SnapshotModificationData { + #[getter] + /// Get the value associated with this snapshot + pub fn get_value(&self, py: Python<'_>) -> PyResult> { + let obj = to_pyobject(py, &self.value).unwrap().unbind(); + Ok(obj) + } + + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, module = "szurubooru_client.models") +)] #[serde(untagged)] /// Type representing the data as part of a snapshot #[allow(clippy::large_enum_variant)] @@ -1095,6 +1516,10 @@ impl WithBaseURL for SnapshotData { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// Overall type representing some sort of change to a resource pub struct SnapshotResource { @@ -1113,6 +1538,16 @@ pub struct SnapshotResource { pub time: Option>, } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl SnapshotResource { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + impl WithBaseURL for SnapshotResource { fn with_base_url(self, url: &str) -> Self { SnapshotResource { @@ -1124,6 +1559,10 @@ impl WithBaseURL for SnapshotResource { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// A result when searching for similar posts to a given image pub struct ImageSearchSimilarPost { @@ -1133,6 +1572,16 @@ pub struct ImageSearchSimilarPost { pub post: PostResource, } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl ImageSearchSimilarPost { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + impl WithBaseURL for ImageSearchSimilarPost { fn with_base_url(self, url: &str) -> Self { Self { @@ -1143,6 +1592,10 @@ impl WithBaseURL for ImageSearchSimilarPost { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] #[serde(rename_all = "camelCase")] /// A type to represent the result from an Image search request pub struct ImageSearchResult { @@ -1154,6 +1607,16 @@ pub struct ImageSearchResult { pub similar_posts: Vec, } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl ImageSearchResult { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + impl WithBaseURL for ImageSearchResult { fn with_base_url(self, url: &str) -> Self { Self { @@ -1164,6 +1627,10 @@ impl WithBaseURL for ImageSearchResult { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr( + all(feature = "python"), + pyclass(get_all, module = "szurubooru_client.models") +)] /// A type that represents posts that are before or after an existing post pub struct AroundPostResult { /// A previous post, if it exists @@ -1172,6 +1639,16 @@ pub struct AroundPostResult { next: Option, } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +#[doc(hidden)] +impl AroundPostResult { + /// Generates a representative string of this resource + fn __repr__(&self) -> String { + format!("{:?}", self) + } +} + #[cfg(test)] mod tests { use crate::models::{GlobalInfo, GlobalInfoConfig, SnapshotResource, TagCategoryResource}; diff --git a/szurubooru-client/src/py/asynchronous.rs b/szurubooru-client/src/py/asynchronous.rs new file mode 100644 index 0000000..d5be925 --- /dev/null +++ b/szurubooru-client/src/py/asynchronous.rs @@ -0,0 +1,1441 @@ +use crate::models::*; +use crate::py::PyPagedSearchResult; +use crate::tokens::QueryToken; +use crate::SzurubooruClient; +use chrono::{DateTime, Utc}; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; +use std::path::PathBuf; + +#[pyclass(name = "SzurubooruAsyncClient", module = "szurubooru_client")] +/// An asynchronous client for Szurubooru +/// +/// :see: :class:`~szurubooru_client.SzurubooruSyncClient` for supported parameters +pub struct PythonAsyncClient { + client: SzurubooruClient, +} + +#[pymethods] +impl PythonAsyncClient { + #[new] + #[pyo3(signature = (host, username=None, token=None, password=None, allow_insecure=None))] + /// Creates a new instance of the Asynchornous client + /// + /// :see: :class:`~szurubooru_client.SzurubooruSyncClient` for supported parameters + pub fn new( + host: String, + username: Option, + token: Option, + password: Option, + allow_insecure: Option, + ) -> PyResult { + let allow_insecure = allow_insecure.unwrap_or(false); + + match (username, token, password) { + (Some(u), Some(t), None) => { + let client = SzurubooruClient::new_with_token(&host, &u, &t, allow_insecure)?; + Ok(PythonAsyncClient { client }) + } + (Some(u), None, Some(p)) => { + let client = SzurubooruClient::new_with_basic_auth(&host, &u, &p, allow_insecure)?; + Ok(PythonAsyncClient { client }) + } + (None, None, None) => { + let client = SzurubooruClient::new_anonymous(&host, allow_insecure)?; + Ok(PythonAsyncClient { client }) + } + _ => Err(PyRuntimeError::new_err( + "(Username and Token) or (Username and Password) must be provided", + )), + } + } + + #[pyo3(signature = (fields=None))] + /// List the available tag categories (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.list_tag_categories` for parameters and return type + pub async fn list_tag_categories( + &self, + fields: Option>, + ) -> PyResult> { + let request = self.client.with_optional_fields(fields); + request + .list_tag_categories() + .await + .map(|ltc| ltc.results) + .map_err(Into::into) + } + + #[pyo3(signature = (name, color=None, order=None, fields=None))] + /// Creates a new tag category using the specified parameters (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.create_tag_category` for parameters and return type + pub async fn create_tag_category( + &self, + name: String, + color: Option, + order: Option, + fields: Option>, + ) -> PyResult { + let mut cutagcat = CreateUpdateTagCategoryBuilder::default(); + cutagcat.name(name); + if let Some(color) = color { + cutagcat.color(color); + } + if let Some(order) = order { + cutagcat.order(order); + } + let cutagcat = cutagcat.build()?; + self.client + .with_optional_fields(fields) + .create_tag_category(&cutagcat) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (name, version, new_name=None, color=None, order=None, fields=None))] + /// Updates an existing tag category (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.update_tag_category` for parameters and return type + pub async fn update_tag_category( + &self, + name: String, + version: u32, + new_name: Option, + color: Option, + order: Option, + fields: Option>, + ) -> PyResult { + let mut cutag = CreateUpdateTagCategoryBuilder::default(); + let mut cutag = cutag.version(version); + + if let Some(name) = new_name { + cutag = cutag.name(name); + } + if let Some(color) = color { + cutag = cutag.color(color); + } + if let Some(order) = order { + cutag = cutag.order(order); + } + + let cutag = cutag.build()?; + let request = self.client.with_optional_fields(fields); + request + .update_tag_category(name, &cutag) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (name, fields=None))] + /// Fetches a tag category by name (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.get_tag_category` for parameters and return type + pub async fn get_tag_category( + &self, + name: String, + fields: Option>, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .get_tag_category(name) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (name, version))] + /// Deletes a tag category (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.delete_tag_category` for parameters and return type + pub async fn delete_tag_category(&self, name: String, version: u32) -> PyResult<()> { + self.client + .request() + .delete_tag_category(name, version) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (name))] + /// Sets the default tag category for the site (async version) + pub async fn set_default_tag_category(&self, name: String) -> PyResult<()> { + self.client + .request() + .set_default_tag_category(name) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (query=None, fields=None, limit=None, offset=None))] + /// List the tags currently available on the site (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.list_tags` for parameters and return type + pub async fn list_tags( + &self, + query: Option>, + fields: Option>, + limit: Option, + offset: Option, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .with_optional_limit(limit) + .with_optional_offset(offset) + .list_tags(query.as_ref()) + .await + .map_err(Into::into) + .map(Into::into) + } + + #[pyo3(signature = (names, category=None, description=None, implications=None, suggestions=None, fields=None))] + /// Creating a new tag (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.create_tag` for parameters and return type + pub async fn create_tag( + &self, + names: Py, + category: Option, + description: Option, + implications: Option>, + suggestions: Option>, + fields: Option>, + ) -> PyResult { + let mut cubuild = CreateUpdateTagBuilder::default(); + Python::with_gil(|py| { + if let Ok(name) = names.extract::(py) { + Ok(cubuild.names(vec![name])) + } else { + let list_res = names.extract::>(py); + if let Ok(names) = list_res { + Ok(cubuild.names(names)) + } else { + Err(list_res.err().unwrap()) + } + } + })?; + //cubuild.names(names); + if let Some(cat) = category { + cubuild.category(cat); + } + if let Some(desc) = description { + cubuild.description(desc); + } + if let Some(imps) = implications { + cubuild.implications(imps); + } + if let Some(s) = suggestions { + cubuild.suggestions(s); + } + let tag_build = cubuild.build()?; + self.client + .with_optional_fields(fields) + .create_tag(&tag_build) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (name, version, names=None, category=None, description=None, implications=None, suggestions=None, fields=None))] + #[allow(clippy::too_many_arguments)] + /// Updates an existing tag (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.update_tag` for parameters and return type + pub async fn update_tag( + &self, + name: String, + version: u32, + names: Option>, + category: Option, + description: Option, + implications: Option>, + suggestions: Option>, + fields: Option>, + ) -> PyResult { + let mut cubuild = CreateUpdateTagBuilder::default(); + cubuild.version(version); + if let Some(names) = names { + Python::with_gil(|py| { + if let Ok(name) = names.extract::(py) { + Ok(cubuild.names(vec![name])) + } else { + let list_res = names.extract::>(py); + if let Ok(names) = list_res { + Ok(cubuild.names(names)) + } else { + Err(list_res.err().unwrap()) + } + } + })?; + } + if let Some(cat) = category { + cubuild.category(cat); + } + if let Some(desc) = description { + cubuild.description(desc); + } + if let Some(imps) = implications { + cubuild.implications(imps); + } + if let Some(s) = suggestions { + cubuild.suggestions(s); + } + let tag_build = cubuild.build()?; + self.client + .with_optional_fields(fields) + .update_tag(name, &tag_build) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (name, fields=None))] + /// Fetches an existing tag (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.get_tag` for parameters and return type + pub async fn get_tag( + &self, + name: String, + fields: Option>, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .get_tag(name) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (name, version))] + /// Deletes an existing tag (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.delete_tag` for parameters and return type + pub async fn delete_tag(&self, name: String, version: u32) -> PyResult<()> { + self.client + .request() + .delete_tag(name, version) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (remove_tag, remove_tag_version, merge_to_tag, merge_to_version, fields=None))] + /// Removes source tag and merges all of its usages, suggestions and implications to the + /// target tag. (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.merge_tags` for parameters and return type + pub async fn merge_tags( + &self, + remove_tag: String, + remove_tag_version: u32, + merge_to_tag: String, + merge_to_version: u32, + fields: Option>, + ) -> PyResult { + let mtags = MergeTagsBuilder::default() + .remove_tag_version(remove_tag_version) + .remove_tag(remove_tag) + .merge_to_version(merge_to_version) + .merge_to_tag(merge_to_tag) + .build()?; + self.client + .with_optional_fields(fields) + .merge_tags(&mtags) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (name))] + /// Lists siblings of given tag, e.g. tags that were used in the same posts as the given tag. + /// (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.get_tag_siblings` for parameters and return type + pub async fn get_tag_siblings(&self, name: String) -> PyResult> { + self.client + .request() + .get_tag_siblings(name) + .await + .map(|ts| ts.results) + .map_err(Into::into) + } + + #[pyo3(signature = (query=None, fields=None, limit=None, offset=None))] + /// Lists the posts currently available on the site (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.list_posts` for parameters and return type + pub async fn list_posts( + &self, + query: Option>, + fields: Option>, + limit: Option, + offset: Option, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .with_optional_limit(limit) + .with_optional_offset(offset) + .list_posts(query.as_ref()) + .await + .map_err(Into::into) + .map(Into::into) + } + + #[pyo3(signature = (url=None, upload_token=None, file_path=None, thumbnail_path=None, tags=None, safety=None, source=None, + relations=None, notes=None, flags=None, anonymous=None, fields=None))] + #[allow(clippy::too_many_arguments)] + /// Create a new post using one of three image sources (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.create_post` for parameters and return type + pub async fn create_post( + &self, + url: Option, + upload_token: Option, + file_path: Option, + thumbnail_path: Option, + tags: Option>, + safety: Option, + source: Option, + relations: Option>, + notes: Option>, + flags: Option>, + anonymous: Option, + fields: Option>, + ) -> PyResult { + let mut cupost = CreateUpdatePostBuilder::default(); + if let Some(source) = source { + cupost.source(source); + } + if let Some(tags) = tags { + cupost.tags(tags); + } + if let Some(safety) = safety { + cupost.safety(safety); + } + if let Some(relations) = relations { + cupost.relations(relations); + } + if let Some(notes) = notes { + cupost.notes(notes); + } + if let Some(flags) = flags { + cupost.flags(flags); + } + if let Some(anonymous) = anonymous { + cupost.anonymous(anonymous); + } + + if let Some(token) = upload_token { + cupost.content_token(token); + let cupost = cupost.build()?; + self.client + .with_optional_fields(fields) + .create_post_from_token(&cupost) + .await + .map_err(Into::into) + } else if let Some(url) = url { + cupost.content_url(url); + let cupost = cupost.build()?; + self.client + .with_optional_fields(fields) + .create_post_from_url(&cupost) + .await + .map_err(Into::into) + } else if let Some(file) = file_path { + let cupost = cupost.build()?; + self.client + .with_optional_fields(fields) + .create_post_from_file_path(file, thumbnail_path, &cupost) + .await + .map_err(Into::into) + } else { + Err(PyRuntimeError::new_err( + "One of url, token or file must be specified", + )) + } + } + + #[pyo3(signature = (post_id, post_version, url=None, token=None, file_path=None, + thumbnail_path=None, tags=None, safety=None, source=None, relations=None, notes=None, + flags=None, fields=None))] + #[allow(clippy::too_many_arguments)] + /// Updates an existing post (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.update_post` for parameters and return type + pub async fn update_post( + &self, + post_id: u32, + post_version: u32, + url: Option, + token: Option, + file_path: Option, + thumbnail_path: Option, + tags: Option>, + safety: Option, + source: Option, + relations: Option>, + notes: Option>, + flags: Option>, + fields: Option>, + ) -> PyResult { + let mut cupost = CreateUpdatePostBuilder::default(); + cupost.version(post_version); + if let Some(source) = source { + cupost.source(source); + } + if let Some(tags) = tags { + cupost.tags(tags); + } + if let Some(safety) = safety { + cupost.safety(safety); + } + if let Some(relations) = relations { + cupost.relations(relations); + } + if let Some(notes) = notes { + cupost.notes(notes); + } + if let Some(flags) = flags { + cupost.flags(flags); + } + + if let Some(token) = token { + cupost.content_token(token); + let cupost = cupost.build()?; + self.client + .with_optional_fields(fields) + .update_post_from_token(post_id, &cupost) + .await + .map_err(Into::into) + } else if let Some(url) = url { + cupost.content_url(url); + let cupost = cupost.build()?; + self.client + .with_optional_fields(fields) + .update_post_from_url(post_id, &cupost) + .await + .map_err(Into::into) + } else if file_path.is_some() || thumbnail_path.is_some() { + let cupost = cupost.build()?; + self.client + .with_optional_fields(fields) + .update_post_from_file_path(post_id, file_path, thumbnail_path, &cupost) + .await + .map_err(Into::into) + } else { + let cupost = cupost.build()?; + self.client + .with_optional_fields(fields) + .update_post(post_id, &cupost) + .await + .map_err(Into::into) + } + } + + #[pyo3(signature = (post_id))] + /// Downloads the given post's image as a byte array (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.get_image_bytes` for parameters and return type + pub async fn get_image_bytes(&self, post_id: u32) -> PyResult> { + let bytes = self + .client + .request() + .get_image_bytes(post_id) + .await? + .to_vec(); + Ok(bytes) + } + + #[pyo3(signature = (post_id, file_path))] + /// Downloads the given post's image to a path on the filesystem + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.download_image_to_path` for parameters and return type + pub async fn download_image_to_path(&self, post_id: u32, file_path: PathBuf) -> PyResult<()> { + self.client + .request() + .download_image_to_path(post_id, file_path) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (post_id))] + /// Downloads the given post's thumbnail as a byte array + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.get_thumbnail_bytes` for parameters and return type + pub async fn get_thumbnail_bytes<'py>(&self, post_id: u32) -> PyResult> { + let bytes = self + .client + .request() + .get_thumbnail_bytes(post_id) + .await? + .to_vec(); + Ok(bytes) + } + + #[pyo3(signature = (post_id, file_path))] + /// Downloads the given post's thumbnail to a path on the filesystem + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.` for parameters and return type + pub async fn download_thumbnail_to_path( + &self, + post_id: u32, + file_path: PathBuf, + ) -> PyResult<()> { + self.client + .request() + .download_thumbnail_to_path(post_id, file_path) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (image_path))] + /// Reverse image searches for an image from the filesystem (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.reverse_image_search` for parameters and return type + pub async fn reverse_image_search(&self, image_path: PathBuf) -> PyResult { + self.client + .request() + .reverse_search_file_path(image_path) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (image_path))] + /// Searches for an *exact* image match of an image from the filesystem (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.post_for_image` for parameters and return type + pub async fn post_for_image(&self, image_path: PathBuf) -> PyResult> { + self.client + .request() + .post_for_file_path(image_path) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (post_id, fields=None))] + /// Fetches an individual post by its post ID (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.get_post` for parameters and return type + pub async fn get_post( + &self, + post_id: u32, + fields: Option>, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .get_post(post_id) + .await + .map_err(Into::into) + } + + /// Fetches posts from *around* the given post ID. That means the post before and after, + // if they exist. (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.get_around_post` for parameters and return type + pub async fn get_around_post(&self, post_id: u32) -> PyResult { + self.client + .request() + .get_around_post(post_id) + .await + .map_err(Into::into) + } + + /// Deletes a post by its ID (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.delete_post` for parameters and return type + pub async fn delete_post(&self, post_id: u32, version: u32) -> PyResult<()> { + self.client + .request() + .delete_post(post_id, version) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (remove_post, remove_post_version, merge_to_post, + merge_to_version, replace_post_content=false, fields=None))] + /// Removes source post and merges all of its tags, relations, scores, favorites and comments to + /// the target post (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.merge_post` for parameters and return type + pub async fn merge_post( + &self, + remove_post: u32, + remove_post_version: u32, + merge_to_post: u32, + merge_to_version: u32, + replace_post_content: bool, + fields: Option>, + ) -> PyResult { + let mpost = MergePostBuilder::default() + .remove_post_version(remove_post_version) + .remove_post(remove_post) + .merge_to_version(merge_to_version) + .merge_to_post(merge_to_post) + .replace_post_content(replace_post_content) + .build()?; + self.client + .with_optional_fields(fields) + .merge_post(&mpost) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (post_id, rating, fields=None))] + /// Updates score of authenticated user for given post. Valid scores are -1, 0 and 1. + /// (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.rate_post` for parameters and return type + pub async fn rate_post( + &self, + post_id: u32, + rating: i8, + fields: Option>, + ) -> PyResult { + if !(-1..=1).contains(&rating) { + Err(PyValueError::new_err("Rating must be -1, 0, or 1")) + } else { + self.client + .with_optional_fields(fields) + .rate_post(post_id, rating) + .await + .map_err(Into::into) + } + } + + #[pyo3(signature = (post_id, fields=None))] + /// Marks the post as favorite for the current user (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.favorite_post` for parameters and return type + pub async fn favorite_post( + &self, + post_id: u32, + fields: Option>, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .favorite_post(post_id) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (post_id, fields=None))] + /// Unmarks the post as favorite for the current user (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.unfavorite_post` for parameters and return type + pub async fn unfavorite_post( + &self, + post_id: u32, + fields: Option>, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .unfavorite_post(post_id) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (fields=None))] + /// Retrieves the post that is currently featured on the main page (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.get_featured_post` for parameters and return type + pub async fn get_featured_post( + &self, + fields: Option>, + ) -> PyResult> { + self.client + .with_optional_fields(fields) + .get_featured_post() + .await + .map_err(Into::into) + } + + #[pyo3(signature = (post_id, fields=None))] + /// Features a post on the main page (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.set_featured_post` for parameters and return type + pub async fn set_featured_post( + &self, + post_id: u32, + fields: Option>, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .set_featured_post(post_id) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (fields=None))] + /// Lists all pool categories (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.list_pool_categories` for parameters and return type + pub async fn list_pool_categories( + &self, + fields: Option>, + ) -> PyResult> { + self.client + .with_optional_fields(fields) + .list_pool_categories() + .await + .map_err(Into::into) + .map(|pc| pc.results) + } + + #[pyo3(signature = (name, color=None, fields=None))] + /// Creates a new pool category using specified parameters (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.create_pool_category` for parameters and return type + pub async fn create_pool_category( + &self, + name: String, + color: Option, + fields: Option>, + ) -> PyResult { + let mut pc = CreateUpdatePoolCategoryBuilder::default(); + pc.name(name); + if let Some(color) = color { + pc.color(color); + } + let pc = pc.build()?; + self.client + .with_optional_fields(fields) + .create_pool_category(&pc) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (name, version, new_name=None, color=None, fields=None))] + /// Updates an existing tag category using specified parameters (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.update_pool_category` for parameters and return type + pub async fn update_pool_category( + &self, + name: String, + version: u32, + new_name: Option, + color: Option, + fields: Option>, + ) -> PyResult { + let mut pc = CreateUpdatePoolCategoryBuilder::default(); + pc.version(version); + if let Some(name) = new_name { + pc.name(name); + } + if let Some(color) = color { + pc.color(color); + } + let pc = pc.build()?; + self.client + .with_optional_fields(fields) + .update_pool_category(name, &pc) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (name, fields=None))] + /// Fetches an existing pool category (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.get_pool_category` for parameters and return type + pub async fn get_pool_category( + &self, + name: String, + fields: Option>, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .get_pool_category(name) + .await + .map_err(Into::into) + } + + /// Deletes existing pool category (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.delete_pool_category` for parameters and return type + pub async fn delete_pool_category(&self, name: String, version: u32) -> PyResult<()> { + self.client + .request() + .delete_pool_category(name, version) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (name, fields=None))] + /// Sets given pool category as default (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.set_default_pool_category` for parameters and return type + pub async fn set_default_pool_category( + &self, + name: String, + fields: Option>, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .set_default_pool_category(name) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (query=None, fields=None, limit=None, offset=None))] + /// List the post pools currently available on the site (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.list_pools` for parameters and return type + pub async fn list_pools( + &self, + query: Option>, + fields: Option>, + limit: Option, + offset: Option, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .with_optional_limit(limit) + .with_optional_offset(offset) + .list_pools(query.as_ref()) + .await + .map_err(Into::into) + .map(Into::into) + } + + #[pyo3(signature = (names, category=None, description=None, posts=None, fields=None))] + /// Creates a new pool using specified parameters (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.create_pool` for parameters and return type + pub async fn create_pool<'py>( + &self, + names: Py, + category: Option, + description: Option, + posts: Option>, + fields: Option>, + ) -> PyResult { + let mut cupool = CreateUpdatePoolBuilder::default(); + Python::with_gil(|py| { + if let Ok(name) = names.extract::(py) { + Ok(cupool.names(vec![name])) + } else { + let list_res = names.extract::>(py); + if let Ok(names) = list_res { + Ok(cupool.names(names)) + } else { + Err(list_res.err().unwrap()) + } + } + })?; + //cupool.names(names); + if let Some(cat) = category { + cupool.category(cat); + } + if let Some(desc) = description { + cupool.description(desc); + } + if let Some(posts) = posts { + cupool.posts(posts); + } + let cupool = cupool.build()?; + self.client + .with_optional_fields(fields) + .create_pool(&cupool) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (pool_id, version, new_names=None, category=None, description=None, + posts=None, fields=None))] + #[allow(clippy::too_many_arguments)] + /// Updates an existing pool using specified parameters (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.update_pool` for parameters and return type + pub async fn update_pool( + &self, + pool_id: u32, + version: u32, + new_names: Option>, + category: Option, + description: Option, + posts: Option>, + fields: Option>, + ) -> PyResult { + let mut cupool = CreateUpdatePoolBuilder::default(); + cupool.version(version); + if let Some(names) = new_names { + cupool.names(names); + } + + if let Some(cat) = category { + cupool.category(cat); + } + if let Some(desc) = description { + cupool.description(desc); + } + if let Some(posts) = posts { + cupool.posts(posts); + } + let cupool = cupool.build()?; + self.client + .with_optional_fields(fields) + .update_pool(pool_id, &cupool) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (pool_id, fields=None))] + /// Retrieves information about an existing pool (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.get_pool` for parameters and return type + pub async fn get_pool( + &self, + pool_id: u32, + fields: Option>, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .get_pool(pool_id) + .await + .map_err(Into::into) + } + + /// Deletes existing pool (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.delete_pool` for parameters and return type + pub async fn delete_pool(&self, pool_id: u32, version: u32) -> PyResult<()> { + self.client + .request() + .delete_pool(pool_id, version) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (remove_pool, remove_pool_version, merge_to_pool, merge_to_version, fields=None))] + /// Removes source pool and merges all of its posts with the target pool. (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.merge_pools` for parameters and return type + pub async fn merge_pools( + &self, + remove_pool: u32, + remove_pool_version: u32, + merge_to_pool: u32, + merge_to_version: u32, + fields: Option>, + ) -> PyResult { + let mpool = MergePoolBuilder::default() + .remove_pool_version(remove_pool_version) + .remove_pool(remove_pool) + .merge_to_version(merge_to_version) + .merge_to_pool(merge_to_pool) + .build()?; + self.client + .with_optional_fields(fields) + .merge_pools(&mpool) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (query=None, fields=None, limit=None, offset=None))] + /// List the comments currently available on the site (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.list_comments` for parameters and return type + pub async fn list_comments( + &self, + query: Option>, + fields: Option>, + limit: Option, + offset: Option, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .with_optional_limit(limit) + .with_optional_offset(offset) + .list_comments(query.as_ref()) + .await + .map_err(Into::into) + .map(Into::into) + } + + #[pyo3(signature = (text, post_id, fields=None))] + /// Creates a new comment under a given post (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.create_comment` for parameters and return type + pub async fn create_comment( + &self, + text: String, + post_id: u32, + fields: Option>, + ) -> PyResult { + let mut cucomment = CreateUpdateCommentBuilder::default(); + cucomment.post_id(post_id); + cucomment.text(text); + + let cucomment = cucomment.build()?; + self.client + .with_optional_fields(fields) + .create_comment(&cucomment) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (comment_id, version, text, fields=None))] + /// Updates an existing comment with new text (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.update_comment` for parameters and return type + pub async fn update_comment( + &self, + comment_id: u32, + version: u32, + text: String, + fields: Option>, + ) -> PyResult { + let mut cucomment = CreateUpdateCommentBuilder::default(); + cucomment.version(version); + cucomment.text(text); + + let cucomment = cucomment.build()?; + self.client + .with_optional_fields(fields) + .update_comment(comment_id, &cucomment) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (comment_id, fields=None))] + /// Fetches an existing comment (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.get_comment` for parameters and return type + pub async fn get_comment( + &self, + comment_id: u32, + fields: Option>, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .get_comment(comment_id) + .await + .map_err(Into::into) + } + + /// Deletes an existing comment (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.delete_comment` for parameters and return type + pub async fn delete_comment(&self, comment_id: u32, version: u32) -> PyResult<()> { + self.client + .request() + .delete_comment(comment_id, version) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (comment_id, rating, fields=None))] + /// Updates score of authenticated user for given comment. Valid scores are -1, 0 and 1. + /// (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.rate_comment` for parameters and return type + pub async fn rate_comment( + &self, + comment_id: u32, + rating: i8, + fields: Option>, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .rate_comment(comment_id, rating) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (query=None, fields=None, limit=None, offset=None))] + /// List the users currently registered on the site (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.list_users` for parameters and return type + pub async fn list_users( + &self, + query: Option>, + fields: Option>, + limit: Option, + offset: Option, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .with_optional_limit(limit) + .with_optional_offset(offset) + .list_users(query.as_ref()) + .await + .map_err(Into::into) + .map(Into::into) + } + + #[pyo3(signature = (name, password, rank=None, avatar_path=None, fields=None))] + /// Creates a new user using specified parameters (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.create_user` for parameters and return type + pub async fn create_user( + &self, + name: String, + password: String, + rank: Option, + avatar_path: Option, + fields: Option>, + ) -> PyResult { + let mut cuser = CreateUpdateUserBuilder::default(); + cuser.name(name); + cuser.password(password); + if let Some(rank) = rank { + cuser.rank(rank); + } + if let Some(avatar_path) = avatar_path { + cuser.avatar_style(UserAvatarStyle::Manual); + let cuser = cuser.build()?; + self.client + .with_optional_fields(fields) + .create_user_with_avatar_path(avatar_path, &cuser) + .await + .map_err(Into::into) + } else { + cuser.avatar_style(UserAvatarStyle::Gravatar); + let cuser = cuser.build()?; + self.client + .with_optional_fields(fields) + .create_user(&cuser) + .await + .map_err(Into::into) + } + } + + #[pyo3(signature = (name, version, new_name=None, password=None, rank=None, avatar_path=None, fields=None))] + #[allow(clippy::too_many_arguments)] + /// Updates an existing user using specified parameters (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.update` for parameters and return type + pub async fn update_user( + &self, + name: String, + version: u32, + new_name: Option, + password: Option, + rank: Option, + avatar_path: Option, + fields: Option>, + ) -> PyResult { + let mut cuser = CreateUpdateUserBuilder::default(); + cuser.version(version); + if let Some(new_name) = new_name { + cuser.name(new_name); + } + if let Some(password) = password { + cuser.password(password); + } + if let Some(rank) = rank { + cuser.rank(rank); + } + if let Some(avatar_path) = avatar_path { + cuser.avatar_style(UserAvatarStyle::Manual); + let cuser = cuser.build()?; + self.client + .with_optional_fields(fields) + .update_user_with_avatar_path(name, avatar_path, &cuser) + .await + .map_err(Into::into) + } else { + cuser.avatar_style(UserAvatarStyle::Gravatar); + let cuser = cuser.build()?; + self.client + .with_optional_fields(fields) + .update_user(name, &cuser) + .await + .map_err(Into::into) + } + } + + #[pyo3(signature = (user_name, fields=None))] + /// Retrieves information about an existing user (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.get_user` for parameters and return type + pub async fn get_user( + &self, + user_name: String, + fields: Option>, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .get_user(user_name) + .await + .map_err(Into::into) + } + + /// Deletes an existing user (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.delete_user` for parameters and return type + pub async fn delete_user(&self, user_name: String, version: u32) -> PyResult<()> { + self.client + .request() + .delete_user(user_name, version) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (user_name, fields=None))] + /// Fetches a list of the given user's auth tokens (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.list_user_tokens` for parameters and return type + pub async fn list_user_tokens( + &self, + user_name: String, + fields: Option>, + ) -> PyResult> { + self.client + .with_optional_fields(fields) + .list_user_tokens(user_name) + .await + .map_err(Into::into) + .map(|ur| ur.results) + } + + #[pyo3(signature = (user_name, note=None, enabled=None, expiration_time=None, fields=None))] + /// Creates an auth token for the given user (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.create_user_token` for parameters and return type + pub async fn create_user_token( + &self, + user_name: String, + note: Option, + enabled: Option, + expiration_time: Option>, + fields: Option>, + ) -> PyResult { + let mut cutoken = CreateUpdateUserAuthTokenBuilder::default(); + if let Some(note) = note { + cutoken.note(note); + } + if let Some(etime) = expiration_time { + cutoken.expiration_time(etime); + } + if let Some(enabled) = enabled { + cutoken.enabled(enabled); + } + let cutoken = cutoken.build()?; + self.client + .with_optional_fields(fields) + .create_user_token(user_name, &cutoken) + .await + .map_err(Into::into) + } + + #[pyo3(signature = (user_name, token, version, enabled=None, note=None, expiration_time=None, fields=None))] + #[allow(clippy::too_many_arguments)] + /// Update a user's existing auth token (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.update_user_token` for parameters and return type + pub async fn update_user_token( + &self, + user_name: String, + token: String, + version: u32, + enabled: Option, + note: Option, + expiration_time: Option>, + fields: Option>, + ) -> PyResult { + let mut cutoken = CreateUpdateUserAuthTokenBuilder::default(); + cutoken.version(version); + if let Some(enabled) = enabled { + cutoken.enabled(enabled); + } + if let Some(note) = note { + cutoken.note(note); + } + if let Some(etime) = expiration_time { + cutoken.expiration_time(etime); + } + let cutoken = cutoken.build()?; + self.client + .with_optional_fields(fields) + .update_user_token(user_name, token, &cutoken) + .await + .map_err(Into::into) + } + + /// Deletes an existing user auth token (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.delete_user_token` for parameters and return type + pub async fn delete_user_token( + &self, + user_name: String, + token: String, + version: u32, + ) -> PyResult<()> { + self.client + .request() + .delete_user_token(user_name, token, version) + .await + .map_err(Into::into) + } + + /// Start a password reset request (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.password_reset_request` for parameters and return type + pub async fn password_reset_request(&self, email_or_name: String) -> PyResult<()> { + self.client + .request() + .password_reset_request(email_or_name) + .await + .map_err(Into::into) + } + + /// Confirm a password reset request (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.password_reset_confirm` for parameters and return type + pub async fn password_reset_confirm( + &self, + email_or_name: String, + reset_token: String, + ) -> PyResult { + self.client + .request() + .password_reset_confirm(email_or_name, reset_token) + .await + .map_err(Into::into) + .map(|tp| tp.password) + } + + #[pyo3(signature = (query=None, fields=None, limit=None, offset=None))] + /// List the snapshots currently available on the site (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.list_snapshots` for parameters and return type + pub async fn list_snapshots( + &self, + query: Option>, + fields: Option>, + limit: Option, + offset: Option, + ) -> PyResult { + self.client + .with_optional_fields(fields) + .with_optional_limit(limit) + .with_optional_offset(offset) + .list_snapshots(query.as_ref()) + .await + .map_err(Into::into) + .map(Into::into) + } + + /// Retrieves simple statistics. ``featured_post`` is ``None`` if there is no featured post yet. + /// ``server_time`` is pretty much the same as the Date HTTP + /// field, only formatted in a manner consistent with other dates. Values in config key are + /// taken directly from the server config, with the exception of privilege array keys being + /// converted to lower camel case to match the API convention. + /// + /// (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.global_info` for parameters and return type + pub async fn global_info(&self) -> PyResult { + self.client + .request() + .get_global_info() + .await + .map_err(Into::into) + } + + /// Puts a file from a given file path in temporary storage and assigns it a token that can be + /// used in other requests. (async version) + /// + /// :see: :func:`~szurubooru_client.SzurubooruSyncClient.upload_temporary_file` for parameters and return type + pub async fn upload_temporary_file(&self, file_path: PathBuf) -> PyResult { + self.client + .request() + .upload_temporary_file_from_path(file_path) + .await + .map_err(Into::into) + .map(|t| t.token) + } +} diff --git a/szurubooru-client/src/py/mod.rs b/szurubooru-client/src/py/mod.rs new file mode 100644 index 0000000..658289b --- /dev/null +++ b/szurubooru-client/src/py/mod.rs @@ -0,0 +1,54 @@ +use crate::models::PagedSearchResult; +use pyo3::prelude::*; +use pyo3::types::PyList; + +// rustfmt likes to break the Python docstrings +#[rustfmt::skip] +pub mod asynchronous; +#[rustfmt::skip] +pub mod synchronous; + +#[derive(Debug)] +#[pyclass(name = "PagedResult", get_all, module = "szurubooru_client")] +/// A paged result generated by most of the ``list`` methods of the Szurubooru clients +pub struct PyPagedSearchResult { + /// The query string that was used to generate these results + pub query: String, + /// The offset for the request, how many resource to skip before returning the results + pub offset: u32, + /// The maximum number of results to return + pub limit: u32, + /// The total number of results generated by the query + pub total: u32, + /// The results themselves + pub results: Py, +} + +#[cfg_attr(all(feature = "python"), pymethods)] +impl PyPagedSearchResult { + fn __repr__(&self) -> String { + format!("{:?}", self) + } + + /*fn __len__(&self) -> PyResult { + Python::with_gil(|py| { + Ok(self.results.bind_borrowed(py).len()) + }) + }*/ +} + +impl> From> for PyPagedSearchResult { + fn from(value: PagedSearchResult) -> Self { + Python::with_gil(|py| { + let list = + PyList::new_bound(py, value.results.into_iter().map(|v| v.into_py(py))).unbind(); + PyPagedSearchResult { + query: value.query, + offset: value.offset, + limit: value.limit, + total: value.total, + results: list, + } + }) + } +} diff --git a/szurubooru-client/src/py/synchronous.rs b/szurubooru-client/src/py/synchronous.rs new file mode 100644 index 0000000..feb5b66 --- /dev/null +++ b/szurubooru-client/src/py/synchronous.rs @@ -0,0 +1,1565 @@ +use crate::models::*; +use crate::py::asynchronous::PythonAsyncClient; +use crate::py::PyPagedSearchResult; +use crate::tokens::QueryToken; +use chrono::{DateTime, Utc}; +use pyo3::prelude::*; +use std::path::PathBuf; +use tokio::runtime::{Builder, Runtime}; + +#[pyclass(name = "SzurubooruSyncClient", module = "szurubooru_client")] +/// Constructor for the SzurubooruSyncClient +/// This client is completely synchronous. For the ``asyncio`` compatible version, +/// see :class:`SzurubooruAsyncClient` +/// +/// :param str host: Base host URL for the Szurubooru instance. Should be the protocol, hostname and any port E.g ``http://localhost:9801`` +/// :param str username: The username used to authenticate against the Szurubooru instance. Leave blank for anonymous authentication +/// :param str password: The password to use for ``Basic`` authentication. Token authentication should be preferred +/// :param str token: The token to use for ``Bearer`` authentication. +/// :param bool allow_insecure: Disable cert validation. Disables SSL authentication +/// +/// :rtype: SzurubooruSyncClient +pub struct PythonSyncClient { + client: PythonAsyncClient, + runtime: Runtime, +} + +#[pymethods] +impl PythonSyncClient { + #[new] + #[pyo3(signature = (host, username=None, token=None, password=None, allow_insecure=None))] + /// This method is for creating new instances of the SzurubooruSyncClient + pub fn new( + host: String, + username: Option, + token: Option, + password: Option, + allow_insecure: Option, + ) -> PyResult { + let runtime = Builder::new_current_thread().enable_all().build()?; + let client = PythonAsyncClient::new(host, username, token, password, allow_insecure)?; + Ok(Self { client, runtime }) + } + + #[pyo3(signature = (fields=None))] + /// List the available tag categories + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// :return: A ``list`` of Tag Category resources + /// :rtype: list[TagCategoryResource] + pub fn list_tag_categories( + &self, + fields: Option>, + ) -> PyResult> { + self.runtime + .block_on(self.client.list_tag_categories(fields)) + } + + #[pyo3(signature = (name, color=None, order=None, fields=None))] + /// Creates a new tag category using the specified parameters. + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param str name: The tag category name, must match the server's ``tag_category_name_regex`` + /// :param Optional[str] color: The color name for this tag category + /// :param Optional[str] order: The sort order for the tag category + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A Tag Category resources + /// :rtype: :class:`TagCategoryResource ` + pub fn create_tag_category( + &self, + name: String, + color: Option, + order: Option, + fields: Option>, + ) -> PyResult { + self.runtime + .block_on(self.client.create_tag_category(name, color, order, fields)) + } + + #[pyo3(signature = (name, version, new_name=None, color=None, order=None, fields=None))] + /// Updates an existing tag category using the specified parameters. + /// + /// .. note:: + /// This method requires a resource version. See :ref:`Resource Versioning ` + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param str name: The tag category name, must match the server's ``tag_category_name_regex`` + /// :param int version: The existing resource's version + /// :param Optional[str] new_name: The new name for the tag category + /// :param Optional[str] color: The color name for this tag category + /// :param Optional[str] order: The sort order for the tag category + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A Tag Category resource + /// :rtype: :class:`TagCategoryResource ` + pub fn update_tag_category( + &self, + name: String, + version: u32, + new_name: Option, + color: Option, + order: Option, + fields: Option>, + ) -> PyResult { + self.runtime.block_on( + self.client + .update_tag_category(name, version, new_name, color, order, fields), + ) + } + + #[pyo3(signature = (name, fields=None))] + /// Fetches a tag category by name + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param str name: The name of the tag category + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A Tag Category resource + /// :rtype: :class:`TagCategoryResource ` + pub fn get_tag_category( + &self, + name: String, + fields: Option>, + ) -> PyResult { + self.runtime + .block_on(self.client.get_tag_category(name, fields)) + } + + #[pyo3(signature = (name, version))] + /// Deletes a tag category + /// + /// .. note:: + /// This method requires a resource version. See :ref:`Resource Versioning ` + /// + /// :param str name: The tag category's name + /// :param int version: The existing resource's version + /// + pub fn delete_tag_category(&self, name: String, version: u32) -> PyResult<()> { + self.runtime + .block_on(self.client.delete_tag_category(name, version)) + } + + #[pyo3(signature = (name))] + /// Sets the default tag category for the site + /// + /// :param str name: The name of the category to set as default + pub fn set_default_tag_category(&self, name: String) -> PyResult<()> { + self.runtime + .block_on(self.client.set_default_tag_category(name)) + } + + #[pyo3(signature = (query=None, fields=None, limit=None, offset=None))] + /// List the tags currently available on the site + /// + /// .. note:: + /// This method supports :doc:`Query Tokens ` + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// .. note:: + /// This method supports :ref:`Result limits ` + /// + /// .. note:: + /// This method supports :ref:`Result offsets ` + /// + /// :param list[QueryToken] query: A list of query tokens used to filter the results + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// :param Optional[int] limit: The maximum number of resources to return + /// :param Optional[int] offset: The number of resources to skip before returning the resulting resources + /// + /// :see: :class:`szurubooru_client.tokens.TagNamedToken` and :class:`~szurubooru_client.tokens.TagSortToken` for query filtering + /// + /// :return: A paged result of :class:`~szurubooru_client.models.TagResource` + /// :rtype: :class:`~szurubooru_client.PagedResult` + pub fn list_tags( + &self, + query: Option>, + fields: Option>, + limit: Option, + offset: Option, + ) -> PyResult { + self.runtime + .block_on(self.client.list_tags(query, fields, limit, offset)) + } + + #[pyo3(signature = (names, category=None, description=None, implications=None, suggestions=None, fields=None))] + /// Creates a new tag using specified parameters. Names, suggestions and implications must + /// match `tag_name_regex` from server's configuration. Category must exist and is the same + /// as the `name` field within :class:`~szurubooru_client.models.TagCategoryResource` resource. + /// Suggestions and implications are optional. If specified implied tags or suggested tags do + /// not exist yet, they will be automatically created. Tags created automatically have no + /// implications, no suggestions, one name and their category is set to the first tag category + /// found. If there are no tag categories established yet, an error will be thrown. + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param names: A string or list of strings that serve as the name(s) for this tag + /// :param str category: The name of the tag category that this tag belongs to + /// :param list[str] implications: Tags that are automatically implied when this tag is used + /// :param list[str] suggestions: Tags that should be suggested when this tag is used + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A Tag resource + /// :rtype: :class:`TagResource ` + pub fn create_tag( + &self, + names: Py, + category: Option, + description: Option, + implications: Option>, + suggestions: Option>, + fields: Option>, + ) -> PyResult { + self.runtime.block_on(self.client.create_tag( + names, + category, + description, + implications, + suggestions, + fields, + )) + } + + #[pyo3(signature = (name, version, names=None, category=None, description=None, + implications=None, suggestions=None, fields=None))] + #[allow(clippy::too_many_arguments)] + /// Updates an existing tag using specified parameters. Names, suggestions and implications must + /// match `tag_name_regex` from server's configuration. Category must exist and is the same + /// as the `name` field within :class:`~szurubooru_client.models.TagCategoryResource` resource. + /// Suggestions and implications are optional. If specified implied tags or suggested tags do + /// not exist yet, they will be automatically created. Tags created automatically have no + /// implications, no suggestions, one name and their category is set to the first tag category + /// found. If there are no tag categories established yet, an error will be thrown. + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// .. note:: + /// This method requires a resource version. See :ref:`Resource Versioning ` + /// + /// :param name: The name of the existing tags + /// :param int version: The existing resource's version + /// :param Optional[list|str] names: A string or list of strings that the tag should be known as + /// :param str category: The name of the tag category that this tag belongs to + /// :param list[str] implications: Tags that are automatically implied when this tag is used + /// :param list[str] suggestions: Tags that should be suggested when this tag is used + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A Tag resource + /// :rtype: :class:`TagResource ` + pub fn update_tag( + &self, + name: String, + version: u32, + names: Option>, + category: Option, + description: Option, + implications: Option>, + suggestions: Option>, + fields: Option>, + ) -> PyResult { + self.runtime.block_on(self.client.update_tag( + name, + version, + names, + category, + description, + implications, + suggestions, + fields, + )) + } + + #[pyo3(signature = (name, fields=None))] + /// Fetches an existing tag + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param str name: The name of the tag to fetch + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A Tag resource + /// :rtype: :class:`~szurubooru_client.models.TagResource` + pub fn get_tag(&self, name: String, fields: Option>) -> PyResult { + self.runtime.block_on(self.client.get_tag(name, fields)) + } + + #[pyo3(signature = (name, version))] + /// Deletes an existing tag + /// + /// .. note:: + /// This method requires a resource version. See :ref:`Resource Versioning ` + /// + /// :param str name: The name of the tag to delete + /// :param int version: The existing resource's version + pub fn delete_tag(&self, name: String, version: u32) -> PyResult<()> { + self.runtime.block_on(self.client.delete_tag(name, version)) + } + + #[pyo3(signature = (remove_tag, remove_tag_version, merge_to_tag, merge_to_version, fields=None))] + /// Removes source tag and merges all of its usages, suggestions and implications to the + /// target tag. Other tag properties such as category and aliases do not get transferred + /// and are discarded. + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// .. note:: + /// This method requires two resource versions. See :ref:`Resource Versioning ` + /// + /// :param str remove_tag: The name of the tag to be removed + /// :param int remove_tag_version: The current version of the tag to be removed + /// :param str merge_to_tag: The name of the tag to be merged *to* + /// :param int merge_to_version: The current version of the tag to merge *to* + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A Tag resource + /// :rtype: :class:`~szurubooru_client.models.TagResource` + pub fn merge_tags( + &self, + remove_tag: String, + remove_tag_version: u32, + merge_to_tag: String, + merge_to_version: u32, + fields: Option>, + ) -> PyResult { + self.runtime.block_on(self.client.merge_tags( + remove_tag, + remove_tag_version, + merge_to_tag, + merge_to_version, + fields, + )) + } + + #[pyo3(signature = (name))] + /// Lists siblings of given tag, e.g. tags that were used in the same posts as the given tag. + /// The ``occurrences`` field signifies how many times a given + /// sibling appears with given tag. Results are sorted by occurrences count and the list is + /// truncated to the first 50 elements. + /// + /// :param str name: The name of the tag to fetch siblings for + /// + /// :return: A list of Tag siblings + /// :rtype: list[TagSibling] + pub fn get_tag_siblings(&self, name: String) -> PyResult> { + self.runtime.block_on(self.client.get_tag_siblings(name)) + } + + #[pyo3(signature = (query=None, fields=None, limit=None, offset=None))] + /// Lists the posts currently available on the site + /// + /// .. note:: + /// This method supports :doc:`Query Tokens ` + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// .. note:: + /// This method supports :ref:`Result limits ` + /// + /// .. note:: + /// This method supports :ref:`Result offsets ` + /// + /// :param Optional[list[QueryToken]] query: A list of query tokens used to filter the results + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// :param Optional[int] limit: The maximum number of resources to return + /// :param Optional[int] offset: The number of results to skip before returning the result + /// + /// :see: :class:`szurubooru_client.tokens.PostNamedToken`, :class:`~szurubooru_client.tokens.PostSortToken`, and :class:`~szurubooru_client.tokens.PostSpecialToken` for query filtering + /// + /// :return: A :class:`~szurubooru_client.PagedResult` of Post resources + /// :rtype: :class:`~szurubooru_client.models.PostResource` + pub fn list_posts( + &self, + query: Option>, + fields: Option>, + limit: Option, + offset: Option, + ) -> PyResult { + self.runtime + .block_on(self.client.list_posts(query, fields, limit, offset)) + } + + #[pyo3(signature = (url=None, upload_token=None, file_path=None, thumbnail_path=None, tags=None, safety=None, source=None, + relations=None, notes=None, flags=None, anonymous=None, fields=None))] + #[allow(clippy::too_many_arguments)] + /// Creates a new post using one of three image sources: URL, upload token or file path. + /// + /// * URL: The server will download the given Image URL as the post's content + /// * Upload token: The token returned by using :func:`~szurubooru_client.SzurubooruSyncClient.upload_temporary_file` + /// * File path: The ``pathlib.Path`` or ``str`` path to the file to be uploaded from the local filesystem + /// + /// .. warning:: + /// The ``safety`` argument is *required* + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param Optional[str] url: The URL of the image to use for the post's content + /// :param Optional[str] upload_token: The token returned by the temporary upload method + /// :param Optional[str|pathlib.Path] file_path: The local file path to upload + /// :param Optional[str|Path] thumbnail_path: The local file path to the thumbnail for the post + /// :param Optional[list[str]] tags: The list of tag names to use for the post + /// :param PostSafety safety: The safety level of the post + /// :param Optional[list[int]] relations: A list of related post IDs + /// :param Optional[list[NoteResource]] notes: A list of :class:`~szurubooru_client.models.NoteResource` for the post + /// :param Optional[list[str]] flags: A list of flags to apply to the post + /// :param Optional[bool] anonymous: Whether to create the post anonymously + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A Post resource + /// :rtype: :class:`~szurubooru_client.models.PostResource` + pub fn create_post( + &self, + url: Option, + upload_token: Option, + file_path: Option, + thumbnail_path: Option, + tags: Option>, + safety: Option, + source: Option, + relations: Option>, + notes: Option>, + flags: Option>, + anonymous: Option, + fields: Option>, + ) -> PyResult { + self.runtime.block_on(self.client.create_post( + url, + upload_token, + file_path, + thumbnail_path, + tags, + safety, + source, + relations, + notes, + flags, + anonymous, + fields, + )) + } + + #[pyo3(signature = (post_id, post_version, url=None, token=None, file_path=None, + thumbnail_path=None, tags=None, safety=None, source=None, relations=None, notes=None, + flags=None, fields=None))] + #[allow(clippy::too_many_arguments)] + /// Updates an existing post + /// + /// The post's content can be replaced using one of three methods: + /// * URL: The server will download the given Image URL as the post's content + /// * Upload token: The token returned by using :func:`~szurubooru_client.SzurubooruSyncClient.upload_temporary_file` + /// * File path: The ``pathlib.Path`` or string path to the file to be uploaded. + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// .. note:: + /// This method requires a resource version. See :ref:`Resource Versioning ` + /// + /// :param int post_id: The ID of the post to update + /// :param int version: The existing resource's version + /// :param Optional[str] url: The URL of the image to use for the post's content + /// :param Optional[str] upload_token: The token returned by the temporary upload method + /// :param Optional[str|Path] file_path: The local file path to upload + /// :param Optional[str|Path] thumbnail_path: The local file path to the thumbnail for the post + /// :param Optional[list[str]] tags: The list of tag names to use for the post + /// :param Optional[PostSafety] safety: The safety level of the post + /// :param Optional[list[int]] relations: A list of related post IDs + /// :param Optional[list[NoteResource]] notes: A list of :class:`~szurubooru_client.models.NoteResource` for the post + /// :param Optional[list[str]] flags: A list of flags to apply to the post + /// :param Optional[bool] anonymous: Whether to create the post anonymously + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A Post resource + /// :rtype: :class:`~szurubooru_client.models.PostResource` + pub fn update_post( + &self, + post_id: u32, + post_version: u32, + url: Option, + token: Option, + file_path: Option, + thumbnail_path: Option, + tags: Option>, + safety: Option, + source: Option, + relations: Option>, + notes: Option>, + flags: Option>, + fields: Option>, + ) -> PyResult { + self.runtime.block_on(self.client.update_post( + post_id, + post_version, + url, + token, + file_path, + thumbnail_path, + tags, + safety, + source, + relations, + notes, + flags, + fields, + )) + } + + #[pyo3(signature = (post_id))] + /// Downloads the given post's image as a byte array + /// + /// :param int post_id: The ID of the post to fetch + /// + /// :return: A byte array of the given post's content + /// :rtype: list[byte] + pub fn get_image_bytes(&self, post_id: u32) -> PyResult> { + self.runtime.block_on(self.client.get_image_bytes(post_id)) + } + + #[pyo3(signature = (post_id, file_path))] + /// Downloads the given post's image to a path on the filesystem + /// + /// :param int post_id: The ID of the post to fetch + /// :param Path|str file_path: The path to download the image to + pub fn download_image_to_path(&self, post_id: u32, file_path: PathBuf) -> PyResult<()> { + self.runtime + .block_on(self.client.download_image_to_path(post_id, file_path)) + } + + #[pyo3(signature = (post_id))] + /// Downloads the given post's thumbnail as a byte array + /// + /// :param int post_id: The ID of the post to fetch + /// + /// :return: A byte array of the given post's content + /// :rtype: list[byte] + pub fn get_thumbnail_bytes(&self, post_id: u32) -> PyResult> { + self.runtime + .block_on(self.client.get_thumbnail_bytes(post_id)) + } + + #[pyo3(signature = (post_id, file_path))] + /// Downloads the given post's thumbnail to a path on the filesystem + /// + /// :param int post_id: The ID of the post to fetch + /// :param Path|str file_path: The path to download the thumbnail to + pub fn download_thumbnail_to_path(&self, post_id: u32, file_path: PathBuf) -> PyResult<()> { + self.runtime + .block_on(self.client.download_thumbnail_to_path(post_id, file_path)) + } + + #[pyo3(signature = (image_path))] + /// Reverse image searches for an image from the filesystem. Returns + /// a list of visually similar images + /// + /// :param Path|str image_path: The path to the image to search for + /// + /// :return: An object containing the IDs of similar posts + /// :rtype: :class:`~szurubooru_client.models.ImageSearchResult` + pub fn reverse_image_search(&self, image_path: PathBuf) -> PyResult { + self.runtime + .block_on(self.client.reverse_image_search(image_path)) + } + + #[pyo3(signature = (image_path))] + /// Searches for an *exact* image match of an image from the filesystem + /// + /// :param Path|str image_path: The path to the image to search for + /// + /// :return: A Post Resource or None if the image doesn't exist + /// :rtype: None|:class:`~szurubooru_client.models.PostResource` + pub fn post_for_image(&self, image_path: PathBuf) -> PyResult> { + self.runtime + .block_on(self.client.post_for_image(image_path)) + } + + #[pyo3(signature = (post_id, fields=None))] + /// Fetches an individual post by its post ID + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param int post_id: The ID of the post to fetch + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A Post resource + /// :rtype: :class:`~szurubooru_client.models.PostResource` + pub fn get_post(&self, post_id: u32, fields: Option>) -> PyResult { + self.runtime.block_on(self.client.get_post(post_id, fields)) + } + + #[pyo3(signature = (post_id))] + /// Fetches posts from *around* the given post ID. That means the post before and after, + /// if they exist. + /// + /// :param int post_id: The ID of the post to fetch + /// + /// :return: A resource containing the IDs of the next and previous IDs + /// :rtype: :class:`~szurubooru_client.models.AroundPostResult` + pub fn get_around_post(&self, post_id: u32) -> PyResult { + self.runtime.block_on(self.client.get_around_post(post_id)) + } + + #[pyo3(signature = (post_id, version))] + /// Deletes a post by its ID + /// + /// .. note:: + /// This method requires a resource version. See :ref:`Resource Versioning ` + /// + /// :param int post_id: The ID of the post to delete + /// :param int version: The existing resource's version + pub fn delete_post(&self, post_id: u32, version: u32) -> PyResult<()> { + self.runtime + .block_on(self.client.delete_post(post_id, version)) + } + + #[pyo3(signature = (remove_post, remove_post_version, merge_to_post, + merge_to_version, replace_post_content=false, fields=None))] + /// Removes source post and merges all of its tags, relations, scores, favorites and comments to + /// the target post. If ``replace_content`` is set to ``true``, content of the target post + /// is replaced using the content of the source post; otherwise it remains unchanged. Source + /// post properties such as its safety, source, whether to loop the video and other scalar + /// values do not get transferred and are discarded. + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// .. note:: + /// This method requires two resource versions. See :ref:`Resource Versioning ` + /// + /// :param int remove_post: The ID of the source post + /// :param int remove_post_version: The current version of the source post + /// :param int merge_to_post: The ID of the destination post + /// :param int merge_to_version: The current version of the destination post + /// :param bool replace_post_content: Whether to replace the destination post's content with the content from the source post + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A post resource + /// :rtype: :class:`~szurubooru_client.models.PostResource` + pub fn merge_post( + &self, + remove_post: u32, + remove_post_version: u32, + merge_to_post: u32, + merge_to_version: u32, + replace_post_content: bool, + fields: Option>, + ) -> PyResult { + self.runtime.block_on(self.client.merge_post( + remove_post, + remove_post_version, + merge_to_post, + merge_to_version, + replace_post_content, + fields, + )) + } + + #[pyo3(signature = (post_id, rating, fields=None))] + /// Updates score of authenticated user for given post. Valid scores are -1, 0 and 1. + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param int post_id: The ID of the post to rate + /// :param int rating: The rating to give the post. Must be -1, 0 or 1. + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A post resource + /// :rtype: :class:`~szurubooru_client.models.PostResource` + pub fn rate_post( + &self, + post_id: u32, + rating: i8, + fields: Option>, + ) -> PyResult { + self.runtime + .block_on(self.client.rate_post(post_id, rating, fields)) + } + + #[pyo3(signature = (post_id, fields=None))] + /// Marks the post as favorite for the current user. + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param int post_id: The ID of the post to rate + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A post resource + /// :rtype: :class:`~szurubooru_client.models.PostResource` + pub fn favorite_post( + &self, + post_id: u32, + fields: Option>, + ) -> PyResult { + self.runtime + .block_on(self.client.favorite_post(post_id, fields)) + } + + #[pyo3(signature = (post_id, fields=None))] + /// Unmarks the post as favorite for the current user. + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param int post_id: The ID of the post to rate + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A post resource + /// :rtype: :class:`~szurubooru_client.models.PostResource` + pub fn unfavorite_post( + &self, + post_id: u32, + fields: Option>, + ) -> PyResult { + self.runtime + .block_on(self.client.unfavorite_post(post_id, fields)) + } + + #[pyo3(signature = (fields=None))] + /// Retrieves the post that is currently featured on the main page. If no post is + /// featured, the returned value is ``None``. Note that this method exists mostly for compatibility + /// with setting featured post - most of the time, you'd want to use query global info which + /// contains more information. + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A post resource or ``None`` + /// :rtype: Optional[:class:`~szurubooru_client.models.PostResource`] + pub fn get_featured_post(&self, fields: Option>) -> PyResult> { + self.runtime.block_on(self.client.get_featured_post(fields)) + } + + #[pyo3(signature = (post_id, fields=None))] + /// Features a post on the main page + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param int post_id: The ID of the post to feature + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A post resource + /// :rtype: :class:`~szurubooru_client.models.PostResource` + pub fn set_featured_post( + &self, + post_id: u32, + fields: Option>, + ) -> PyResult { + self.runtime + .block_on(self.client.set_featured_post(post_id, fields)) + } + + #[pyo3(signature = (fields=None))] + /// Lists all pool categories + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A list of Pool Category resources + /// :rtype: list[:class:`~szurubooru_client.models.PoolCategoryResource`] + pub fn list_pool_categories( + &self, + fields: Option>, + ) -> PyResult> { + self.runtime + .block_on(self.client.list_pool_categories(fields)) + } + + #[pyo3(signature = (name, color=None, fields=None))] + /// Creates a new pool category using specified parameters. Name must match + /// ``pool_category_name_regex`` from server's configuration. First category created becomes + /// the default category. + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param str name: The name of the pool category to create + /// :param str color: The color to associate with the pool category + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A pool category resource + /// :rtype: :class:`~szurubooru_client.models.PoolCategoryResource` + pub fn create_pool_category( + &self, + name: String, + color: Option, + fields: Option>, + ) -> PyResult { + self.runtime + .block_on(self.client.create_pool_category(name, color, fields)) + } + + #[pyo3(signature = (name, version, new_name=None, color=None, fields=None))] + /// Updates an existing tag category using specified parameters. Name must match + /// `tag_category_name_regex` from server's configuration. + /// + /// .. note:: + /// This method requires a resource version. See :ref:`Resource Versioning ` + /// + /// :param str name: The name of the pool category to modify + /// :param int version: The existing resource's version + /// :param Optional[str] new_name: The new name for the pool category + /// :param Optional[str] color: The new color for the pool category + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: An updated pool category resource + /// :rtype: :class:`~szurubooru_client.models.PoolCategoryResource` + pub fn update_pool_category( + &self, + name: String, + version: u32, + new_name: Option, + color: Option, + fields: Option>, + ) -> PyResult { + self.runtime.block_on( + self.client + .update_pool_category(name, version, new_name, color, fields), + ) + } + + #[pyo3(signature = (name, fields=None))] + /// Fetches an existing pool category + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param str name: The name of the pool category to fetch + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A pool category resource + /// :rtype: :class:`~szurubooru_client.models.PoolCategoryResource` + pub fn get_pool_category( + &self, + name: String, + fields: Option>, + ) -> PyResult { + self.runtime + .block_on(self.client.get_pool_category(name, fields)) + } + + #[pyo3(signature = (name, version))] + /// Deletes existing pool category. The pool category to be deleted must have no usages. + /// + /// .. note:: + /// This method requires a resource version. See :ref:`Resource Versioning ` + /// + /// :param str name: The name of the pool category to delete + /// :param int version: The existing resource's version + pub fn delete_pool_category(&self, name: String, version: u32) -> PyResult<()> { + self.runtime + .block_on(self.client.delete_pool_category(name, version)) + } + + #[pyo3(signature = (name, fields=None))] + /// Sets given pool category as default. All new pools created manually or automatically will + /// have this category. + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param str name: The name of the pool category to be set as default + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A pool category resource + /// :rtype: :class:`~szurubooru_client.models.PoolCategoryResource` + pub fn set_default_pool_category( + &self, + name: String, + fields: Option>, + ) -> PyResult { + self.runtime + .block_on(self.client.set_default_pool_category(name, fields)) + } + + #[pyo3(signature = (query=None, fields=None, limit=None, offset=None))] + /// List the post pools currently available on the site + /// + /// .. note:: + /// This method supports :doc:`Query Tokens ` + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// .. note:: + /// This method supports :ref:`Result limits ` + /// + /// .. note:: + /// This method supports :ref:`Result offsets ` + /// + /// :param list[QueryToken] query: A list of query tokens used to filter the results + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// :param Optional[int] limit: The maximum number of resources to return + /// :param Optional[int] offset: The number of resources to skip before returning the resulting resources + /// + /// :see: :class:`szurubooru_client.tokens.PoolNamedToken` and :class:`~szurubooru_client.tokens.PoolSortToken` for query filtering + /// + /// :return: A paged result of :class:`~szurubooru_client.models.PoolResource` + /// :rtype: :class:`~szurubooru_client.PagedResult` + pub fn list_pools( + &self, + query: Option>, + fields: Option>, + limit: Option, + offset: Option, + ) -> PyResult { + self.runtime + .block_on(self.client.list_pools(query, fields, limit, offset)) + } + + #[pyo3(signature = (names, category=None, description=None, posts=None, fields=None))] + /// Creates a new pool using specified parameters. Names, suggestions and implications must + /// match `pool_name_regex` from server's configuration. ``posts`` is an optional list of + /// post IDs to add to the pool. If the specified posts do not exist, an error will be thrown + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param list[str]|str names: The name or names for the new pool + /// :param Optional[str] category: The pool category for this pool + /// :param Optional[str] description: The description for this pool + /// :param Optional[list[int]] posts: The posts that will be part of this pool + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A pool resource + /// :rtype: :class:`~szurubooru_client.models.PoolResource` + pub fn create_pool( + &self, + names: Py, + category: Option, + description: Option, + posts: Option>, + fields: Option>, + ) -> PyResult { + self.runtime.block_on( + self.client + .create_pool(names, category, description, posts, fields), + ) + } + + #[pyo3(signature = (pool_id, version, new_names=None, category=None, description=None, + posts=None, fields=None))] + #[allow(clippy::too_many_arguments)] + /// Updates an existing pool using specified parameters. + /// ``new_names``, if given, must match ``pool_name_regex`` from server's configuration. + /// ``category``, if given, must exist. + /// ``posts`` is an optional list of integer post IDs. If the specified posts do not exist yet, + /// an error will be thrown. The full list of post IDs must be provided if they are being + /// updated, and the previous list of posts will be replaced with the new one. + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// .. note:: + /// This method requires a resource version. See :ref:`Resource Versioning ` + /// + /// :param int pool_id: The ID of the pool to update + /// :param int version: The existing resource's version + /// :param Optional[list[str]] new_names: The new name(s) for the pool + /// :param Optional[str] category: The new pool category for the pool + /// :param Optional[str] description: The new description for the pool + /// :param Optional[list[int]] posts: The posts that belong to this pool + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A pool category resource + /// :rtype: :class:`~szurubooru_client.models.PoolResource` + pub fn update_pool( + &self, + pool_id: u32, + version: u32, + new_names: Option>, + category: Option, + description: Option, + posts: Option>, + fields: Option>, + ) -> PyResult { + self.runtime.block_on(self.client.update_pool( + pool_id, + version, + new_names, + category, + description, + posts, + fields, + )) + } + + #[pyo3(signature = (pool_id, fields=None))] + /// Retrieves information about an existing pool + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param int pool_id: The ID of the pool to fetch + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A pool resource + /// :rtype: :class:`~szurubooru_client.models.PoolResource` + pub fn get_pool(&self, pool_id: u32, fields: Option>) -> PyResult { + self.runtime.block_on(self.client.get_pool(pool_id, fields)) + } + + #[pyo3(signature = (pool_id, version))] + /// Deletes existing pool. All posts in the pool will only have their relation to the pool + /// removed. + /// + /// .. note:: + /// This method requires a resource version. See :ref:`Resource Versioning ` + /// + /// :param int pool_id: The ID of the pool to delete + /// :param int version: The existing resource's version + pub fn delete_pool(&self, pool_id: u32, version: u32) -> PyResult<()> { + self.runtime + .block_on(self.client.delete_pool(pool_id, version)) + } + + #[pyo3(signature = (remove_pool, remove_pool_version, merge_to_pool, merge_to_version, fields=None))] + /// Removes source pool and merges all of its posts with the target pool. Other pool properties + /// such as category and aliases do not get transferred and are discarded. + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// .. note:: + /// This method requires two resource versions. See :ref:`Resource Versioning ` + /// + /// :param int remove_pool: The ID of the source pool + /// :param int remove_pool_version: The current version of the source pool + /// :param int merge_to_pool: The ID of the destination pool + /// :param int merge_to_version: The current version of the destination pool + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A pool resource + /// :rtype: :class:`~szurubooru_client.models.PoolResource` + pub fn merge_pools( + &self, + remove_pool: u32, + remove_pool_version: u32, + merge_to_pool: u32, + merge_to_version: u32, + fields: Option>, + ) -> PyResult { + self.runtime.block_on(self.client.merge_pools( + remove_pool, + remove_pool_version, + merge_to_pool, + merge_to_version, + fields, + )) + } + + #[pyo3(signature = (query=None, fields=None, limit=None, offset=None))] + /// List the comments currently available on the site. + /// + /// + /// .. note:: + /// This method supports :doc:`Query Tokens ` + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// .. note:: + /// This method supports :ref:`Result limits ` + /// + /// .. note:: + /// This method supports :ref:`Result offsets ` + /// + /// :param list[QueryToken] query: A list of query tokens used to filter the results + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// :param Optional[int] limit: The maximum number of resources to return + /// :param Optional[int] offset: The number of resources to skip before returning the resulting resources + /// + /// :see: :class:`~szurubooru_client.tokens.CommentNamedToken` and `~szurubooru_client.tokens.CommentSortToken` for query filtering + /// + /// :return: A paged result of :class:`~szurubooru_client.models.CommentResource` + /// :rtype: :class:`~szurubooru_client.PagedResult` + pub fn list_comments( + &self, + query: Option>, + fields: Option>, + limit: Option, + offset: Option, + ) -> PyResult { + self.runtime + .block_on(self.client.list_comments(query, fields, limit, offset)) + } + + #[pyo3(signature = (text, post_id, fields=None))] + /// Creates a new comment under a given post + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param str text: The text of the comment + /// :param int post_id: The ID of the post to create the comment on + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A comment resource + /// :rtype: :class:`~szurubooru_client.models.CommentResource` + pub fn create_comment( + &self, + text: String, + post_id: u32, + fields: Option>, + ) -> PyResult { + self.runtime + .block_on(self.client.create_comment(text, post_id, fields)) + } + + #[pyo3(signature = (comment_id, version, text, fields=None))] + /// Updates an existing comment with new text + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// .. note:: + /// This method requires a resource version. See :ref:`Resource Versioning ` + /// + /// :param int comment_id: The ID of the comment to update + /// :param int version: The existing resource's version + /// :param str text: The new text for the comment + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A comment resource + /// :rtype: :class:`~szurubooru_client.models.CommentResource` + pub fn update_comment( + &self, + comment_id: u32, + version: u32, + text: String, + fields: Option>, + ) -> PyResult { + self.runtime.block_on( + self.client + .update_comment(comment_id, version, text, fields), + ) + } + + #[pyo3(signature = (comment_id, fields=None))] + /// Fetches an existing comment + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param int comment_id: The ID of the comment to fetch + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A comment resource + /// :rtype: :class:`~szurubooru_client.models.CommentResource` + pub fn get_comment( + &self, + comment_id: u32, + fields: Option>, + ) -> PyResult { + self.runtime + .block_on(self.client.get_comment(comment_id, fields)) + } + + #[pyo3(signature = (comment_id, version))] + /// Deletes an existing comment + /// + /// .. note:: + /// This method requires a resource version. See :ref:`Resource Versioning ` + /// + /// :param int comment_id: The ID of the comment to delete + /// :param int version: The existing resource's version + pub fn delete_comment(&self, comment_id: u32, version: u32) -> PyResult<()> { + self.runtime + .block_on(self.client.delete_comment(comment_id, version)) + } + + #[pyo3(signature = (comment_id, rating, fields=None))] + /// Updates score of authenticated user for given comment. Valid scores are -1, 0 and 1. + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param int comment_id: The ID of the comment to rate + /// :param int rating: The rating to give the comment. Must be -1, 0, or 1 + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A comment resource + /// :rtype: :class:`~szurubooru_client.models.CommentResource` + pub fn rate_comment( + &self, + comment_id: u32, + rating: i8, + fields: Option>, + ) -> PyResult { + self.runtime + .block_on(self.client.rate_comment(comment_id, rating, fields)) + } + + #[pyo3(signature = (query=None, fields=None, limit=None, offset=None))] + /// List the users currently registered on the site + /// + /// .. note:: + /// This method supports :doc:`Query Tokens ` + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// .. note:: + /// This method supports :ref:`Result limits ` + /// + /// .. note:: + /// This method supports :ref:`Result offsets ` + /// + /// :param list[QueryToken] query: A list of query tokens used to filter the results + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// :param Optional[int] limit: The maximum number of resources to return + /// :param Optional[int] offset: The number of resources to skip before returning the resulting resources + /// + /// :see: :class:`szurubooru_client.tokens.UserNamedToken` and :class:`~szurubooru_client.tokens.UserSortToken` for query filtering + /// + /// :return: A paged result of :class:`~szurubooru_client.models.UserResource` + /// :rtype: :class:`~szurubooru_client.PagedResult` + pub fn list_users( + &self, + query: Option>, + fields: Option>, + limit: Option, + offset: Option, + ) -> PyResult { + self.runtime + .block_on(self.client.list_users(query, fields, limit, offset)) + } + + #[pyo3(signature = (name, password, rank=None, avatar_path=None, fields=None))] + /// Creates a new user using specified parameters. Names and passwords must match + /// ``user_name_regex`` and ``password_regex`` from server's configuration, respectively. + /// Email address, rank and avatar fields are optional. Avatar style can be either + /// ``Gravatar`` or ``Manual`` from the :class`~szurubooru_client.models.UserAvatarStyle` enum. + /// ``Manual`` avatar style requires client to pass also the ``avatar_path`` argument. + /// If the rank is empty and the user happens to be the first user ever created, + /// become an administrator, whereas subsequent users will be given the rank indicated by + /// ``default_rank`` in the server's configuration. + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param str name: The new user's username + /// :param str password: The new user's password + /// :param Optional[UserRank] rank: The rank to give the new user + /// :param Optional[str] avatar_path: The local file path to the user's avatar image + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A user resource + /// :rtype: :class:`~szurubooru_client.models.UserResource` + pub fn create_user( + &self, + name: String, + password: String, + rank: Option, + avatar_path: Option, + fields: Option>, + ) -> PyResult { + self.runtime.block_on( + self.client + .create_user(name, password, rank, avatar_path, fields), + ) + } + + #[pyo3(signature = (name, version, new_name=None, password=None, rank=None, avatar_path=None, fields=None))] + #[allow(clippy::too_many_arguments)] + /// Updates an existing user using specified parameters. Names and passwords must match + /// ``user_name_regex`` and ``password_regex`` from server's configuration, respectively. + /// Email address, rank and avatar fields are optional. Avatar style can be either + /// ``Gravatar`` or ``Manual`` from the :class`~szurubooru_client.models.UserAvatarStyle` enum. + /// ``Manual`` avatar style requires client to pass also the ``avatar_path`` argument. + /// If the rank is empty and the user happens to be the first user ever created, + /// become an administrator, whereas subsequent users will be given the rank indicated by + /// ``default_rank`` in the server's configuration. + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// .. note:: + /// This method requires a resource version. See :ref:`Resource Versioning ` + /// + /// :param str name: The existing user's username + /// :param int version: The existing resource's version + /// :param Optional[str] new_name: The user new username + /// :param Optional[str] password: The existing user's password + /// :param Optional[UserRank] rank: The rank to give the existing user + /// :param Optional[str] avatar_path: The local file path to the user's new avatar image + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A user resource + /// :rtype: :class:`~szurubooru_client.models.UserResource` + pub fn update_user( + &self, + name: String, + version: u32, + new_name: Option, + password: Option, + rank: Option, + avatar_path: Option, + fields: Option>, + ) -> PyResult { + self.runtime.block_on(self.client.update_user( + name, + version, + new_name, + password, + rank, + avatar_path, + fields, + )) + } + + #[pyo3(signature = (user_name, fields=None))] + /// Retrieves information about an existing user + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param str user_name: The username of the user to fetch + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A user resource + /// :rtype: :class:`~szurubooru_client.models.UserResource` + pub fn get_user( + &self, + user_name: String, + fields: Option>, + ) -> PyResult { + self.runtime + .block_on(self.client.get_user(user_name, fields)) + } + + #[pyo3(signature = (user_name, version))] + /// Deletes an existing user + /// + /// .. note:: + /// This method requires a resource version. See :ref:`Resource Versioning ` + /// + /// :param str user_name: The username of the user to delete + /// :param int version: The existing resource's version + pub fn delete_user(&self, user_name: String, version: u32) -> PyResult<()> { + self.runtime + .block_on(self.client.delete_user(user_name, version)) + } + + #[pyo3(signature = (user_name, fields=None))] + /// Fetches a list of the given user's auth tokens + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param str user_name: The username of the user to fetch the auth tokens for + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A user auth token resource + /// :rtype: :class:`~szurubooru_client.models.UserAuthTokenResource` + pub fn list_user_tokens( + &self, + user_name: String, + fields: Option>, + ) -> PyResult> { + self.runtime + .block_on(self.client.list_user_tokens(user_name, fields)) + } + + #[pyo3(signature = (user_name, note=None, enabled=None, expiration_time=None, fields=None))] + /// Creates an auth token for the given user + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// :param str user_name: The username of the user to create an auth token for + /// :param Optional[str] note: A text note to include with the token + /// :param Optional[bool] enabled: Whether the token is enabled or not + /// :param Optional[DateTime] expiration_time: The ``DateTime`` specifying when the token should expire + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A user auth token resource + /// :rtype: :class:`~szurubooru_client.models.UserAuthTokenResource` + pub fn create_user_token( + &self, + user_name: String, + note: Option, + enabled: Option, + expiration_time: Option>, + fields: Option>, + ) -> PyResult { + self.runtime.block_on(self.client.create_user_token( + user_name, + note, + enabled, + expiration_time, + fields, + )) + } + + #[pyo3(signature = (user_name, token, version, enabled=None, note=None, expiration_time=None, fields=None))] + #[allow(clippy::too_many_arguments)] + /// Update a user's existing auth token + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// .. note:: + /// This method requires a resource version. See :ref:`Resource Versioning ` + /// + /// :param str user_name: The user's username + /// :param str token: The token to update + /// :param int version: The existing resource's version + /// :param Optional[str] note: A text note to include with the token + /// :param Optional[bool] enabled: Whether the token is enabled or not + /// :param Optional[DateTime] expiration_time: The ``DateTime`` specifying when the token should expire + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// + /// :return: A user auth token resource + /// :rtype: :class:`~szurubooru_client.models.UserAuthTokenResource` + pub fn update_user_token( + &self, + user_name: String, + token: String, + version: u32, + enabled: Option, + note: Option, + expiration_time: Option>, + fields: Option>, + ) -> PyResult { + self.runtime.block_on(self.client.update_user_token( + user_name, + token, + version, + enabled, + note, + expiration_time, + fields, + )) + } + + #[pyo3(signature = (user_name, token, version))] + /// Deletes an existing user auth token + /// + /// .. note:: + /// This method requires a resource version. See :ref:`Resource Versioning ` + /// + /// :param str user_name: The user's username + /// :param str token: The token value + /// :param int version: The existing resource's version + pub fn delete_user_token( + &self, + user_name: String, + token: String, + version: u32, + ) -> PyResult<()> { + self.runtime + .block_on(self.client.delete_user_token(user_name, token, version)) + } + + #[pyo3(signature = (email_or_name))] + /// Start a password reset request + /// + /// :param str email_or_name: The email or username of the user to request the reset for + pub fn password_reset_request(&self, email_or_name: String) -> PyResult<()> { + self.runtime + .block_on(self.client.password_reset_request(email_or_name)) + } + + #[pyo3(signature = (email_or_name, reset_token))] + /// Confirm a password reset request + /// + /// :param str email_or_name: The email or username of the user to confirm the reset request + /// :param str reset_token: The token sent to the user's email + /// + /// :return: A new temporary password + /// :rtype: str + pub fn password_reset_confirm( + &self, + email_or_name: String, + reset_token: String, + ) -> PyResult { + self.runtime.block_on( + self.client + .password_reset_confirm(email_or_name, reset_token), + ) + } + + #[pyo3(signature = (query=None, fields=None, limit=None, offset=None))] + /// List the snapshots currently available on the site + /// + /// .. note:: + /// This method supports :doc:`Query Tokens ` + /// + /// .. note:: + /// This method supports :doc:`Field selection ` + /// + /// .. note:: + /// This method supports :ref:`Result limits ` + /// + /// .. note:: + /// This method supports :ref:`Result offsets ` + /// + /// :param list[QueryToken] query: A list of query tokens used to filter the results + /// :param Optional[list[str]] fields: A list of fields to select for the returned object + /// :param Optional[int] limit: The maximum number of resources to return + /// :param Optional[int] offset: The number of resources to skip before returning the resulting resources + /// + /// :see: :class:`szurubooru_client.tokens.SnapshotNamedToken` for query filtering + /// + /// :return: A paged result of :class:`~szurubooru_client.models.SnapshotResource` + /// :rtype: :class:`~szurubooru_client.PagedResult` + pub fn list_snapshots( + &self, + query: Option>, + fields: Option>, + limit: Option, + offset: Option, + ) -> PyResult { + self.runtime + .block_on(self.client.list_snapshots(query, fields, limit, offset)) + } + + /// Retrieves simple statistics. ``featured_post`` is ``None`` if there is no featured post yet. + /// ``server_time`` is pretty much the same as the Date HTTP + /// field, only formatted in a manner consistent with other dates. Values in config key are + /// taken directly from the server config, with the exception of privilege array keys being + /// converted to lower camel case to match the API convention. + /// + /// :return: A Global Info object + /// :rtype: :class:`~szurubooru_client.models.GlobalInfo` + pub fn global_info(&self) -> PyResult { + self.runtime.block_on(self.client.global_info()) + } + + /// Puts a file from a given file path in temporary storage and assigns it a token that can be + /// used in other requests. + /// The files uploaded that way are deleted after a short while so clients shouldn't use it + /// as a free upload service. + /// + /// :param Path|str file_path: The path to the file to upload from the local filesystem + /// + /// :return: A token that represents the uploaded image + /// :rtype: str + pub fn upload_temporary_file(&self, file_path: PathBuf) -> PyResult { + self.runtime + .block_on(self.client.upload_temporary_file(file_path)) + } +} diff --git a/szurubooru-client/src/tokens.rs b/szurubooru-client/src/tokens.rs index a7474b0..35c6b23 100644 --- a/szurubooru-client/src/tokens.rs +++ b/szurubooru-client/src/tokens.rs @@ -2,6 +2,8 @@ //! warned that the types here help with the Type safety for the Tag names only. It does //! not guarantee that a given API endpoint will support the given tag. +#[cfg(feature = "python")] +use pyo3::{exceptions::PyValueError, prelude::*}; use std::fmt::Display; use strum_macros::AsRefStr; @@ -22,7 +24,8 @@ pub trait ToQueryString { } /// A query token using for searching posts, tags and pools -#[derive(Debug)] +#[derive(Debug, Clone)] +#[cfg_attr(all(feature = "python"), pyclass(module = "szurubooru_client.tokens"))] pub struct QueryToken { /// The key for this token. For `foo:bar` this would be `foo` pub key: String, @@ -116,7 +119,7 @@ impl QueryToken { /// let liked_posts = QueryToken::special(PostSpecialToken::Liked); /// client.request().list_posts(Some(&vec![liked_posts])); /// ``` - pub fn special(key: impl SpecialToken) -> Self { + pub fn special(key: impl AsRef) -> Self { QueryToken::anonymous(key) } @@ -139,6 +142,210 @@ impl QueryToken { } } +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pyfunction)] +/// Generates a named token. Named tokens are used to filter resources returned by the API. +/// An example of this would be returning posts with a certain safety value. +/// +/// This function will accept any string, but if you want to be more sure about your code you +/// can use any of the :ref:`Named token ` types listed below. +/// See the example below. +/// +/// :param key: String or Named field +/// :param value: The string or int value to use to filter by +/// :returns: The named query token +/// :rtype: QueryToken +/// +/// ----- +/// Usage +/// ----- +/// This lists all posts that are marked as 'safe'. +/// +/// ```python +/// client.list_posts(query=[named_token(PostNamedToken.Safety, 'safe')]) +/// ``` +/// +/// Which is equivalent to the possibly more error-prone: +/// +/// ```python +/// client.list_posts(query=[named_token("safety", 'safe')]) +/// ``` +pub fn named_token(key: &Bound<'_, PyAny>, value: &Bound<'_, PyAny>) -> PyResult { + QueryToken::token_py(key, value) +} + +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pyfunction)] +/// Generates a sorting token. Sorting tokens are used to sort resources returned by the API. +/// An example of this would be returning posts by score descending. +/// +/// This function will accept any string, but if you want to be more sure about your code you +/// can use any of the :ref:`Sort token ` types listed below. See the example below. +/// +/// :param key: String or Sort field name +/// :returns: The sort query token +/// :rtype: QueryToken +/// +/// ----- +/// Usage +/// ----- +/// This lists posts by score descending: +/// +/// ```python +/// client.list_posts(query=[-sort_token(PostSortToken.Score)]) +/// ``` +/// +/// Which is equivalent to the possibly more error-prone: +/// +/// ```python +/// client.list_posts(query=[-sort_token("score")]) +/// ``` +pub fn sort_token(key: &Bound<'_, PyAny>) -> PyResult { + QueryToken::sort_py(key) +} + +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pyfunction)] +/// Generates an anonymous token. Anonymous tokens are tokens that don't require some +/// sort of prefix to be used in a search. What the anonymous tag corresponds to depends +/// on the type of resource you're listing. For example, when listing posts the anonymous +/// tags correspond to post tags +/// +/// +/// :param str key: The anonymous token to create +/// :returns: The anonymous query token +/// :rtype: QueryToken +/// +/// ----- +/// Usage +/// ----- +/// +/// ```python +/// client.list_posts(fields=[anonymous_token("cat")]) +/// ``` +pub fn anonymous_token(key: &Bound<'_, PyAny>) -> PyResult { + QueryToken::anonymous_py(key) +} + +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pyfunction)] +/// Special tokens are a very limited set of tokens supported by the ``list_posts`` API. +/// They include being able to filter by posts that the current user has upvoted, or favorited. +/// See :class:`PostSpecialToken` for all the supported token names. +/// +/// :param key: The special token name, string or ``PostSpecialToken`` +/// +/// ----- +/// Usage +/// ----- +/// +/// Selects posts with score of 0, without comments and without favorites +/// +/// ```python +/// client.list_post(fields=[special_token(PostSpecialToken.Tumbleweed)]) +/// ``` +pub fn special_token(key: &Bound<'_, PyAny>) -> PyResult { + QueryToken::special_py(key) +} + +#[cfg(feature = "python")] +#[cfg_attr(all(feature = "python"), pymethods)] +impl QueryToken { + #[pyo3(name = "__str__")] + /// Generates a string representation of this QueryToken + pub fn to_python_string(&self) -> PyResult { + Ok(format!("QueryToken(\"{}\", \"{}\")", self.key, self.value)) + } + + #[pyo3(name = "__repr__")] + /// Generates a string representation of this QueryToken + pub fn to_python_repr(&self) -> PyResult { + self.to_python_string() + } + + #[pyo3(name = "token")] + #[staticmethod] + #[doc(hidden)] + pub fn token_py(key: &Bound<'_, PyAny>, value: &Bound<'_, PyAny>) -> PyResult { + let value = if let Ok(value) = value.extract::() { + value.to_string() + } else { + value.extract::()? + }; + + if let Ok(tnt) = key.extract::() { + Ok(QueryToken::token(tnt, value)) + } else if let Ok(pnt) = key.extract::() { + Ok(QueryToken::token(pnt, value)) + } else if let Ok(pnt) = key.extract::() { + Ok(QueryToken::token(pnt, value)) + } else if let Ok(comment) = key.extract::() { + Ok(QueryToken::token(comment, value)) + } else if let Ok(user) = key.extract::() { + Ok(QueryToken::token(user, value)) + } else if let Ok(x) = key.extract::() { + Ok(QueryToken::token(x, value)) + } else if let Ok(strvalue) = key.extract::() { + Ok(QueryToken::token(strvalue, value)) + } else { + Err(PyErr::new::("Invalid value type for key")) + } + } + + #[pyo3(name = "sort")] + #[staticmethod] + #[doc(hidden)] + pub fn sort_py(key: &Bound<'_, PyAny>) -> PyResult { + if let Ok(tnt) = key.extract::() { + Ok(QueryToken::sort(tnt)) + } else if let Ok(pnt) = key.extract::() { + Ok(QueryToken::sort(pnt)) + } else if let Ok(pnt) = key.extract::() { + Ok(QueryToken::sort(pnt)) + } else if let Ok(comment) = key.extract::() { + Ok(QueryToken::sort(comment)) + } else if let Ok(user) = key.extract::() { + Ok(QueryToken::sort(user)) + } else if let Ok(strvalue) = key.extract::() { + Ok(QueryToken::sort(strvalue)) + } else { + Err(PyErr::new::("Invalid value type for key")) + } + } + + #[pyo3(name = "anonymous")] + #[staticmethod] + #[doc(hidden)] + pub fn anonymous_py(key: &Bound<'_, PyAny>) -> PyResult { + let key = key.extract::()?; + Ok(QueryToken::anonymous(key)) + } + + #[pyo3(name = "special")] + #[staticmethod] + #[doc(hidden)] + pub fn special_py(key: &Bound<'_, PyAny>) -> PyResult { + if let Ok(special) = key.extract::() { + Ok(QueryToken::special(special)) + } else if let Ok(strvalue) = key.extract::() { + Ok(QueryToken::special(strvalue)) + } else { + Err(PyErr::new::("Invalid value type for key")) + } + } + + #[pyo3(name = "negate")] + #[doc(hidden)] + pub fn negate_py(&self) -> PyResult { + Ok(self.negate()) + } + + /// Negates the query token. Would turn ``konosuba`` into ``-konosuba`` + pub fn __neg__(&self) -> PyResult { + Ok(self.negate()) + } +} + impl Display for QueryToken { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let suffix = if !self.value.is_empty() { @@ -157,8 +364,12 @@ impl ToQueryString for Vec { } } -#[derive(Debug, AsRefStr)] +#[derive(Debug, AsRefStr, PartialEq, Eq, Clone)] #[strum(serialize_all = "kebab-case")] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.tokens") +)] /// Type-safe named query tokens for use with [list_tags](crate::SzurubooruRequest::list_tags) pub enum TagNamedToken { /// having given name (accepts wildcards) @@ -188,8 +399,28 @@ pub enum TagNamedToken { } impl NamedToken for TagNamedToken {} -#[derive(Debug, AsRefStr)] +/*#[cfg(feature="python")] +impl<'py> FromPyObject<'py> for TagNamedToken { + fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { + /*use pyo3::exceptions::PyTypeError; + if ob.is_instance_of::() { + Ok() + } + let strvalue = ob.extract::()?; + match TagNamedToken::from_str(&strvalue) { + Ok(tnt) => Ok(tnt), + Err(_) => Err(PyTypeError::new_err("Invalid variant")) + }*/ + Ok(ob.downcast_into_exact::()?.) + } +}*/ + +#[derive(Debug, AsRefStr, Eq, PartialEq, Clone)] #[strum(serialize_all = "kebab-case")] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.tokens") +)] /// Type-safe sort query tokens for use with [list_tags](crate::SzurubooruRequest::list_tags) pub enum TagSortToken { /// as random as it can get @@ -223,8 +454,12 @@ pub enum TagSortToken { } impl SortableToken for TagSortToken {} -#[derive(Debug, AsRefStr)] +#[derive(Debug, AsRefStr, PartialEq, Eq, Clone)] #[strum(serialize_all = "kebab-case")] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.tokens") +)] /// Type-safe named query tokens for use with [list_posts](crate::SzurubooruRequest::list_posts) pub enum PostNamedToken { /// having given post number @@ -259,8 +494,9 @@ pub enum PostNamedToken { RelationCount, /// having been featured given number of times FeatureCount, - /// given type of posts. `value` can be either `image`, `animation` (or `animated` or `anim`), - /// `flash` (or `swf`) or `video` (or `webm`). Use [models::PostType] for type-safe values + /// given type of posts. The value can be either `image`, `animation` (or `animated` or `anim`), + /// `flash` (or `swf`) or `video` (or `webm`). Use [PostType](crate::models::PostType) + /// for type-safe values Type, /// having given SHA1 checksum ContentChecksum, @@ -312,16 +548,20 @@ pub enum PostNamedToken { FeatureDate, /// alias of [PostNamedToken::FeatureDate] FeatureTime, - /// having given safety. can be either `safe`, `sketchy` (or `questionable`) or `unsafe` - /// Use [models::PostSafety] for the type-safe version + /// Post safety. Can be either `safe`, `sketchy` (or `questionable`) or `unsafe` + /// Use [PostSafety](crate::models::PostSafety) for the type-safe version Safety, /// alias of [PostNamedToken::Safety] Rating, } impl NamedToken for PostNamedToken {} -#[derive(Debug, AsRefStr)] +#[derive(Debug, AsRefStr, PartialEq, Eq, Clone)] #[strum(serialize_all = "kebab-case")] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.tokens") +)] /// Type-safe sort query tokens for use with [list_posts](crate::SzurubooruRequest::list_posts) pub enum PostSortToken { /// as random as it can get @@ -364,7 +604,7 @@ pub enum PostSortToken { Date, /// alias of [PostSortToken::CreationDate] Time, - /// like [PostSortToken::CreationDate], only looks at last edit time + /// like [PostSortToken::CreationDate], only looks at last edit time instead LastEditDate, /// alias of [PostSortToken::LastEditDate] LastEditTime, @@ -387,8 +627,12 @@ pub enum PostSortToken { } impl SortableToken for PostSortToken {} -#[derive(Debug, AsRefStr)] +#[derive(Debug, AsRefStr, PartialEq, Eq, Clone)] #[strum(serialize_all = "kebab-case")] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.tokens") +)] /// Type-safe special query tokens for use with [list_posts](crate::SzurubooruRequest::list_posts) pub enum PostSpecialToken { /// posts liked by currently logged-in user @@ -402,8 +646,12 @@ pub enum PostSpecialToken { } impl SpecialToken for PostSpecialToken {} -#[derive(Debug, AsRefStr)] +#[derive(Debug, AsRefStr, PartialEq, Eq, Clone)] #[strum(serialize_all = "kebab-case")] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.tokens") +)] /// Type-safe named query tokens for use with [list_pools](crate::SzurubooruRequest::list_pools) pub enum PoolNamedToken { /// having given name (accepts wildcards) @@ -427,8 +675,12 @@ pub enum PoolNamedToken { } impl NamedToken for PoolNamedToken {} -#[derive(Debug, AsRefStr)] +#[derive(Debug, AsRefStr, PartialEq, Eq, Clone)] #[strum(serialize_all = "kebab-case")] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.tokens") +)] /// Type-safe sort query tokens for use with [list_pools](crate::SzurubooruRequest::list_pools) pub enum PoolSortToken { /// as random as it can get @@ -454,8 +706,12 @@ pub enum PoolSortToken { } impl SortableToken for PoolSortToken {} -#[derive(Debug, AsRefStr)] +#[derive(Debug, AsRefStr, PartialEq, Eq, Clone)] #[strum(serialize_all = "kebab-case")] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.tokens") +)] /// Type-safe named query tokens for use with /// [list_comments](crate::SzurubooruRequest::list_comments) pub enum CommentNamedToken { @@ -484,8 +740,12 @@ pub enum CommentNamedToken { } impl NamedToken for CommentNamedToken {} -#[derive(Debug, AsRefStr)] +#[derive(Debug, AsRefStr, PartialEq, Eq, Clone)] #[strum(serialize_all = "kebab-case")] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.tokens") +)] /// Type-safe sort query tokens for use with /// [list_comments](crate::SzurubooruRequest::list_comments) pub enum CommentSortToken { @@ -512,8 +772,12 @@ pub enum CommentSortToken { } impl SortableToken for CommentSortToken {} -#[derive(Debug, AsRefStr)] +#[derive(Debug, AsRefStr, PartialEq, Eq, Clone)] #[strum(serialize_all = "kebab-case")] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.tokens") +)] /// Type-safe named query tokens for use with [list_users](crate::SzurubooruRequest::list_users) pub enum UserNamedToken { /// having given name (accepts wildcards) @@ -533,8 +797,12 @@ pub enum UserNamedToken { } impl NamedToken for UserNamedToken {} -#[derive(Debug, AsRefStr)] +#[derive(Debug, AsRefStr, PartialEq, Eq, Clone)] #[strum(serialize_all = "kebab-case")] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.tokens") +)] /// Type-safe sort query tokens for use with [list_users](crate::SzurubooruRequest::list_users) pub enum UserSortToken { /// as random as it can get @@ -556,8 +824,12 @@ pub enum UserSortToken { } impl SortableToken for UserNamedToken {} -#[derive(Debug, AsRefStr)] +#[derive(Debug, AsRefStr, PartialEq, Eq, Clone)] #[strum(serialize_all = "kebab-case")] +#[cfg_attr( + all(feature = "python"), + pyclass(eq, eq_int, module = "szurubooru_client.tokens") +)] /// Type-safe named query tokens for use with /// [list_snapshots](crate::SzurubooruRequest::list_snapshots) pub enum SnapshotNamedToken { diff --git a/szurubooru-client/szurubooru_client/__init__.py b/szurubooru-client/szurubooru_client/__init__.py new file mode 100644 index 0000000..4d3525d --- /dev/null +++ b/szurubooru-client/szurubooru_client/__init__.py @@ -0,0 +1,4 @@ +from .szurubooru_client import * + +__doc__ = szurubooru_client.__doc__ +__all__ = ["SzurubooruSyncClient", "SzurubooruAsyncClient", "SzuruClientError", "PagedResult"] diff --git a/szurubooru-client/szurubooru_client/models.py b/szurubooru-client/szurubooru_client/models.py new file mode 100644 index 0000000..cad5d84 --- /dev/null +++ b/szurubooru-client/szurubooru_client/models.py @@ -0,0 +1,34 @@ +from .szurubooru_client import _models + +AroundPostResult = _models.AroundPostResult +CommentResource = _models.CommentResource +GlobalInfo = _models.GlobalInfo +ImageSearchResult = _models.ImageSearchResult +ImageSearchSimilarPost = _models.ImageSearchSimilarPost +MicroPoolResource = _models.MicroPoolResource +MicroPostResource = _models.MicroPostResource +MicroTagResource = _models.MicroTagResource +MicroUserResource = _models.MicroUserResource +NoteResource = _models.NoteResource +PoolCategoryResource = _models.PoolCategoryResource +PoolResource = _models.PoolResource +PostResource = _models.PostResource +PostSafety = _models.PostSafety +PostType = _models.PostType +SnapshotCreationDeletionData = _models.SnapshotCreationDeletionData +SnapshotData = _models.SnapshotData +SnapshotModificationData = _models.SnapshotModificationData +SnapshotOperationType = _models.SnapshotOperationType +SnapshotResource = _models.SnapshotResource +SnapshotResourceType = _models.SnapshotResourceType +TagCategoryResource = _models.TagCategoryResource +TagResource = _models.TagResource +TagSibling = _models.TagSibling +UserAuthTokenResource = _models.UserAuthTokenResource +UserAvatarStyle = _models.UserAvatarStyle +UserRank = _models.UserRank +UserResource = _models.UserResource + +__doc__ = _models.__doc__ +#if hasattr(_models, "__all__"): +# __all__ = getattr(_models, "__all__") diff --git a/szurubooru-client/szurubooru_client/tokens.py b/szurubooru-client/szurubooru_client/tokens.py new file mode 100644 index 0000000..e988c4d --- /dev/null +++ b/szurubooru-client/szurubooru_client/tokens.py @@ -0,0 +1,23 @@ +from .szurubooru_client import _tokens + +anonymous_token = _tokens.anonymous_token +named_token = _tokens.named_token +sort_token = _tokens.sort_token +special_token = _tokens.special_token +CommentNamedToken = _tokens.CommentNamedToken +CommentSortToken = _tokens.CommentSortToken +PoolNamedToken = _tokens.PoolNamedToken +PoolSortToken = _tokens.PoolSortToken +PostNamedToken = _tokens.PostNamedToken +PostSortToken = _tokens.PostSortToken +PostSpecialToken = _tokens.PostSpecialToken +QueryToken = _tokens.QueryToken +SnapshotNamedToken = _tokens.SnapshotNamedToken +TagNamedToken = _tokens.TagNamedToken +TagSortToken = _tokens.TagSortToken +UserNamedToken = _tokens.UserNamedToken +UserSortToken = _tokens.UserSortToken + +__doc__ = _tokens.__doc__ +#if hasattr(_tokens, "__all__"): +# __all__ = getattr(_tokens, "__all__") diff --git a/szurubooru-integration-test/start_szurubooru.sh b/szurubooru-integration-test/start_szurubooru.sh deleted file mode 100755 index 43284ad..0000000 --- a/szurubooru-integration-test/start_szurubooru.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -#export MOUNT_DATA=szurubooru/data -export SERVER_MOUNT=./szurubooru/server -#export MOUNT_SQL=szurubooru/pgsql -export BASE_URL=http://localhost:9801 -export PORT=9801 - -docker compose down || true - -docker compose up -d diff --git a/szurubooru-integration-test/stop_szurubooru.sh b/szurubooru-integration-test/stop_szurubooru.sh deleted file mode 100755 index 5e50d10..0000000 --- a/szurubooru-integration-test/stop_szurubooru.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -docker compose down diff --git a/szurubooru-integration-test/szurubooru/data/.gitkeep b/szurubooru-integration-test/szurubooru/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/szurubooru-integration-test/szurubooru/pgsql/.gitkeep b/szurubooru-integration-test/szurubooru/pgsql/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/szurubooru-integration-test/szurubooru/server/.gitkeep b/szurubooru-integration-test/szurubooru/server/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/szurubooru-integration-test/avatar.jpg b/tests/avatar.jpg similarity index 100% rename from szurubooru-integration-test/avatar.jpg rename to tests/avatar.jpg diff --git a/szurubooru-integration-test/folly.jpg b/tests/folly.jpg similarity index 100% rename from szurubooru-integration-test/folly.jpg rename to tests/folly.jpg diff --git a/szurubooru-integration-test/folly1.jpg b/tests/folly1.jpg similarity index 100% rename from szurubooru-integration-test/folly1.jpg rename to tests/folly1.jpg diff --git a/szurubooru-integration-test/folly2.jpg b/tests/folly2.jpg similarity index 100% rename from szurubooru-integration-test/folly2.jpg rename to tests/folly2.jpg diff --git a/szurubooru-integration-test/folly3.jpg b/tests/folly3.jpg similarity index 100% rename from szurubooru-integration-test/folly3.jpg rename to tests/folly3.jpg diff --git a/szurubooru-integration-test/folly3_thumb.jpg b/tests/folly3_thumb.jpg similarity index 100% rename from szurubooru-integration-test/folly3_thumb.jpg rename to tests/folly3_thumb.jpg diff --git a/szurubooru-integration-test/folly4.jpg b/tests/folly4.jpg similarity index 100% rename from szurubooru-integration-test/folly4.jpg rename to tests/folly4.jpg diff --git a/tests/python-sync/config.yaml b/tests/python-sync/config.yaml new file mode 100644 index 0000000..26ef126 --- /dev/null +++ b/tests/python-sync/config.yaml @@ -0,0 +1,179 @@ +# rather than editing this file, it is strongly suggested to create config.yaml +# and override only what you need. + +# shown in the website title and on the front page +name: integrationland +# full url to the homepage of this szurubooru site, with no trailing slash +domain: https://localhost:9802 +# used to salt the users' password hashes and generate filenames for static content +secret: awdopk1mdqsapdoawd21!!!#asw + +# Delete thumbnails and source files on post delete +# Original functionality is no, to mitigate the impacts of admins going +# on unchecked post purges. +delete_source_files: no + +thumbnails: + avatar_width: 300 + avatar_height: 300 + post_width: 300 + post_height: 300 + +# settings used to download files from the web on behalf of the api users +user_agent: +max_dl_filesize: 25.0E+6 # maximum filesize limit in bytes + +# automatically convert animated GIF uploads to video formats +convert: + gif: + to_webm: false + to_mp4: false + +# allow posts to be uploaded even if some image processing errors occur +allow_broken_uploads: false + +# used to send password reset e-mails +smtp: + host: # example: localhost + port: # example: 25 + user: # example: bot + pass: # example: groovy123 + from: # example: noreply@example.com + # if host is left empty the password reset feature will be disabled, + # in which case it is recommended to fill contactEmail so that users + # know who to contact when they want to reset their password + +contact_email: # example: bob@example.com. Meant for manual password reset procedures + +enable_safety: yes + +tag_name_regex: ^\S+$ +tag_category_name_regex: ^[^\s%+#/]+$ + +pool_name_regex: ^\S+$ +pool_category_name_regex: ^[^\s%+#/]+$ + +# don't make these more restrictive unless you want to annoy people; if you do +# customize them, make sure to update the instructions in the registration form +# template as well. +password_regex: '^.{5,}$' +user_name_regex: '^[a-zA-Z0-9_-]{1,32}$' + +# webhooks to call when events occur (such as post/tag/user/etc. changes) +# the listed urls will be called with a HTTP POST request with a payload +# containing a snapshot resource as JSON. See doc/API.md for details +webhooks: +# - https://api.example.com/webhooks/ + +default_rank: regular + +privileges: + 'users:create:self': anonymous # Registration permission + 'users:create:any': administrator + 'users:list': regular + 'users:view': regular + 'users:edit:any:name': moderator + 'users:edit:any:pass': moderator + 'users:edit:any:email': moderator + 'users:edit:any:avatar': moderator + 'users:edit:any:rank': moderator + 'users:edit:self:name': regular + 'users:edit:self:pass': regular + 'users:edit:self:email': regular + 'users:edit:self:avatar': regular + 'users:edit:self:rank': moderator # one can't promote themselves or anyone to upper rank than their own. + 'users:delete:any': administrator + 'users:delete:self': regular + + 'user_tokens:list:any': administrator + 'user_tokens:list:self': regular + 'user_tokens:create:any': administrator + 'user_tokens:create:self': regular + 'user_tokens:edit:any': administrator + 'user_tokens:edit:self': regular + 'user_tokens:delete:any': administrator + 'user_tokens:delete:self': regular + + 'posts:create:anonymous': regular + 'posts:create:identified': regular + 'posts:list': anonymous + 'posts:reverse_search': regular + 'posts:view': anonymous + 'posts:view:featured': anonymous + 'posts:edit:content': power + 'posts:edit:flags': regular + 'posts:edit:notes': regular + 'posts:edit:relations': regular + 'posts:edit:safety': power + 'posts:edit:source': regular + 'posts:edit:tags': regular + 'posts:edit:thumbnail': power + 'posts:feature': moderator + 'posts:delete': moderator + 'posts:score': regular + 'posts:merge': moderator + 'posts:favorite': regular + 'posts:bulk-edit:tags': power + 'posts:bulk-edit:safety': power + 'posts:bulk-edit:delete': power + + 'tags:create': regular + 'tags:edit:names': power + 'tags:edit:category': power + 'tags:edit:description': power + 'tags:edit:implications': power + 'tags:edit:suggestions': power + 'tags:list': regular + 'tags:view': anonymous + 'tags:merge': moderator + 'tags:delete': moderator + + 'tag_categories:create': moderator + 'tag_categories:edit:name': moderator + 'tag_categories:edit:color': moderator + 'tag_categories:edit:order': moderator + 'tag_categories:list': anonymous + 'tag_categories:view': anonymous + 'tag_categories:delete': moderator + 'tag_categories:set_default': moderator + + 'pools:create': regular + 'pools:edit:names': power + 'pools:edit:category': power + 'pools:edit:description': power + 'pools:edit:posts': power + 'pools:list': regular + 'pools:view': anonymous + 'pools:merge': moderator + 'pools:delete': moderator + + 'pool_categories:create': moderator + 'pool_categories:edit:name': moderator + 'pool_categories:edit:color': moderator + 'pool_categories:list': anonymous + 'pool_categories:view': anonymous + 'pool_categories:delete': moderator + 'pool_categories:set_default': moderator + + 'comments:create': regular + 'comments:delete:any': moderator + 'comments:delete:own': regular + 'comments:edit:any': moderator + 'comments:edit:own': regular + 'comments:list': regular + 'comments:view': regular + 'comments:score': regular + + 'snapshots:list': power + + 'uploads:create': regular + 'uploads:use_downloader': power + +## ONLY SET THESE IF DEPLOYING OUTSIDE OF DOCKER +#debug: 0 # generate server logs? +#show_sql: 0 # show sql in server logs? +#data_url: /data/ +#data_dir: /var/www/data +## usage: schema://user:password@host:port/database_name +## example: postgres://szuru:dog@localhost:5432/szuru_test +#database: diff --git a/szurubooru-integration-test/docker-compose.yml b/tests/python-sync/docker-compose.yml similarity index 91% rename from szurubooru-integration-test/docker-compose.yml rename to tests/python-sync/docker-compose.yml index a51c8a7..2dc35e7 100644 --- a/szurubooru-integration-test/docker-compose.yml +++ b/tests/python-sync/docker-compose.yml @@ -24,7 +24,7 @@ services: THREADS: volumes: - "sz-data:/data" - - "${SERVER_MOUNT}/config.yaml:/opt/app/config.yaml" + - "./config.yaml:/opt/app/config.yaml" client: image: szurubooru/client:latest @@ -32,11 +32,11 @@ services: - server environment: BACKEND_HOST: server - BASE_URL: ${BASE_URL} + BASE_URL: http://localhost:9802 volumes: - "sz-data:/data:ro" ports: - - "${PORT}:80" + - "9802:80" sql: image: postgres:11-alpine diff --git a/tests/python-sync/requirements.txt b/tests/python-sync/requirements.txt new file mode 100644 index 0000000..aa5030d --- /dev/null +++ b/tests/python-sync/requirements.txt @@ -0,0 +1,6 @@ +Jinja2==3.1.4 +MarkupSafe==2.1.5 +maturin==1.7.0 +pdoc==14.6.0 +Pygments==2.18.0 +loguru==0.7.2 diff --git a/tests/python-sync/test.py b/tests/python-sync/test.py new file mode 100644 index 0000000..b0c6cef --- /dev/null +++ b/tests/python-sync/test.py @@ -0,0 +1,366 @@ +from szurubooru_client import * +from szurubooru_client.tokens import * +from szurubooru_client.models import * +import time, sys +from loguru import logger +import hashlib +import tempfile, pathlib + +def connect(): + logger.info("Connecting to Szurubooru instance") + anon_client = SzurubooruSyncClient("http://localhost:9802") + error = None + for i in range(5): + try: + anon_client.global_info() + except Exception as e: + error=e + time.sleep(5) + else: + logger.info("Connection successful!") + break + else: + logger.error("Could not connect to Szurubooru instance, error is {}", error) + sys.exit(1) + return anon_client + +def create_auth_client(client): + client.create_user("integration_user", "integration_password", rank=UserRank.Administrator) + auth_client = SzurubooruSyncClient("http://localhost:9802", username="integration_user", + password="integration_password", allow_insecure=True) + return auth_client + +def test_tag_categories(client): + logger.info("Testing tag categories") + logger.info("Listing tag categories") + tag_cats = client.list_tag_categories() + assert len(tag_cats) == 1 + + logger.info("Creating tag category") + result_tag_cat = client.create_tag_category("my_tag_cat", color="purple", order=1) + assert result_tag_cat.name == "my_tag_cat" + + tag_cats = client.list_tag_categories() + assert len(tag_cats) != 1 + assert tag_cats[1].name == "my_tag_cat" + + logger.info("Getting tag category") + get_tag_cat = client.get_tag_category("my_tag_cat") + assert get_tag_cat.color == result_tag_cat.color + + logger.info("Updating tag category") + update_tag_cat = client.update_tag_category("my_tag_cat", get_tag_cat.version, color="red") + assert update_tag_cat.color != get_tag_cat.color + + logger.info("Deleting tag category") + client.delete_tag_category("my_tag_cat", update_tag_cat.version) + tag_cats = client.list_tag_categories() + assert len(tag_cats) == 1 + +def test_tag(client): + logger.info("Testing tags") + logger.info("Listing tags") + + tags = client.list_tags() + assert len(tags.results) == 0 + + logger.info("Creating tag") + foo_tag = client.create_tag("foo", category="default", description="The foo tag") + assert foo_tag.names == ["foo"] + tags = client.list_tags() + assert len(tags.results) == 1 + + logger.info("Testing field selection") + tags = client.list_tags(fields=["version", "names", "category"]) + assert len(tags.results) == 1 + assert tags.results[0].description is None + + logger.info("Updating tag") + foo_tag = client.update_tag(foo_tag.names[0], version=foo_tag.version, description="The foo2 tag") + assert foo_tag.description == "The foo2 tag" + + logger.info("Getting tag") + foo_tag2 = client.get_tag("foo") + assert foo_tag.description == foo_tag2.description + + logger.info("Creating a second tag") + bar_tag = client.create_tag("bar", category="default", description="The bar tag") + assert bar_tag.names == ["bar"] + + logger.info("Merging tags") + foo_tag2 = client.merge_tags(bar_tag.names[0], bar_tag.version, + foo_tag2.names[0], foo_tag2.version) + tags = client.list_tags() + assert len(tags.results) == 1 + + logger.info("Deleting tag") + client.delete_tag(foo_tag2.names[0], foo_tag2.version) + +def test_creating_posts(client): + logger.info("Testing posts") + + logger.info("Listing posts") + posts = client.list_posts() + assert len(posts.results) == 0 + + logger.info("Creating post from URL") + wiki_post = client.create_post(url="https://upload.wikimedia.org/wikipedia/commons/thumb/5/5a/Maine_Coon_cat_by_Tomitheos.JPG/225px-Maine_Coon_cat_by_Tomitheos.JPG", + safety=PostSafety.Safe) + posts = client.list_posts() + assert len(posts.results) == 1 + + logger.info("Updating post") + wiki_post = client.update_post(wiki_post.id, wiki_post.version, source="Wikipedia") + assert wiki_post.source == "Wikipedia" + + logger.info("Deleting post") + client.delete_post(wiki_post.id, wiki_post.version) + posts = client.list_posts() + assert len(posts.results) == 0 + + logger.info("Testing upload by file path") + folly1 = client.create_post(file_path="../folly1.jpg", + tags=["maine_coon", "cat", "folly1"], + safety=PostSafety.Safe) + posts = client.list_posts() + assert len(posts.results) == 1 + + folly2 = client.create_post(file_path="../folly2.jpg", + tags=["maine_coon", "cat", "folly2"], + safety=PostSafety.Safe) + + logger.info("Testing upload with thumbnail") + folly3 = client.create_post(file_path="../folly3.jpg", + thumbnail_path="../folly3_thumb.jpg", + tags=["maine_coon", "cat", "folly3"], + safety=PostSafety.Safe) + + logger.info("Searching for a post with image") + folly3_search = client.post_for_image("../folly3.jpg") + assert folly3_search is not None + assert folly3_search.id == folly3.id + + logger.info("Reverse image searching") + reverse_search = client.reverse_image_search("../folly3.jpg") + assert reverse_search.exact_post.id == folly3.id + + logger.info("Testing temporary upload") + token = client.upload_temporary_file("../folly4.jpg") + folly4 = client.create_post(upload_token=token, tags=["maine_coon", "cat", "folly4"], + safety=PostSafety.Safe) + + logger.info("Querying by anonymous tag") + cat_posts = client.list_posts(query=[anonymous_token("cat")]) + assert len(cat_posts.results) == 4 + + logger.info("Querying by named tag") + cat_posts = client.list_posts(query=[named_token(PostNamedToken.Tag, "maine_coon")]) + assert len(cat_posts.results) == 4 + + logger.info("Testing pagination") + cat_posts = client.list_posts(limit=1) + assert cat_posts.total == 4 + assert len(cat_posts.results) == 1 + + cat_posts2 = client.list_posts(limit=1, offset=1) + assert cat_posts.results != cat_posts2.results + + logger.info("Testing tag siblings") + tag_occurrences = client.get_tag_siblings("maine_coon") + cat_occurrences = list(filter(lambda x: "cat" in x.tag.names, tag_occurrences)) + assert len(cat_occurrences) == 1 + + logger.info("Rating post") + client.rate_post(folly3.id, 1) + + logger.info("Testing rating error validation") + try: + client.rate_post(folly3.id, -2) + except ValueError: + assert True + else: + assert False + + logger.info("Favoriting post") + client.favorite_post(folly3.id) + + logger.info("Unfavoriting post") + client.unfavorite_post(folly3.id) + + logger.info("Featuring post") + client.set_featured_post(folly3.id) + + logger.info("Getting featured post") + featured_post = client.get_featured_post() + assert folly3.id == featured_post.id + + logger.info("Merging posts") + merged_post = client.merge_post(folly4.id, folly4.version, folly3.id, folly3.version) + assert merged_post.id == folly3.id + +def test_pool_categories(client): + logger.info("Testing pool categories") + + logger.info("Listing pool categories") + pool_cats = client.list_pool_categories() + assert len(pool_cats) != 0 + + logger.info("Creating pool category") + pool_cat = client.create_pool_category("cat_pool_category", color="purple") + assert pool_cat.color == "purple" + pool_dog = client.create_pool_category("dog_category", color="orange") + + logger.info("Updating pool category") + pool_cat = client.update_pool_category(pool_cat.name, pool_cat.version, color="white") + assert pool_cat.color == "white" + + logger.info("Getting pool category") + pool_dog = client.get_pool_category(pool_dog.name) + assert pool_dog.color == "orange" + + logger.info("Deleting pool category") + pool_dog = client.delete_pool_category(pool_dog.name, pool_dog.version) + + logger.info("Setting default pool category") + client.set_default_pool_category(pool_cat.name) + +def test_pools(client): + logger.info("Testing post pools") + logger.info("Listing post pools") + pools = client.list_pools() + assert len(pools.results) == 0 + + logger.info("Creating pools") + cat_pool = client.create_pool("cats_pool", category="cat_pool_category") + catz_pool = client.create_pool("catz_pool", category="cat_pool_category") + dogs_pool = client.create_pool("dogs_pool", category="cat_pool_category") + + logger.info("Getting pool") + cat_pool = client.get_pool(cat_pool.id) + + logger.info("Deleting pool") + client.delete_pool(dogs_pool.id, dogs_pool.version) + + logger.info("Updating pool") + f4_results = client.list_posts([anonymous_token("cat")]) + post_ids= list(map(lambda p: p.id, f4_results.results)) + cat_pool = client.update_pool(cat_pool.id, cat_pool.version, + posts=post_ids, description="All cat pictures") + assert len(cat_pool.posts) != 0 + + logger.info("Merging pools") + merged_pool = client.merge_pools(catz_pool.id, catz_pool.version, cat_pool.id, cat_pool.version) + assert merged_pool.id == cat_pool.id + +def test_comments(client): + logger.info("Testing post comments") + + logger.info("Listing post comments") + comment_list = client.list_comments() + assert len(comment_list.results) == 0 + + cat_results = client.list_posts([anonymous_token("cat")]) + post_id = cat_results.results[0].id + + logger.info("Creating comment") + comment = client.create_comment("Excellent cat!", post_id) + + logger.info("Updating comment") + comment = client.update_comment(comment.id, comment.version, text="Beautiful cat!") + assert comment.text == "Beautiful cat!" + + logger.info("Getting comment") + comment = client.get_comment(comment.id) + assert comment.text == "Beautiful cat!" + + logger.info("Getting all comments for post") + comment_list = client.list_comments([named_token(CommentNamedToken.Post, post_id)]) + assert len(comment_list.results) != 0 + + logger.info("Testing rating comments") + comment = client.rate_comment(comment.id, -1) + assert comment.own_score == -1 + + try: + client.rate_comment(comment.id, -2) + except SzuruClientError: + assert True + else: + assert False + + logger.info("Deleting comment") + client.delete_comment(comment.id, comment.version) + +def test_users(client): + logger.info("Testing users") + + logger.info("Listing users") + user_list = client.list_users() + + logger.info("Creating user with avatar") + user = client.create_user("iu2", "ipass2", rank=UserRank.Regular, avatar_path="../avatar.jpg") + assert user.avatar_style == UserAvatarStyle.Manual + + logger.info("Updating user") + user = client.update_user(user.name, user.version, rank=UserRank.Restricted) + + logger.info("Getting user") + user = client.get_user(user.name) + + logger.info("Deleting user") + client.delete_user(user.name, user.version) + + logger.info("Listing user tokens") + tokens = client.list_user_tokens("integration_user") + assert len(tokens) == 0 + + logger.info("Creating user token") + token = client.create_user_token("integration_user", "My token") + + logger.info("Updating user token") + token2 = client.update_user_token("integration_user", token.token, token.version, enabled=False) + assert token2.enabled == False + + logger.info("Deleting user token") + client.delete_user_token("integration_user", token2.token, token2.version) + +def test_snapshots(client): + logger.info("Testing snapshots") + + logger.info("Listing snapshots") + snapshot_list = client.list_snapshots() + assert len(snapshot_list.results) != 0 + +def test_downloads(client): + logger.info("Testing downloads") + f3_hasher = hashlib.new("sha1") + + with open("../folly3.jpg", "rb") as f: + while (byte := f.read(1)): + f3_hasher.update(byte) + + f3_post = client.list_posts([anonymous_token("folly3")]).results[0] + with tempfile.TemporaryDirectory() as tmpdirname: + tmpdir = pathlib.Path(tmpdirname) + fname = tmpdir / "folly3_dl.jpg" + #fname = "../folly4_dl.jpg" + client.download_image_to_path(f3_post.id, fname) + dl_hasher = hashlib.new("sha1") + with open(fname, "rb") as f: + while (byte := f.read(1)): + dl_hasher.update(byte) + + assert f3_hasher.hexdigest() == dl_hasher.hexdigest() + +if __name__ == "__main__": + client = connect() + client = create_auth_client(client) + test_tag_categories(client) + test_tag(client) + test_creating_posts(client) + test_pool_categories(client) + test_pools(client) + test_comments(client) + test_users(client) + test_snapshots(client) + test_downloads(client) diff --git a/tests/python-sync/test.sh b/tests/python-sync/test.sh new file mode 100755 index 0000000..fe8aca1 --- /dev/null +++ b/tests/python-sync/test.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -e + +pip uninstall -y szurubooru_client || true +pip install -r requirements.txt +maturin develop -F python -m ../../szurubooru-client/Cargo.toml +docker compose down +docker compose up -d +python test.py && docker compose down diff --git a/szurubooru-integration-test/Cargo.toml b/tests/rust-test/Cargo.toml similarity index 75% rename from szurubooru-integration-test/Cargo.toml rename to tests/rust-test/Cargo.toml index 6226773..b96d1dd 100644 --- a/szurubooru-integration-test/Cargo.toml +++ b/tests/rust-test/Cargo.toml @@ -1,12 +1,12 @@ [package] -name = "szurubooru-integration-test" +name = "rust-test" version = "0.1.0" edition = "2021" [dependencies] chrono = "0.4.38" sha1 = "0.10.6" -szurubooru-client = { path = "../szurubooru-client" } +szurubooru-client = { path = "../../szurubooru-client" } tempfile = "3.12.0" tokio = { version = "1.39.2", features = ["full", "test-util", "tracing"] } tracing = "0.1.40" diff --git a/szurubooru-integration-test/szurubooru/server/config.yaml b/tests/rust-test/config.yaml similarity index 100% rename from szurubooru-integration-test/szurubooru/server/config.yaml rename to tests/rust-test/config.yaml diff --git a/tests/rust-test/docker-compose.yml b/tests/rust-test/docker-compose.yml new file mode 100644 index 0000000..9ff2e9d --- /dev/null +++ b/tests/rust-test/docker-compose.yml @@ -0,0 +1,51 @@ +## Example Docker Compose configuration +## +## Use this as a template to set up docker-compose, or as guide to set up other +## orchestration services +version: '2' + +services: + + server: + image: szurubooru/server:latest + depends_on: + - sql + environment: + ## These should be the names of the dependent containers listed below, + ## or FQDNs/IP addresses if these services are running outside of Docker + POSTGRES_HOST: sql + ## Credentials for database: + POSTGRES_USER: pguser + POSTGRES_PASSWORD: pgpassword + ## Commented Values are Default: + #POSTGRES_DB: defaults to same as POSTGRES_USER + #POSTGRES_PORT: 5432 + #LOG_SQL: 0 (1 for verbose SQL logs) + THREADS: + volumes: + - "sz-data:/data" + - "./config.yaml:/opt/app/config.yaml" + + client: + image: szurubooru/client:latest + depends_on: + - server + environment: + BACKEND_HOST: server + BASE_URL: http://localhost:9801 + volumes: + - "sz-data:/data:ro" + ports: + - "9801:80" + + sql: + image: postgres:11-alpine + restart: unless-stopped + environment: + POSTGRES_USER: pguser + POSTGRES_PASSWORD: pgpassword + #volumes: + # - "${MOUNT_SQL}/sqldata:/var/lib/postgresql/data" + +volumes: + sz-data: diff --git a/szurubooru-integration-test/src/main.rs b/tests/rust-test/src/main.rs similarity index 96% rename from szurubooru-integration-test/src/main.rs rename to tests/rust-test/src/main.rs index 072e6ec..812ead2 100644 --- a/szurubooru-integration-test/src/main.rs +++ b/tests/rust-test/src/main.rs @@ -77,21 +77,10 @@ async fn main() -> Result<(), Box> { #[tracing::instrument] async fn start_instance() -> SzurubooruClient { - info!("Starting Szurubooru instance..."); + info!("Connecting to Szurubooru instance..."); let anon_client = SzurubooruClient::new_anonymous("http://localhost:9801", true) .expect("Can't create anonymous client"); - Command::new("sh") - .current_dir(env!("CARGO_MANIFEST_DIR")) - .arg("-c") - .arg("./start_szurubooru.sh") - .stderr(Stdio::null()) - .stdout(Stdio::null()) - .stdin(Stdio::null()) - .status() - .await - .expect("Failed to start szurubooru"); - let mut connected = false; let mut error = None; @@ -220,7 +209,11 @@ async fn test_tags(client: &SzurubooruClient) { info!("Testing field selection"); let tag_list = client - .with_fields(vec!["version", "names", "category"]) + .with_fields(vec![ + "version".to_string(), + "names".to_string(), + "category".to_string(), + ]) .list_tags(None) .await .expect("Could not list tags"); @@ -269,15 +262,15 @@ async fn test_tags(client: &SzurubooruClient) { info!("Merging tags"); let merge_tag = MergeTagsBuilder::default() - .remove_version(bar_tag.version) - .remove(bar_tag.names.as_ref().unwrap().first().unwrap()) + .remove_tag_version(bar_tag.version) + .remove_tag(bar_tag.names.as_ref().unwrap().first().unwrap().clone()) .merge_to_version(tag_res3.version) - .merge_to(tag_res3.names.as_ref().unwrap().first().unwrap()) + .merge_to_tag(tag_res3.names.as_ref().unwrap().first().unwrap().clone()) .build() .expect("Could not create merge tags object"); let merged_tag = client .request() - .merge_tag(&merge_tag) + .merge_tags(&merge_tag) .await .expect("Could not merge tags"); assert_eq!(tag_res3.names, merged_tag.names); @@ -357,7 +350,7 @@ async fn test_creating_posts(client: &SzurubooruClient) { .safety(PostSafety::Safe) .build() .expect("Could not build first upload object"); - let folly1_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("folly1.jpg"); + let folly1_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../folly1.jpg"); let mut folly1_file = File::open(&folly1_path).expect(&format!("Could not open file {folly1_path:?}")); let _folly1_post = client @@ -376,7 +369,7 @@ async fn test_creating_posts(client: &SzurubooruClient) { .safety(PostSafety::Safe) .build() .expect("Could not build second upload object"); - let folly2_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("folly2.jpg"); + let folly2_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../folly2.jpg"); let _folly2_post = client .request() .create_post_from_file_path(folly2_path, None::, &folly2_obj) @@ -393,8 +386,8 @@ async fn test_creating_posts(client: &SzurubooruClient) { .safety(PostSafety::Safe) .build() .expect("Could not build third upload object"); - let folly3_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("folly3.jpg"); - let folly3_thumbnail = Path::new(env!("CARGO_MANIFEST_DIR")).join("folly3_thumb.jpg"); + let folly3_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../folly3.jpg"); + let folly3_thumbnail = Path::new(env!("CARGO_MANIFEST_DIR")).join("../folly3_thumb.jpg"); let folly3_post = client .request() .create_post_from_file_path(&folly3_path, Some(folly3_thumbnail), &folly3_obj) @@ -404,10 +397,10 @@ async fn test_creating_posts(client: &SzurubooruClient) { info!("Searching for post by image"); let matching_posts = client .request() - .posts_for_file_path(&folly3_path) + .post_for_file_path(&folly3_path) .await .expect("Could not search for post by file path"); - assert_eq!(matching_posts.results.first().unwrap(), &folly3_post); + assert_eq!(&matching_posts.unwrap(), &folly3_post); info!("Reverse searching"); let matching_posts = client @@ -418,7 +411,7 @@ async fn test_creating_posts(client: &SzurubooruClient) { assert!(matching_posts.exact_post.is_some()); info!("Testing temporary upload"); - let folly4_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("folly4.jpg"); + let folly4_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../folly4.jpg"); let folly4_temp_upload = client .request() .upload_temporary_file_from_path(folly4_path) @@ -681,10 +674,10 @@ async fn test_pools(client: &SzurubooruClient) { info!("Merging pools"); let merge_pool_obj = MergePoolBuilder::default() - .remove_version(catz_pool.version.unwrap()) - .remove(catz_pool.id.unwrap()) + .remove_pool_version(catz_pool.version.unwrap()) + .remove_pool(catz_pool.id.unwrap()) .merge_to_version(cat_pool.version.unwrap()) - .merge_to(cat_pool.id.unwrap()) + .merge_to_pool(cat_pool.id.unwrap()) .build() .expect("Unable to build merge object"); let _cat_pool = client @@ -790,7 +783,7 @@ async fn test_users(client: &SzurubooruClient) { // Create user is already tested above info!("Creating user with avatar"); - let avatar_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("avatar.jpg"); + let avatar_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../avatar.jpg"); let create_user = CreateUpdateUserBuilder::default() .name("iu2".to_string()) .password("ipass2".to_string()) @@ -892,7 +885,7 @@ async fn test_snapshots(client: &SzurubooruClient) { async fn test_downloads(client: &SzurubooruClient) { info!("Testing image download"); - let folly3_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("folly3.jpg"); + let folly3_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../folly3.jpg"); let mut f3_hasher = Sha1::new(); diff --git a/tests/rust-test/test.sh b/tests/rust-test/test.sh new file mode 100755 index 0000000..b8ecbbc --- /dev/null +++ b/tests/rust-test/test.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +cargo check +docker compose down +docker compose up -d +cargo run && docker compose down