diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..e6d862b --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,63 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Python application + +on: + push: + #branches: [ "main" ] + #pull_request: + #branches: [ "main" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + # PYTHON + + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + + # INSTALL PACKAGES + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 + # if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Install Poetry + uses: snok/install-poetry@v1 + + - name: Install dependencies with Poetry + run: | + poetry --version + poetry install --with dev + + # LINTING + + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + # TESTS + + - name: Test with pytest + env: + # set environment variables using repo secrets + TRUTHSOCIAL_USERNAME: ${{ secrets.TRUTHSOCIAL_USERNAME }} + TRUTHSOCIAL_PASSWORD: ${{ secrets.TRUTHSOCIAL_PASSWORD }} + run: | + poetry run pytest diff --git a/README.md b/README.md index 03dd057..f039505 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ truthbrush --help # will use your local copy of truthbrush If you prefer not to install Poetry in your root environment, you can also use Conda: ```sh -conda create -n truthbrush-env python=3.9 +conda create -n truthbrush-env python=3.10 conda activate truthbrush-env conda install -c conda-forge poetry @@ -157,6 +157,9 @@ pytest # optionally run tests with verbose logging outputs: pytest --log-cli-level=DEBUG -s + +# optionally run tests with suppressed warnings: +pytest --disable-pytest-warnings ``` Please format your code with `black`: @@ -165,6 +168,11 @@ Please format your code with `black`: black . ``` +### Continuous Integration + +The Continuous Integration build on GitHub Actions is controlled via the "python-app.yml" workflow file. To make the build pass, the environment variables `TRUTHSOCIAL_USERNAME` and `TRUTHSOCIAL_PASSWORD` must be set as GitHub repository secrets. + + ## Wishlist Support for the following capabilities is planned: diff --git a/poetry.lock b/poetry.lock index d08a804..8c53496 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "astroid" @@ -21,33 +21,33 @@ wrapt = [ [[package]] name = "black" -version = "24.8.0" +version = "24.10.0" description = "The uncompromising code formatter." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6"}, - {file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb"}, - {file = "black-24.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42"}, - {file = "black-24.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a"}, - {file = "black-24.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1"}, - {file = "black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af"}, - {file = "black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4"}, - {file = "black-24.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af"}, - {file = "black-24.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368"}, - {file = "black-24.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed"}, - {file = "black-24.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018"}, - {file = "black-24.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2"}, - {file = "black-24.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd"}, - {file = "black-24.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2"}, - {file = "black-24.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e"}, - {file = "black-24.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920"}, - {file = "black-24.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c"}, - {file = "black-24.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e"}, - {file = "black-24.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47"}, - {file = "black-24.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb"}, - {file = "black-24.8.0-py3-none-any.whl", hash = "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed"}, - {file = "black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f"}, + {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, + {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, + {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, + {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, + {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, + {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, + {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, + {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, + {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, + {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, + {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, + {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, + {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, + {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, + {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, + {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, + {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, + {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, + {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, + {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, + {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, + {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, ] [package.dependencies] @@ -61,7 +61,7 @@ typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] @@ -182,21 +182,21 @@ files = [ [[package]] name = "curl-cffi" -version = "0.7.2" +version = "0.7.3" description = "libcurl ffi bindings for Python, with impersonation support." optional = false python-versions = ">=3.8" files = [ - {file = "curl_cffi-0.7.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3756d31635f62004cc3b2280f9001b03294eacb58b4017f27cc2b645b2251880"}, - {file = "curl_cffi-0.7.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:3ec154c1f516364d9c52e6045be78724fc3eef711b6721cc855d32f1ad6a69c5"}, - {file = "curl_cffi-0.7.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24b084e5b0046c76a58902c2b985bdb2638d9b16af130b33b521a4c6d59792d6"}, - {file = "curl_cffi-0.7.2-cp38-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2caa3cd83ec2cb3ad5729311184324b03db181a0f1ca6a3abd25ba9319562396"}, - {file = "curl_cffi-0.7.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea2ebed4ff052fcd7417cfd20fdab9415eb47ea06ca0c31896971a1c768c9bf3"}, - {file = "curl_cffi-0.7.2-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:3ca68890e839b35134e063d7c57684e14a6bca5f68948f8a689e64569abe5a40"}, - {file = "curl_cffi-0.7.2-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:afa1230a4833246f45deb404b13676ac29c37476ec94bae0005dbde85f96be35"}, - {file = "curl_cffi-0.7.2-cp38-abi3-win32.whl", hash = "sha256:a4f58b83eab8a77f788444e347a0b4c877cf327ecb60299b5fa01e5c73b294ee"}, - {file = "curl_cffi-0.7.2-cp38-abi3-win_amd64.whl", hash = "sha256:83f22b65fddbc46d330e49bd0a44f73112d3f0a10378e8b31d5123df9db9fc72"}, - {file = "curl_cffi-0.7.2.tar.gz", hash = "sha256:4671f367671038b14ea2c25f49e27402686c602d0d640ac7d484ac5c1f66b538"}, + {file = "curl_cffi-0.7.3-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:890f1b0e1454977ff6bd388d29eb1eafb76bb8ef63d3c8b7539aafd4808a6cec"}, + {file = "curl_cffi-0.7.3-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:b5520bcf6284417e66c82728512e344b50bc0e9d8bd7949923b30558746b49a3"}, + {file = "curl_cffi-0.7.3-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43367efb8d48e9997cf7591084ed7529409ad98bb67e284ae152f2a15e4ee68e"}, + {file = "curl_cffi-0.7.3-cp38-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec6718bc5151b3e0ecc35aaf40078a39cc239405182c63fc95933eb7bff572dd"}, + {file = "curl_cffi-0.7.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a90bfd0bcc7bf0f30ef6de6f4e612ea1bdc238a48928845532109a0273f6275"}, + {file = "curl_cffi-0.7.3-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:3f9373ce27b20e65fb1e6cbea6e4411edca1f3b4440be68090b262eece728544"}, + {file = "curl_cffi-0.7.3-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a357d3da6aa8ccd41f1e6e422b9ff09ad4cc99b23b8f6299998cbfee6e690f01"}, + {file = "curl_cffi-0.7.3-cp38-abi3-win32.whl", hash = "sha256:fe01be42c27667353028ee06802cc32e3936496e645a39175db4cf7bea45bb10"}, + {file = "curl_cffi-0.7.3-cp38-abi3-win_amd64.whl", hash = "sha256:39e195a13b95bd7e4a1e8eb7804807b4504c462f6886fde01dfe4b7552fd965e"}, + {file = "curl_cffi-0.7.3.tar.gz", hash = "sha256:901012e21af899bdf1278fd9fcee5aad6931ed56e1f5a620ffa90220c9e79f10"}, ] [package.dependencies] diff --git a/test/api/groups_test.py b/test/api/groups_test.py new file mode 100644 index 0000000..dd74689 --- /dev/null +++ b/test/api/groups_test.py @@ -0,0 +1,34 @@ + + +#def test_group_search(client): +# +# group_name = "Make America Great Again" +# +# results = list(client.search(searchtype="groups", query=group_name)) +# +# # are these different pages? +# # should we return as a single list? +# assert len(results) == 4 # why four pages? +# # why do these have 20 (80 total) if the default limit is 40? +# assert len(results[0]["groups"]) == 20 +# assert len(results[1]["groups"]) == 20 +# assert len(results[2]["groups"]) == 20 +# assert len(results[3]["groups"]) == 20 +# +# assert results[0]["groups"][0]["display_name"] == group_name +# + + +def test_group_search_simplified(client): + group_name = "Make America Great Again" + + groups = client.search_simpler(resource_type="groups", query=group_name) + matching_groups = [group for group in groups if group["display_name"] == group_name] + assert len(matching_groups) == 1 + + +def test_group_posts(client): + group_id = "110228354005031735" # "Make America Great Again" + + timeline = client.group_posts(group_id) + assert len(timeline) == 20 diff --git a/test/test_api.py b/test/api/statuses_test.py similarity index 56% rename from test/test_api.py rename to test/api/statuses_test.py index c05d0c1..ee2cc64 100644 --- a/test/test_api.py +++ b/test/api/statuses_test.py @@ -1,80 +1,75 @@ -from datetime import datetime, timezone -from dateutil import parser as date_parse +from truthbrush.utils import as_datetime -import pytest -from truthbrush.api import Api +def test_pull_statuses(user_timeline): + assert len(user_timeline) > 25 # more than one page -@pytest.fixture(scope="module") -def api(): - return Api() - - -def as_datetime(date_str): - """Datetime formatter function. Ensures timezone is UTC. Consider moving to Api class.""" - return date_parse.parse(date_str).replace(tzinfo=timezone.utc) - + # the posts are in reverse chronological order: + latest, earliest = user_timeline[0], user_timeline[-1] + latest_at = as_datetime(latest["created_at"]) + earliest_at = as_datetime(earliest["created_at"]) + assert earliest_at < latest_at -def test_lookup(api): - user = api.lookup(user_handle="realDonaldTrump") - assert list(user.keys()) == [ + # POST INFO + # contains status info + assert list(latest.keys()) == [ "id", - "username", - "acct", - "display_name", - "locked", - "bot", - "discoverable", - "group", "created_at", - "note", + "in_reply_to_id", + "quote_id", + "in_reply_to_account_id", + "sensitive", + "spoiler_text", + "visibility", + "language", + "uri", "url", - "avatar", - "avatar_static", - "header", - "header_static", - "followers_count", - "following_count", - "statuses_count", - "last_status_at", - "verified", - "location", - "website", - "accepting_messages", - "chats_onboarded", - "feeds_onboarded", - "show_nonmember_group_statuses", - "pleroma", + "content", + "account", + "media_attachments", + "mentions", + "tags", + "card", + "group", + "quote", + "in_reply_to", + "reblog", + "sponsored", + "replies_count", + "reblogs_count", + "favourites_count", + "favourited", + "reblogged", + "muted", + "pinned", + "bookmarked", + "poll", "emojis", - "fields", + "_pulled", ] - assert isinstance(user["id"], str) + assert isinstance(latest["id"], str) -def test_pull_statuses(api): +def test_pull_statuses_recent(client, user_timeline): username = "truthsocial" # COMPLETE PULLS # it fetches a timeline of the user's posts: - full_timeline = list( - api.pull_statuses(username=username, replies=False, verbose=True) - ) - assert len(full_timeline) > 25 # more than one page + assert len(user_timeline) > 25 # more than one page # the posts are in reverse chronological order: - latest, earliest = full_timeline[0], full_timeline[-1] - latest_at, earliest_at = as_datetime(latest["created_at"]), as_datetime( - earliest["created_at"] - ) + latest, earliest = user_timeline[0], user_timeline[-1] + latest_at = as_datetime(latest["created_at"]) + earliest_at = as_datetime(earliest["created_at"]) assert earliest_at < latest_at # EMPTY PULLS # can use created_after param for filtering out posts: next_pull = list( - api.pull_statuses( + client.pull_statuses( username=username, replies=False, created_after=latest_at, verbose=True ) ) @@ -82,7 +77,7 @@ def test_pull_statuses(api): # can use since_id param for filtering out posts: next_pull = list( - api.pull_statuses( + client.pull_statuses( username=username, replies=False, since_id=latest["id"], verbose=True ) ) @@ -91,12 +86,12 @@ def test_pull_statuses(api): # PARTIAL PULLS n_posts = 50 # two and a half pages worth, to verify everything is ok - recent = full_timeline[n_posts] + recent = user_timeline[n_posts] recent_at = as_datetime(recent["created_at"]) # can use created_after param for filtering out posts: partial_pull = list( - api.pull_statuses( + client.pull_statuses( username=username, replies=False, created_after=recent_at, verbose=True ) ) @@ -105,48 +100,9 @@ def test_pull_statuses(api): # can use since_id param for filtering out posts: partial_pull = list( - api.pull_statuses( + client.pull_statuses( username=username, replies=False, since_id=recent["id"], verbose=True ) ) assert len(partial_pull) == n_posts assert recent["id"] not in [post["id"] for post in partial_pull] - - # POST INFO - # contains status info - assert list(latest.keys()) == [ - "id", - "created_at", - "in_reply_to_id", - "quote_id", - "in_reply_to_account_id", - "sensitive", - "spoiler_text", - "visibility", - "language", - "uri", - "url", - "content", - "account", - "media_attachments", - "mentions", - "tags", - "card", - "group", - "quote", - "in_reply_to", - "reblog", - "sponsored", - "replies_count", - "reblogs_count", - "favourites_count", - "favourited", - "reblogged", - "muted", - "pinned", - "bookmarked", - "poll", - "emojis", - "_pulled", - ] - assert isinstance(latest["id"], str) diff --git a/test/api/tags_test.py b/test/api/tags_test.py new file mode 100644 index 0000000..ccf01cb --- /dev/null +++ b/test/api/tags_test.py @@ -0,0 +1,41 @@ +def test_trending_tags(client): + # it pulls a list of tags displayed on the topics tab of the search page: + tags = client.tags() + assert isinstance(tags, list) + assert len(tags) == 20 + + # each tag has a name and number of recent posts: + tag = tags[0] + assert isinstance(tag, dict) + assert sorted(list(tag.keys())) == [ + "history", + "name", + "recent_history", + "recent_statuses_count", + "url", + ] + + # the tag name is displayed on the trending page: + assert isinstance(tag["name"], str) + assert isinstance(tag["url"], str) + assert tag["url"] == f"https://truthsocial.com/tags/{tag['name']}" + + # the number of recent statuses is displayed on the website: + assert isinstance(tag["recent_statuses_count"], int) + + # a history of how the tag has trended day by day, over the past week: + assert isinstance(tag["history"], list) # of dict + # >[ + # >{'accounts': '1453', 'day': '1721606400', 'days_ago': 0, 'uses': '4272'}, + # >{'accounts': '860', 'day': '1721520000', 'days_ago': 1, 'uses': '2277'}, + # >{'accounts': '981', 'day': '1721433600', 'days_ago': 2, 'uses': '2548'}, + # >{'accounts': '1255', 'day': '1721347200', 'days_ago': 3, 'uses': '3373'}, + # >{'accounts': '1058', 'day': '1721260800', 'days_ago': 4, 'uses': '2995'}, + # >{'accounts': '1039', 'day': '1721174400', 'days_ago': 5, 'uses': '2978'}, + # >{'accounts': '1489', 'day': '1721088000', 'days_ago': 6, 'uses': '3907'} + # >] + # looks like they are in reverse chronological order + + assert isinstance(tag["recent_history"], list) # of int + # [1489, 1039, 1058, 1255, 981, 860, 1453] + # looks like they go in chronological order, starting with 6 days ago diff --git a/test/api/users_test.py b/test/api/users_test.py new file mode 100644 index 0000000..2611954 --- /dev/null +++ b/test/api/users_test.py @@ -0,0 +1,41 @@ +def test_lookup_user(client): + + user = client.lookup(user_handle="realDonaldTrump") + assert isinstance(user, dict) + assert isinstance(user["id"], str) + + expected_attributes = [ + "accepting_messages", + "acct", + "avatar", + "avatar_static", + "bot", + "chats_onboarded", + "created_at", + "discoverable", + "display_name", + "emojis", + "feeds_onboarded", + "fields", + "followers_count", + "following_count", + "group", + "header", + "header_static", + "id", + "last_status_at", + "location", + "locked", + "note", + "pleroma", + "receive_only_follow_mentions", + "show_nonmember_group_statuses", + "statuses_count", + "tv_account", + "tv_onboarded", + "url", + "username", + "verified", + "website", + ] + assert sorted(list(user.keys())) == expected_attributes diff --git a/test/api_test.py b/test/api_test.py new file mode 100644 index 0000000..75ce928 --- /dev/null +++ b/test/api_test.py @@ -0,0 +1,28 @@ +from truthbrush.api import ( + Api, + TRUTHSOCIAL_USERNAME, + TRUTHSOCIAL_PASSWORD, + TRUTHSOCIAL_TOKEN, +) + + +def test_client_initialization(client): + assert isinstance(client, Api) + assert client._username == TRUTHSOCIAL_USERNAME + assert client._password == TRUTHSOCIAL_PASSWORD + assert client.auth_id == TRUTHSOCIAL_TOKEN + + +def test_client_check_login(client): + assert client.auth_id == None + + client._check_login() + + # obtains and sets the auth token: + assert isinstance(client.auth_id, str) + + +def test_client_get_auth_id(client): + # a lower level method for obtaining an auth token: + auth_id = client.get_auth_id(client._username, client._password) + assert isinstance(auth_id, str) diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..34d17f4 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,14 @@ +import pytest + +from truthbrush.api import Api + + +@pytest.fixture(scope="module") +def client(): + return Api() + + +@pytest.fixture(scope="module") +def user_timeline(client): + result = client.pull_statuses(username="truthsocial", replies=False, verbose=True) + return list(result) diff --git a/test/utils_test.py b/test/utils_test.py new file mode 100644 index 0000000..f7b7123 --- /dev/null +++ b/test/utils_test.py @@ -0,0 +1,12 @@ +from datetime import datetime, timezone + +from truthbrush.utils import as_datetime + + +def test_as_datetime(): + """Test that a valid date string is correctly converted to a datetime object in UTC.""" + recent = "2024-07-14 14:50:31.628257+00:00" + result = as_datetime(recent) + + expected = datetime(2024, 7, 14, 14, 50, 31, 628257, tzinfo=timezone.utc) + assert result == expected diff --git a/truthbrush/api.py b/truthbrush/api.py index 9d175bd..9ceb3bf 100644 --- a/truthbrush/api.py +++ b/truthbrush/api.py @@ -1,9 +1,10 @@ from time import sleep -from typing import Any, Iterator, List, Optional +from typing import Any, Iterator, List, Optional, Union from loguru import logger from dateutil import parser as date_parse -from datetime import datetime, timezone, date +from datetime import datetime, timezone #, date from curl_cffi import requests +import curl_cffi import json import logging import os @@ -43,6 +44,33 @@ class LoginErrorException(Exception): class Api: + """A client for interfacing with the Truth Social API. + + Params: + username (str): The user name for logging in to Truth Social. + password (str): The password for logging in to Truth Social. + + Examples: + Initialize the client by passing your Truth Social username and password: + ```python + from truthbrush import Api + + client = Api(username="yourname", password="yourpass") + ``` + + To avoid hard-coding these secret credentials, you are encouraged to use environment variables + `TRUTHSOCIAL_USERNAME` and `TRUTHSOCIAL_PASSWORD`, for example stored in a local ".env" file. + You could then pass these environment variables, or omit because they are used by default: + + ```python + from truthbrush import Api + + # assuming you have set env vars TRUTHSOCIAL_USERNAME and TRUTHSOCIAL_PASSWORD: + client = Api() + ``` + + """ + def __init__( self, username=TRUTHSOCIAL_USERNAME, @@ -52,23 +80,24 @@ def __init__( self.ratelimit_max = 300 self.ratelimit_remaining = None self.ratelimit_reset = None - self.__username = username - self.__password = password + self._username = username + self._password = password self.auth_id = token - def __check_login(self): - """Runs before any login-walled function to check for login credentials and generates an auth ID token""" + def _check_login(self): + """Checks for login credentials and generates an auth ID token. + Developer Note: consider making this a decorator function and wrapping the API calls that need this. + """ if self.auth_id is None: - if self.__username is None: + if self._username is None: raise LoginErrorException("Username is missing.") - if self.__password is None: + if self._password is None: raise LoginErrorException("Password is missing.") - self.auth_id = self.get_auth_id(self.__username, self.__password) + self.auth_id = self.get_auth_id(self._username, self._password) logger.warning(f"Using token {self.auth_id}") def _make_session(self): - s = requests.Session() - return s + return requests.Session() def _check_ratelimit(self, resp): if resp.headers.get("x-ratelimit-limit") is not None: @@ -153,9 +182,9 @@ def _get_paginated(self, url: str, params: dict = None, resume: str = None) -> A def user_likes( self, post: str, include_all: bool = False, top_num: int = 40 - ) -> bool | Any: + ) -> Union[bool, Any]: """Return the top_num most recent (or all) users who liked the post.""" - self.__check_login() + self._check_login() top_num = int(top_num) if top_num < 1: return @@ -178,7 +207,7 @@ def pull_comments( top_num: int = 40, ): """Return the top_num oldest (or all) replies to a post.""" - self.__check_login() + self._check_login() top_num = int(top_num) if top_num < 1: return @@ -200,7 +229,7 @@ def pull_comments( def lookup(self, user_handle: str = None) -> Optional[dict]: """Lookup a user's information.""" - self.__check_login() + self._check_login() assert user_handle is not None return self._get("/v1/accounts/lookup", params=dict(acct=user_handle)) @@ -209,16 +238,23 @@ def search( searchtype: str = None, query: str = None, limit: int = 40, - resolve: bool = 4, + resolve: bool = True, offset: int = 0, min_id: str = "0", max_id: str = None, ) -> Optional[dict]: - """Search users, statuses or hashtags.""" + """Search users, statuses, hashtags, or groups. + + Params : + searchtype (str) the resource type, one of: 'hashtags', 'accounts', 'statuses', or 'groups'. - self.__check_login() + query (str) : the name of the resource to search for. + + """ + self._check_login() assert query is not None and searchtype is not None + resolve = 'true' if resolve else 'false' page = 0 while page < limit: if max_id is None: @@ -248,30 +284,57 @@ def search( ), ) - offset += 40 + offset += 40 # use limit here? # added new not sure if helpful if not resp or all(value == [] for value in resp.values()): break yield resp + + def search_simpler(self, resource_type: str, query: str, limit=40, offset=0): + """Search users, statuses, hashtags, or groups. + + Params : + resource_type (str) the type of resource: 'hashtags', 'accounts', 'statuses', or 'groups'. + + query (str) : the name of the resource to search for. + """ + self._check_login() + + params = dict(q=query, type=resource_type, limit=limit, offset=offset) + response = self._get("/v2/search", params=params) + if resource_type not in response: + raise ValueError(f"resource type {resource_type} not found in response") + + results = response[resource_type] # return only the resources requested + return results + def trending(self, limit=10): """Return trending truths. - Optional arg limit<20 specifies number to return.""" - self.__check_login() + Params: + limit (int, optional): specifies number of items to return (max 20) + Defaults to 10. + + """ + + self._check_login() return self._get(f"/v1/truth/trending/truths?limit={limit}") def group_posts(self, group_id: str, limit=20): - self.__check_login() + """Return posts for a given group.""" + self._check_login() timeline = [] + posts = self._get(f"/v1/timelines/group/{group_id}?limit={limit}") + while posts != None: timeline += posts limit = limit - len(posts) if limit <= 0: break - max_id = posts[-1]["id"] + max_id = posts[-1]["id"] #> throws error when no results found posts = self._get( f"/v1/timelines/group/{group_id}?max_id={max_id}&limit={limit}" ) @@ -280,36 +343,36 @@ def group_posts(self, group_id: str, limit=20): def tags(self): """Return trending tags.""" - self.__check_login() + self._check_login() return self._get("/v1/trends") def suggested(self, maximum: int = 50) -> dict: """Return a list of suggested users to follow.""" - self.__check_login() + self._check_login() return self._get(f"/v2/suggestions?limit={maximum}") def trending_groups(self, limit=10): """Return trending group truths. Optional arg limit<20 specifies number to return.""" - self.__check_login() + self._check_login() return self._get(f"/v1/truth/trends/groups?limit={limit}") def group_tags(self): """Return trending group tags.""" - self.__check_login() + self._check_login() return self._get("/v1/groups/tags") def suggested_groups(self, maximum: int = 50) -> dict: """Return a list of suggested groups to follow.""" - self.__check_login() + self._check_login() return self._get(f"/v1/truth/suggestions/groups?limit={maximum}") def ads(self, device: str = "desktop") -> dict: """Return a list of ads from Rumble's Ad Platform via Truth Social API.""" - self.__check_login() + self._check_login() return self._get(f"/v3/truth/ads?device={device}") def user_followers( @@ -354,25 +417,92 @@ def user_following( def pull_statuses( self, - username: str, replies=False, verbose=False, created_after: datetime = None, since_id=None, pinned=False, + username=None, + user_id=None, ) -> List[dict]: """Pull the given user's statuses. + To specify which user, pass either the `username` or `user_id` parameter. + The `user_id` parameter is preferred, as it skips an additional API call. + + To optionally filter posts, retaining only posts created after a given time, + pass either the `created_after` or `since_id` parameter, + designating a timestamp or identifier of a recent post, respectively. + Posts will be pulled exclusive of the provided filter condition. + + Returns a [generator](https://docs.python.org/3/reference/expressions.html#generator-expressions) + of posts in reverse chronological order, or an empty list if not found. + Params: - created_after : timezone aware datetime object - since_id : number or string + username (str): + Username of the user you want to pull statuses for. + Using this option will make an API call to get the user's id. + If possible, pass the user_id instead to skip this additional call. + + user_id (str): + Identifier of the user you want to pull statuses for. + + created_after (timezone aware datetime object): + The timestamp of a post you have pulled most recently. + For example, '2024-07-14 14:50:31.628257+00:00'. + + since_id (number or string): + The identifier of a post you have pulled most recently. + + Examples: + Fetching all statuses by a given user: + ```python + statuses = client.pull_statuses(username="user123") + print(len(list(statuses))) + ``` + + Fetching recent statuses, posted since a specified status identifier: + ```python + recent_id = "0123456789" + recent_statuses = client.pull_statuses( + username="user123", + since_id=recent_id + ) + print(len(list(recent_statuses))) + ``` + + Fetching recent statuses, posted since a specified timezone-aware timestamp: + + ```python + recent = '2024-07-14 14:50:31.628257+00:00' + recent_statuses = client.pull_statuses( + username="user123", + created_after=recent + ) + print(len(list(recent_statuses))) + ``` + + ```python + from datetime import datetime, timedelta + import dateutil + + recent = datetime.now() - timedelta(days=7) + recent = dateutil.parse(recent).replace(tzinfo=timezone.utc) + print(str(recent)) + #> '2024-07-14 14:50:31.628257+00:00' + + recent_statuses = client.pull_statuses( + username="user123", + created_after=recent + ) + print(len(list(recent_statuses))) + ``` + - Returns a list of posts in reverse chronological order, - or an empty list if not found. """ params = {} - user_id = self.lookup(username)["id"] + user_id = user_id or self.lookup(username)["id"] page_counter = 0 keep_going = True while keep_going: @@ -432,7 +562,7 @@ def pull_statuses( since_id and post["id"] <= since_id ): keep_going = False # stop the loop, request no more pages - break # do not yeild this post or remaining (older) posts on this page + break # do not yield this post or remaining (older) posts on this page if verbose: logger.debug(f"{post['id']} {post['created_at']}") @@ -466,7 +596,7 @@ def get_auth_id(self, username: str, password: str) -> str: sess_req.raise_for_status() except requests.RequestsError as e: logger.error(f"Failed login request: {str(e)}") - raise SystemExit('Cannot authenticate to .') + raise SystemExit("Cannot authenticate to .") if not sess_req.json()["access_token"]: raise ValueError("Invalid truthsocial.com credentials provided!") diff --git a/truthbrush/utils.py b/truthbrush/utils.py new file mode 100644 index 0000000..ce774e8 --- /dev/null +++ b/truthbrush/utils.py @@ -0,0 +1,16 @@ +from datetime import timezone +from dateutil import parser as date_parse + + +"""Utility Functions""" + + +def as_datetime(date_str): + """Datetime formatter function. Ensures timezone is UTC. + + Params : + date_str (str) : Date string, like '2024-07-14 14:50:31.628257+00:00' + formatted like the ones returned by the API. + + """ + return date_parse.parse(date_str).replace(tzinfo=timezone.utc)