diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..86a82cd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run regression tests + run: python -m unittest discover -s tests -p "test_*.py" + + - name: Smoke test startup path resolution + run: | + python -c "import os; from components.globals import BUNDLE_DIR; assert os.path.isdir(BUNDLE_DIR), BUNDLE_DIR" diff --git a/README.md b/README.md index 5d13a81..faa67e2 100644 --- a/README.md +++ b/README.md @@ -42,16 +42,74 @@ Installers and standalone versions are available for Windows and macOS Instructions for using Reaper are available on [reaper.social](https://reaper.social) -## Run source -Reaper uses string formatting that was added in Python 3.6. You need to run Reaper with Python 3.6+ or download a pre-built version from the [releases](https://github.com/ScriptSmith/reaper/releases) +## Quick Start -Download -``` +Reaper is easiest to run with Python 3.10-3.12. + +### Windows (PowerShell) +```powershell git clone https://github.com/ScriptSmith/reaper.git cd reaper +py -3.10 -m venv .venv +.venv\Scripts\Activate.ps1 +python -m pip install --upgrade pip +pip install -r requirements.txt +python reaper.py ``` -Run -``` -pip3 install -r requirements.txt -python3 reaper.py + +### macOS / Linux +```bash +git clone https://github.com/ScriptSmith/reaper.git +cd reaper +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +pip install -r requirements.txt +python reaper.py ``` + +## API Key Setup + +Reaper depends on external platform APIs. Before collecting data: + +1. Create API credentials for each provider you need (for example Reddit, YouTube, Tumblr, Pinterest, Twitter/X, Facebook). +2. Open Reaper and go to the API keys page. +3. Enter credentials for the enabled sources. +4. Save keys and run a small test job first. + +## API Status Reality Check + +This project integrates external APIs that change over time. A source being listed in the UI does not guarantee the upstream endpoint is still available. + +Recommended validation workflow: + +1. Test each source with a minimal request before running large jobs. +2. Watch the Error Manager output for auth/endpoint hints. +3. Disable or hide sources that are no longer supported in your fork until updated. + +## Troubleshooting + +### RecursionError on startup (`maximum recursion depth exceeded`) + +If you saw this in older builds, upgrade to a version that includes Issue #13 fix. +The bug was in `components/globals.py` path resolution and was triggered by some Windows folder layouts. + +### `ModuleNotFoundError` or import failures + +1. Confirm your virtual environment is activated. +2. Reinstall dependencies: + `pip install --upgrade pip && pip install -r requirements.txt` +3. Verify Python version: + `python --version` + +### PyQt5 install problems + +1. Use a supported Python version (3.10-3.12 recommended). +2. Upgrade pip before installing. +3. On Linux, ensure Qt runtime/system packages are installed by your distro package manager. + +### Invalid API keys / source errors + +1. Re-check token permissions and expiry at the provider side. +2. Test one source at a time to isolate failures. +3. Check Reaper logs in your app data/log directory if startup succeeds but jobs fail. diff --git a/components/globals.py b/components/globals.py index 8e72d6c..b0d79e4 100644 --- a/components/globals.py +++ b/components/globals.py @@ -9,12 +9,31 @@ LOG_DIR = appdirs.user_log_dir(APP_NAME, APP_AUTHOR) CACHE_DIR = appdirs.user_cache_dir(APP_NAME, APP_AUTHOR) + def _calc_path(path): - head, tail = os.path.split(path) - if tail == 'reaper': - return path - else: - return _calc_path(head) + """ + Resolve the project root directory without recursive parent traversal. + + The original implementation recursively walked parent directories until it + found a folder named "reaper". On Windows this can loop forever when the + path reaches a drive boundary (e.g. "D:\\") and os.path.split() keeps + returning the same head. + """ + current = os.path.abspath(path) + start = current + + while True: + if ( + os.path.isdir(os.path.join(current, "ui")) + and os.path.isdir(os.path.join(current, "components")) + ): + return current + + head, _ = os.path.split(current) + if head == current or head == "": + return start + + current = head BUNDLE_DIR = sys._MEIPASS if getattr(sys, "frozen", False) else \ _calc_path(os.path.dirname(os.path.abspath(__file__))) diff --git a/components/job_queue.py b/components/job_queue.py index b13aef0..11cd1c0 100644 --- a/components/job_queue.py +++ b/components/job_queue.py @@ -14,6 +14,41 @@ from components.globals import * +def _api_error_hint(error): + message = str(error).lower() + hints = [] + + if any( + token in message + for token in ( + "unauthorized", + "forbidden", + "invalid token", + "access token", + "oauth", + "401", + "403", + "bad authentication data", + ) + ): + hints.append( + "Credentials appear invalid or expired. Re-check API keys for this source." + ) + + if any( + token in message + for token in ("deprecated", "unsupported", "endpoint", "410", "removed") + ): + hints.append( + "The provider endpoint may be deprecated or changed. Verify current API docs." + ) + + if not hints: + return None + + return " ".join(hints) + + class QueueState(Enum): RUNNING = "running" STOPPED = "stopped" @@ -341,6 +376,10 @@ def run(self): self.job_error.emit(job) job.pickle() + hint = _api_error_hint(e) + if hint: + self.job_error_log.emit(hint) + if not isinstance(e, IterError): self.job_error_log.emit(format_exc()) self.stop() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c3f7fbb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "reaper" +version = "2.5.4" +description = "GUI social media data collection client powered by socialreaper." +readme = "README.md" +requires-python = ">=3.10,<3.13" +authors = [{ name = "UQ" }] +dependencies = [ + "PyQt5>=5.15.9,<6", + "socialreaper @ git+https://github.com/ScriptSmith/socialreaper.git", + "appdirs>=1.4.4,<2", + "QDarkStyle>=3.2.3,<4", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a7e7e20 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=8.3,<9 diff --git a/requirements.txt b/requirements.txt index 860ca6f..ee0dd6f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ -PyQt5==5.9.1 --e git+https://github.com/ScriptSmith/socialreaper.git#egg=socialreaper -appdirs==1.4.3 -QDarkStyle==2.5.3 -sip==4.19.8 +PyQt5>=5.15.9,<6 +socialreaper @ git+https://github.com/ScriptSmith/socialreaper.git +appdirs>=1.4.4,<2 +QDarkStyle>=3.2.3,<4 diff --git a/tests/test_globals.py b/tests/test_globals.py new file mode 100644 index 0000000..cc6e37f --- /dev/null +++ b/tests/test_globals.py @@ -0,0 +1,40 @@ +import os +import tempfile +import unittest +from unittest import mock + +from components.globals import _calc_path + + +class CalcPathTests(unittest.TestCase): + def test_returns_project_root_when_layout_markers_exist(self): + with tempfile.TemporaryDirectory() as temp_dir: + project_root = os.path.join(temp_dir, "reaper-master") + components_dir = os.path.join(project_root, "components") + ui_dir = os.path.join(project_root, "ui") + + os.makedirs(components_dir) + os.makedirs(ui_dir) + + self.assertEqual(_calc_path(components_dir), project_root) + + def test_returns_start_path_when_project_root_not_found(self): + with tempfile.TemporaryDirectory() as temp_dir: + nested = os.path.join(temp_dir, "a", "b", "c") + os.makedirs(nested) + + self.assertEqual(_calc_path(nested), os.path.abspath(nested)) + + def test_stops_when_split_returns_same_path(self): + with tempfile.TemporaryDirectory() as temp_dir: + start = os.path.abspath(temp_dir) + + with mock.patch( + "components.globals.os.path.split", + side_effect=lambda p: (p, ""), + ): + self.assertEqual(_calc_path(start), start) + + +if __name__ == "__main__": + unittest.main()