Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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"
74 changes: 66 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
29 changes: 24 additions & 5 deletions components/globals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)))
39 changes: 39 additions & 0 deletions components/job_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down
20 changes: 20 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
2 changes: 2 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8.3,<9
9 changes: 4 additions & 5 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions tests/test_globals.py
Original file line number Diff line number Diff line change
@@ -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()