diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 447edc1..809b706 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -16,10 +16,10 @@ jobs: with: fetch-depth: 1 - - name: Set up Python 3.10 - uses: actions/setup-python@v4 + - name: Set up Python 3.14 + uses: actions/setup-python@v5 with: - python-version: 3.10-dev + python-version: "3.14" - name: Install Poetry uses: snok/install-poetry@v1 diff --git a/README.md b/README.md index 00c5d17..b366f43 100644 --- a/README.md +++ b/README.md @@ -45,12 +45,31 @@ If you encounter login issues, you can instead extract your login token from the You may also set these variables in a `.env` file in the directory from which you are running Truthbrush. +### Public mode (no credentials) + +Some Truth Social endpoints are readable without authentication. To run Truthbrush against only those endpoints, pass `--no-auth` on the CLI or construct the client with `require_auth=False`: + +```sh +truthbrush --no-auth trends +truthbrush --no-auth user realDonaldTrump +``` + +```py +from truthbrush import Api + +api = Api(require_auth=False) +print(api.trending()) +``` + +Endpoints that require authentication will return an API error (typically HTTP 401) when called in public mode. Which endpoints are publicly accessible is determined by Truth Social and may change without notice. + ## CLI Usage ```text Usage: truthbrush [OPTIONS] COMMAND [ARGS]... Options: + --no-auth Run without authentication. Only public endpoints will succeed. --help Show this message and exit. @@ -164,8 +183,10 @@ pytest pytest --log-cli-level=DEBUG -s ``` -Please format your code with `black`: +Please format and lint your code with `ruff`, and run `ty` to check types: ```sh -black . +ruff format . +ruff check . +ty check truthbrush/ ``` diff --git a/poetry.lock b/poetry.lock index e832f7d..db8e4c5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. [[package]] name = "certifi" @@ -179,25 +179,6 @@ dev = ["charset_normalizer (>=3.3.2,<4.0)", "coverage (>=6.4.1,<7.0)", "cryptogr extra = ["lxml_html_clean", "markdownify (>=1.1.0)", "readability-lxml (>=0.8.1)"] test = ["charset_normalizer (>=3.3.2,<4.0)", "cryptography (>=46.0.4,<47.0)", "httpx (==0.23.1)", "litestar (>=2.19.0,<3.0)", "proxy.py (>=2.4.3,<3.0)", "pytest (>=8.1.1,<9.0)", "pytest-asyncio (>=0.23.6,<1.0)", "pytest-trio (>=0.8.0,<1.0)", "python-multipart (>=0.0.9,<1.0)", "trio (>=0.25.0,<1.0)", "trustme (>=1.1.0,<2.0)", "typing_extensions", "uvicorn (>=0.29.0,<1.0)", "websockets (>=14.0)"] -[[package]] -name = "exceptiongroup" -version = "1.3.1" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, - {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} - -[package.extras] -test = ["pytest (>=6)"] - [[package]] name = "iniconfig" version = "2.3.0" @@ -227,7 +208,7 @@ colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} [package.extras] -dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.13.0) ; python_version >= \"3.8\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] +dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==0.910) ; python_version < \"3.6\"", "mypy (==0.971) ; python_version == \"3.6\"", "mypy (==1.13.0) ; python_version >= \"3.8\"", "mypy (==1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] [[package]] name = "markdown-it-py" @@ -335,12 +316,10 @@ files = [ [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} iniconfig = ">=1.0.1" packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] @@ -434,64 +413,6 @@ files = [ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] -[[package]] -name = "tomli" -version = "2.4.0" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, - {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, - {file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"}, - {file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"}, - {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"}, - {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"}, - {file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"}, - {file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"}, - {file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"}, - {file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"}, - {file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"}, - {file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"}, - {file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"}, - {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"}, - {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"}, - {file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"}, - {file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"}, - {file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"}, - {file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"}, - {file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"}, - {file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"}, - {file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"}, - {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"}, - {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"}, - {file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"}, - {file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"}, - {file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"}, - {file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"}, - {file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"}, - {file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"}, - {file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"}, - {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"}, - {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"}, - {file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"}, - {file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"}, - {file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"}, - {file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"}, - {file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"}, - {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"}, - {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"}, - {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"}, - {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"}, - {file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"}, - {file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"}, - {file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"}, - {file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"}, - {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, -] - [[package]] name = "ty" version = "0.0.31" @@ -519,19 +440,6 @@ files = [ {file = "ty-0.0.31.tar.gz", hash = "sha256:4a4094292d9671caf3b510c7edf36991acd9c962bb5d97205374ffed9f541c45"}, ] -[[package]] -name = "typing-extensions" -version = "4.15.0" -description = "Backported and Experimental Type Hints for Python 3.9+" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, -] - [[package]] name = "win32-setctime" version = "1.2.0" @@ -550,5 +458,5 @@ dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] [metadata] lock-version = "2.1" -python-versions = "^3.10" -content-hash = "ce3f87e2d4ea82dc93560db209b2e6cdae43cc64c4df8037a6bad94c9f743df9" +python-versions = "^3.14" +content-hash = "d758c0adfceb2a1c3259adc859468f8db85fa47b4529e096c289ffda63d93874" diff --git a/pyproject.toml b/pyproject.toml index 993d58f..508c708 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "truthbrush" version = "0.3.0" description = "API client for Truth Social" -authors = ["R. Miles McCain "] +authors = ["R. Miles McCain ", "David Thiel"] license = "Apache 2.0" readme = "README.md" @@ -10,7 +10,7 @@ readme = "README.md" truthbrush = "truthbrush.cli:cli" [tool.poetry.dependencies] -python = "^3.10" +python = "^3.14" click = "^8.3.0" loguru = "^0.7.3" python-dotenv = "^1.2.0" @@ -28,7 +28,7 @@ requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" [tool.ruff] -target-version = "py310" +target-version = "py314" line-length = 100 [tool.ruff.lint] diff --git a/test/test_api.py b/test/test_api.py index f815468..642981b 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -1,7 +1,7 @@ -from datetime import datetime, timezone -from dateutil import parser as date_parse +from datetime import UTC import pytest +from dateutil import parser as date_parse from truthbrush.api import Api, LoginErrorException @@ -13,7 +13,7 @@ def 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) + return date_parse.parse(date_str).replace(tzinfo=UTC) def test_lookup(api): @@ -58,33 +58,25 @@ def test_pull_statuses(api): # COMPLETE PULLS # it fetches a timeline of the user's posts: - full_timeline = list( - api.pull_statuses(username=username, replies=False, verbose=True) - ) + full_timeline = list(api.pull_statuses(username=username, replies=False, verbose=True)) assert len(full_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_at, earliest_at = as_datetime(latest["created_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( - username=username, replies=False, created_after=latest_at, verbose=True - ) + api.pull_statuses(username=username, replies=False, created_after=latest_at, verbose=True) ) assert not any(next_pull) # can use since_id param for filtering out posts: next_pull = list( - api.pull_statuses( - username=username, replies=False, since_id=latest["id"], verbose=True - ) + api.pull_statuses(username=username, replies=False, since_id=latest["id"], verbose=True) ) assert not any(next_pull) @@ -96,18 +88,14 @@ def test_pull_statuses(api): # can use created_after param for filtering out posts: partial_pull = list( - api.pull_statuses( - username=username, replies=False, created_after=recent_at, verbose=True - ) + api.pull_statuses(username=username, replies=False, created_after=recent_at, verbose=True) ) assert len(partial_pull) == n_posts assert recent["id"] not in [post["id"] for post in partial_pull] # can use since_id param for filtering out posts: partial_pull = list( - api.pull_statuses( - username=username, replies=False, since_id=recent["id"], verbose=True - ) + api.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] @@ -155,3 +143,22 @@ def test_pull_statuses(api): def test_get_auth_id_raises_login_error_exception(api): with pytest.raises(LoginErrorException): api.get_auth_id("invalid_username", "invalid_password") + + +def test_public_mode_does_not_require_credentials(monkeypatch): + monkeypatch.delenv("TRUTHSOCIAL_USERNAME", raising=False) + monkeypatch.delenv("TRUTHSOCIAL_PASSWORD", raising=False) + monkeypatch.delenv("TRUTHSOCIAL_TOKEN", raising=False) + public_api = Api(username=None, password=None, token=None, require_auth=False) + assert public_api.auth_id is None + # user_likes calls __check_login then short-circuits on top_num < 1 before any HTTP. + assert list(public_api.user_likes("abc", top_num=0)) == [] + + +def test_strict_mode_still_raises_without_credentials(monkeypatch): + monkeypatch.delenv("TRUTHSOCIAL_USERNAME", raising=False) + monkeypatch.delenv("TRUTHSOCIAL_PASSWORD", raising=False) + monkeypatch.delenv("TRUTHSOCIAL_TOKEN", raising=False) + strict_api = Api(username=None, password=None, token=None) + with pytest.raises(LoginErrorException): + strict_api.lookup(user_handle="realDonaldTrump") diff --git a/truthbrush/api.py b/truthbrush/api.py index d473645..63c7175 100644 --- a/truthbrush/api.py +++ b/truthbrush/api.py @@ -2,9 +2,9 @@ import logging import os from collections.abc import Iterator -from datetime import datetime, timezone +from datetime import UTC, datetime from time import sleep -from typing import Any, Literal +from typing import Any, Literal, cast import curl_cffi from curl_cffi import requests @@ -14,12 +14,9 @@ load_dotenv() # take environment variables from .env. +_DEBUG_ENV = os.getenv("DEBUG") or "" logging.basicConfig( - level=( - logging.DEBUG - if os.getenv("DEBUG") and os.getenv("DEBUG").lower() != "false" - else logging.INFO - ) + level=logging.DEBUG if _DEBUG_ENV.lower() not in ("", "false") else logging.INFO ) BASE_URL = "https://truthsocial.com" @@ -55,7 +52,7 @@ class CFBlockException(LoginErrorException): pass -def date_to_bound(dt_input: "str | datetime", bound: Literal["start", "end"]) -> int: +def date_to_bound(dt_input: str | datetime, bound: Literal["start", "end"]) -> int: if isinstance(dt_input, str): dt_input = datetime.fromisoformat(dt_input) if dt_input.hour or dt_input.minute or dt_input.second or dt_input.microsecond: @@ -64,7 +61,7 @@ def date_to_bound(dt_input: "str | datetime", bound: Literal["start", "end"]) -> ) if dt_input.tzinfo is None: - dt_input = dt_input.replace(tzinfo=timezone.utc) + dt_input = dt_input.replace(tzinfo=UTC) if bound == "start": dt = dt_input.replace(hour=0, minute=0, second=0, microsecond=0) @@ -79,20 +76,25 @@ def date_to_bound(dt_input: "str | datetime", bound: Literal["start", "end"]) -> class Api: def __init__( self, - username=TRUTHSOCIAL_USERNAME, - password=TRUTHSOCIAL_PASSWORD, - token=TRUTHSOCIAL_TOKEN, + username: str | None = TRUTHSOCIAL_USERNAME, + password: str | None = TRUTHSOCIAL_PASSWORD, + token: str | None = TRUTHSOCIAL_TOKEN, + *, + require_auth: bool = True, ): self.ratelimit_max = 300 - self.ratelimit_remaining = None - self.ratelimit_reset = None + self.ratelimit_remaining: int | None = None + self.ratelimit_reset: datetime | None = None self.__username = username self.__password = password self.auth_id = token + self.require_auth = require_auth def __check_login(self): """Runs before any login-walled function to check for login credentials and generates an auth ID token""" if self.auth_id is None: + if not self.require_auth: + return if self.__username is None: raise LoginErrorException("Username is missing.") if self.__password is None: @@ -113,11 +115,13 @@ def _check_ratelimit(self, resp): self.ratelimit_reset = date_parse.parse(resp.headers.get("x-ratelimit-reset")) if ( - self.ratelimit_remaining is not None and self.ratelimit_remaining <= 50 + self.ratelimit_remaining is not None + and self.ratelimit_remaining <= 50 + and self.ratelimit_reset is not None ): # We do 50 to be safe; their tracking is a bit stochastic... it can jump down quickly - now = datetime.utcnow().replace(tzinfo=timezone.utc) + now = datetime.now(UTC) time_to_sleep = ( - self.ratelimit_reset.replace(tzinfo=timezone.utc) - now + self.ratelimit_reset.replace(tzinfo=UTC) - now ).total_seconds() logger.warning(f"Approaching rate limit; sleeping for {time_to_sleep} seconds...") if time_to_sleep > 0: @@ -125,17 +129,17 @@ def _check_ratelimit(self, resp): else: sleep(10) - def _get(self, url: str, params: dict = None) -> Any: + def _get(self, url: str, params: dict | None = None) -> Any: + headers = {"User-Agent": USER_AGENT} + if self.auth_id is not None: + headers["Authorization"] = "Bearer " + self.auth_id try: resp = self._make_session().get( API_BASE_URL + url, params=params, proxies=proxies, impersonate=IMPERSONATE_TARGET, - headers={ - "Authorization": "Bearer " + self.auth_id, - "User-Agent": USER_AGENT, - }, + headers=headers, ) except curl_cffi.curl.CurlError as e: logger.error(f"Curl error: {e}") @@ -158,8 +162,13 @@ def _get(self, url: str, params: dict = None) -> Any: return r - def _get_paginated(self, url: str, params: dict = None, resume: str = None) -> Any: - next_link = API_BASE_URL + url + def _get_paginated( + self, url: str, params: dict | None = None, resume: str | None = None + ) -> Any: + next_link: str | None = API_BASE_URL + url + headers = {"User-Agent": USER_AGENT} + if self.auth_id is not None: + headers["Authorization"] = "Bearer " + self.auth_id if resume is not None: next_link += f"?max_id={resume}" @@ -170,10 +179,7 @@ def _get_paginated(self, url: str, params: dict = None, resume: str = None) -> A params=params, proxies=proxies, impersonate=IMPERSONATE_TARGET, - headers={ - "Authorization": "Bearer " + self.auth_id, - "User-Agent": USER_AGENT, - }, + headers=headers, ) link_header = resp.headers.get("Link", "") next_link = None @@ -188,7 +194,7 @@ def _get_paginated(self, url: str, params: dict = None, resume: str = None) -> A # Will also sleep self._check_ratelimit(resp) - def user_likes(self, post: str, include_all: bool = False, top_num: int = 40) -> bool | Any: + def user_likes(self, post: str, include_all: bool = False, top_num: int = 40) -> Iterator[dict]: """Return the top_num most recent (or all) users who liked the post.""" self.__check_login() top_num = int(top_num) @@ -211,7 +217,7 @@ def pull_comments( include_all: bool = False, only_first: bool = False, top_num: int = 40, - ): + ) -> Iterator[dict]: """Return the top_num oldest (or all) replies to a post.""" self.__check_login() top_num = int(top_num) @@ -232,7 +238,7 @@ def pull_comments( if not include_all and n_output >= top_num: return - def lookup(self, user_handle: str = None) -> dict | None: + def lookup(self, user_handle: str | None = None) -> dict | None: """Lookup a user's information.""" self.__check_login() @@ -241,18 +247,16 @@ def lookup(self, user_handle: str = None) -> dict | None: def search( self, - searchtype: str = None, - query: str = None, + searchtype: str | None = None, + query: str | None = None, limit: int = 40, - resolve: bool = 4, + resolve: bool = True, offset: int = 0, min_id: str = "0", - max_id: str = None, - start_date: ( - str | datetime - ) = None, # intended use is dates i.e "2026-01-01", supports datetime - end_date: str | datetime = None, - ) -> dict | None: + max_id: str | None = None, + start_date: str | datetime | None = None, + end_date: str | datetime | None = None, + ) -> Iterator[dict]: """Search users, statuses or hashtags.""" self.__check_login() @@ -297,9 +301,9 @@ def search( def hashtag( self, - tag: str = None, + tag: str | None = None, limit: int = 100, - ) -> dict | None: + ) -> Iterator[list[dict]]: """Collect posts with a specific hashtag.""" self.__check_login() @@ -309,7 +313,7 @@ def hashtag( tag = tag[1:] num_results = 0 - params = dict() + params: dict = dict() while num_results < limit: logger.info(f"Collecting posts with hashtag: {tag}, max_id: {params.get('max_id')}") resp = self._get( @@ -337,11 +341,11 @@ def trending(self, limit=10): self.__check_login() return self._get(f"/v1/truth/trending/truths?limit={limit}") - def group_posts(self, group_id: str, limit=20): + def group_posts(self, group_id: str, limit: int = 20) -> list[dict]: self.__check_login() - timeline = [] + timeline: list[dict] = [] posts = self._get(f"/v1/timelines/group/{group_id}?limit={limit}") - while posts is not None: + while posts: timeline += posts limit = limit - len(posts) if limit <= 0: @@ -356,7 +360,7 @@ def tags(self): self.__check_login() return self._get("/v1/trends") - def suggested(self, maximum: int = 50) -> dict: + def suggested(self, maximum: int = 50) -> Any: """Return a list of suggested users to follow.""" self.__check_login() return self._get(f"/v2/suggestions?limit={maximum}") @@ -374,12 +378,12 @@ def group_tags(self): self.__check_login() return self._get("/v1/groups/tags") - def suggested_groups(self, maximum: int = 50) -> dict: + def suggested_groups(self, maximum: int = 50) -> Any: """Return a list of suggested groups to follow.""" self.__check_login() return self._get(f"/v1/truth/suggestions/groups?limit={maximum}") - def ads(self, device: str = "desktop") -> dict: + def ads(self, device: str = "desktop") -> Any: """Return a list of ads from Rumble's Ad Platform via Truth Social API.""" self.__check_login() @@ -387,13 +391,16 @@ def ads(self, device: str = "desktop") -> dict: def user_followers( self, - user_handle: str = None, - user_id: str = None, + user_handle: str | None = None, + user_id: str | None = None, maximum: int = 1000, - resume: str = None, + resume: str | None = None, ) -> Iterator[dict]: assert user_handle is not None or user_id is not None - user_id = user_id if user_id is not None else self.lookup(user_handle)["id"] + if user_id is None: + user = self.lookup(user_handle) + assert user is not None, "lookup returned no user" + user_id = user["id"] n_output = 0 for followers_batch in self._get_paginated( @@ -407,13 +414,16 @@ def user_followers( def user_following( self, - user_handle: str = None, - user_id: str = None, + user_handle: str | None = None, + user_id: str | None = None, maximum: int = 1000, - resume: str = None, + resume: str | None = None, ) -> Iterator[dict]: assert user_handle is not None or user_id is not None - user_id = user_id if user_id is not None else self.lookup(user_handle)["id"] + if user_id is None: + user = self.lookup(user_handle) + assert user is not None, "lookup returned no user" + user_id = user["id"] n_output = 0 for followers_batch in self._get_paginated( @@ -427,25 +437,37 @@ def user_following( def pull_statuses( self, - username: str, - replies=False, - verbose=False, - created_after: datetime = None, - since_id=None, - pinned=False, - ) -> list[dict]: + username: str | None = None, + replies: bool = False, + verbose: bool = False, + created_after: datetime | None = None, + since_id: str | int | None = None, + pinned: bool = False, + *, + user_id: str | None = None, + ) -> Iterator[dict]: """Pull the given user's statuses. + Pass either `username` or `user_id`. Supplying `user_id` directly skips + an extra `lookup` call, which matters when `lookup` is not available + (e.g. in public mode, if Truth Social gates that endpoint). + Params: created_after : timezone aware datetime object since_id : number or string - Returns a list of posts in reverse chronological order, - or an empty list if not found. + Yields posts in reverse chronological order. """ - - params = {} - user_id = self.lookup(username)["id"] + self.__check_login() + if user_id is None: + if username is None: + raise ValueError("pull_statuses requires either `username` or `user_id`.") + user = self.lookup(username) + if user is None: + return + user_id = user["id"] + + params: dict = {} page_counter = 0 keep_going = True while keep_going: @@ -469,20 +491,21 @@ def pull_statuses( logger.error(f"Misc. error while pulling statuses for {user_id}: {e}") break - if "error" in result: + if result is None: + break + if isinstance(result, dict) and "error" in result: logger.error( f"API returned an error while pulling user #{user_id}'s statuses: {result}" ) break - - if len(result) == 0: - break - if not isinstance(result, list): logger.error(f"Result is not a list (it's a {type(result)}): {result}") + break + if len(result) == 0: + break - posts = sorted( - result, key=lambda k: k["id"], reverse=True + posts: list[dict] = sorted( + cast(list[dict], result), key=lambda k: k["id"], reverse=True ) # reverse chronological order (recent first, older last) params["max_id"] = posts[-1][ "id" @@ -500,7 +523,7 @@ def pull_statuses( # only keep posts created after the specified date # exclude posts created before the specified date # since the page is listed in reverse chronology, we don't need any remaining posts on this page either - post_at = date_parse.parse(post["created_at"]).replace(tzinfo=timezone.utc) + post_at = date_parse.parse(post["created_at"]).replace(tzinfo=UTC) if (created_after and post_at <= created_after) or ( since_id and int(post["id"]) <= int(since_id) ): diff --git a/truthbrush/cli.py b/truthbrush/cli.py index 0ba6ac2..6d5831e 100644 --- a/truthbrush/cli.py +++ b/truthbrush/cli.py @@ -2,70 +2,76 @@ import datetime import json -from datetime import date import click from .api import Api -api = Api() - @click.group() -def cli(): +@click.option( + "--no-auth", + is_flag=True, + default=False, + help="Run without authentication. Only public endpoints will succeed.", +) +@click.pass_context +def cli(ctx: click.Context, no_auth: bool): """This is an API client for Truth Social.""" + ctx.ensure_object(dict) + ctx.obj["api"] = Api(require_auth=not no_auth) @cli.command() @click.argument("group_id") @click.option("--limit", default=20, help="Limit the number of items returned", type=int) -def groupposts(group_id: str, limit: int): +@click.pass_context +def groupposts(ctx: click.Context, group_id: str, limit: int): """Pull posts from group timeline""" - - print(json.dumps(api.group_posts(group_id, limit))) + print(json.dumps(ctx.obj["api"].group_posts(group_id, limit))) @cli.command() -def trends(): +@click.pass_context +def trends(ctx: click.Context): """Pull trendy Truths.""" - - print(json.dumps(api.trending())) + print(json.dumps(ctx.obj["api"].trending())) @cli.command() -def tags(): +@click.pass_context +def tags(ctx: click.Context): """Pull trendy tags.""" - - print(json.dumps(api.tags())) + print(json.dumps(ctx.obj["api"].tags())) @cli.command() -def grouptags(): +@click.pass_context +def grouptags(ctx: click.Context): """Pull group tags.""" - - print(json.dumps(api.group_tags())) + print(json.dumps(ctx.obj["api"].group_tags())) @cli.command() -def grouptrends(): +@click.pass_context +def grouptrends(ctx: click.Context): """Pull group trends.""" - - print(json.dumps(api.trending_groups())) + print(json.dumps(ctx.obj["api"].trending_groups())) @cli.command() -def groupsuggest(): +@click.pass_context +def groupsuggest(ctx: click.Context): """Pull group suggestions.""" - - print(json.dumps(api.suggested_groups())) + print(json.dumps(ctx.obj["api"].suggested_groups())) @cli.command() @click.argument("handle") -def user(handle: str): +@click.pass_context +def user(ctx: click.Context, handle: str): """Pull a user's metadata.""" - - print(json.dumps(api.lookup(handle))) + print(json.dumps(ctx.obj["api"].lookup(handle))) @cli.command() @@ -83,27 +89,35 @@ def user(handle: str): @click.option( "--end-date", default=None, help="End date for search results (e.g. 2026-03-01)", type=str ) -def search(searchtype: str, query: str, limit: int, resolve: bool, start_date: str, end_date: str): +@click.pass_context +def search( + ctx: click.Context, + searchtype: str, + query: str, + limit: int, + resolve: bool, + start_date: str, + end_date: str, +): """Search for users, statuses, groups, or hashtags.""" - - for page in api.search( + for page in ctx.obj["api"].search( searchtype, query, limit, resolve, start_date=start_date, end_date=end_date ): print(json.dumps(page[searchtype])) @cli.command() -def suggestions(): +@click.pass_context +def suggestions(ctx: click.Context): """Pull the list of suggested users.""" - - print(json.dumps(api.suggested())) + print(json.dumps(ctx.obj["api"].suggested())) @cli.command() -def ads(): +@click.pass_context +def ads(ctx: click.Context): """Pull ads.""" - - print(json.dumps(api.ads())) + print(json.dumps(ctx.obj["api"].ads())) # @cli.command() @@ -152,19 +166,20 @@ def ads(): type=datetime.datetime.fromisoformat, ) @click.option("--pinned/--all", default=False, help="Only pull pinned posts (defaults to all)") +@click.pass_context def statuses( + ctx: click.Context, username: str, replies: bool = False, - created_after: date = None, + created_after: datetime.datetime | None = None, pinned: bool = False, ): """Pull a user's statuses""" - # Assume UTC if no timezone is specified - if created_after and created_after.tzinfo is None: - created_after = created_after.replace(tzinfo=datetime.timezone.utc) + if created_after is not None and created_after.tzinfo is None: + created_after = created_after.replace(tzinfo=datetime.UTC) - for page in api.pull_statuses( + for page in ctx.obj["api"].pull_statuses( username, created_after=created_after, replies=replies, pinned=pinned ): print(json.dumps(page)) @@ -174,9 +189,10 @@ def statuses( @click.argument("post") @click.option("--includeall", is_flag=True, help="return all comments on post.") @click.argument("top_num") -def likes(post: str, includeall: bool, top_num: int): +@click.pass_context +def likes(ctx: click.Context, post: str, includeall: bool, top_num: int): """Pull the top_num most recent users who liked the post.""" - for page in api.user_likes(post, includeall, top_num): + for page in ctx.obj["api"].user_likes(post, includeall, top_num): print(json.dumps(page)) @@ -185,7 +201,8 @@ def likes(post: str, includeall: bool, top_num: int): @click.option("--includeall", is_flag=True, help="return all comments on post. Overrides top_num.") @click.option("--onlyfirst", is_flag=True, help="return only direct replies to specified post") @click.argument("top_num") -def comments(post: str, includeall: bool, onlyfirst: bool, top_num: int = 40): +@click.pass_context +def comments(ctx: click.Context, post: str, includeall: bool, onlyfirst: bool, top_num: int = 40): """Pull the top_num comments on a post (defaults to all users, including replies).""" - for page in api.pull_comments(post, includeall, onlyfirst, top_num): + for page in ctx.obj["api"].pull_comments(post, includeall, onlyfirst, top_num): print(page)