diff --git a/.github/workflows/auto-push-main.yml b/.github/workflows/auto-push-main.yml index fc3f2d5..ecaf652 100644 --- a/.github/workflows/auto-push-main.yml +++ b/.github/workflows/auto-push-main.yml @@ -24,9 +24,7 @@ jobs: python-version: '3.x' - name: Install dependencies - run: | - pip install ruff - pip install -r tests/requirements.txt + run: pip install -e ".[dev]" - name: Lint (ruff) run: ruff check . diff --git a/.github/workflows/backfill-drive.yml b/.github/workflows/backfill-drive.yml index 58669b8..ca348ea 100644 --- a/.github/workflows/backfill-drive.yml +++ b/.github/workflows/backfill-drive.yml @@ -19,7 +19,7 @@ jobs: python-version: '3.x' - name: Install dependencies - run: pip install -r requirements.txt + run: pip install -e ".[drive]" - name: Upload historical artifacts to Google Drive env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b0a702..1981412 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,8 @@ jobs: with: python-version: '3.x' - - name: Install test dependencies - run: pip install -r tests/requirements.txt + - name: Install dependencies + run: pip install -e ".[dev]" - name: Run tests run: pytest tests/ -v diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6923956..ebeb19d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,6 +15,6 @@ jobs: with: python-version: "3.x" - name: Install dependencies - run: pip install -r tests/requirements.txt + run: pip install -e ".[dev]" - name: Run tests - run: pytest + run: pytest tests/ -v diff --git a/.github/workflows/weekly-cleanup.yml b/.github/workflows/weekly-cleanup.yml index 8ee943f..cc0784a 100644 --- a/.github/workflows/weekly-cleanup.yml +++ b/.github/workflows/weekly-cleanup.yml @@ -21,7 +21,7 @@ jobs: python-version: '3.x' - name: Install dependencies - run: pip install -r requirements.txt + run: pip install -e ".[drive]" - name: Run weekly cleanup (score < 1, or score == 1 and older than 14 days) env: @@ -32,7 +32,7 @@ jobs: # Optional — omit these two secrets to skip Drive upload GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }} GOOGLE_DRIVE_FOLDER_ID: ${{ secrets.GOOGLE_DRIVE_FOLDER_ID }} - run: python weekly_cleanup.py + run: python -m redditcleaner.ci.weekly_cleanup - name: Upload deletion logs as artifacts if: always() diff --git a/.gitignore b/.gitignore index 0faa3d9..6b63407 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ __pycache__/ # Flask session / instance folder instance/ flask_session/ +src/redditcleaner.egg-info/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4e35d44 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "redditcleaner" +version = "1.8.0" +requires-python = ">=3.9" +dependencies = [ + "praw>=7.6,<8", +] + +[project.optional-dependencies] +drive = [ + "google-api-python-client>=2.0,<3", + "google-auth>=2.0,<3", + "google-auth-httplib2>=0.1,<1", +] +web = [ + "flask>=2.3,<4", + "flask-wtf>=1.1,<2", + "google-api-python-client>=2.0,<3", + "google-auth>=2.0,<3", + "google-auth-httplib2>=0.1,<1", +] +dev = [ + "pytest>=7.0,<9", + "pytest-mock>=3.0,<4", + "flask>=2.3,<4", + "flask-wtf>=1.1,<2", + "ruff", +] + +[project.scripts] +reddit-clean-comments = "redditcleaner.cli.comment_cleaner:main" +reddit-clean-posts = "redditcleaner.cli.post_cleaner:main" +reddit-weekly-cleanup = "redditcleaner.ci.weekly_cleanup:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +"redditcleaner.web" = ["templates/*.html"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index e2c5a32..0000000 --- a/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -testpaths = tests -addopts = -v diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index bf2deb6..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -praw>=7.6,<8 -google-api-python-client>=2.0,<3 -google-auth>=2.0,<3 -google-auth-httplib2>=0.1,<1 diff --git a/scripts/backfill_drive_upload.py b/scripts/backfill_drive_upload.py index 55b61a2..f3b8482 100644 --- a/scripts/backfill_drive_upload.py +++ b/scripts/backfill_drive_upload.py @@ -26,12 +26,7 @@ import tempfile import zipfile -# Allow importing drive_upload from the repo root when run from any directory. -_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if _REPO_ROOT not in sys.path: - sys.path.insert(0, _REPO_ROOT) - -from drive_upload import upload_logs # noqa: E402 +from redditcleaner.drive_upload import upload_logs def _gh_api(path: str) -> dict: diff --git a/src/redditcleaner/__init__.py b/src/redditcleaner/__init__.py new file mode 100644 index 0000000..39f3ddf --- /dev/null +++ b/src/redditcleaner/__init__.py @@ -0,0 +1,3 @@ +"""RedditCommentCleaner — bulk-delete Reddit comments and posts.""" + +__version__ = "1.8.0" diff --git a/src/redditcleaner/ci/__init__.py b/src/redditcleaner/ci/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/weekly_cleanup.py b/src/redditcleaner/ci/weekly_cleanup.py similarity index 88% rename from weekly_cleanup.py rename to src/redditcleaner/ci/weekly_cleanup.py index 97f2a16..985389d 100644 --- a/weekly_cleanup.py +++ b/src/redditcleaner/ci/weekly_cleanup.py @@ -8,7 +8,7 @@ Credential resolution order: 1. Environment variables (REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, REDDIT_USERNAME, REDDIT_PASSWORD) - 2. Credentials.txt in the same directory (four lines: client_id, + 2. Credentials.txt in the current working directory (four lines: client_id, client_secret, username, password) Optional environment variables: @@ -17,37 +17,20 @@ DRY_RUN set to "1" to preview deletions without making changes Usage: - python weekly_cleanup.py # normal run - python weekly_cleanup.py --dry-run # preview only, nothing deleted + python -m redditcleaner.ci.weekly_cleanup # normal run + python -m redditcleaner.ci.weekly_cleanup --dry-run # preview only, nothing deleted """ import argparse import json import os -import time from datetime import datetime, timezone import praw import prawcore -from drive_upload import maybe_upload_logs - -_RETRY_WAIT = (5, 15, 45) - - -def _with_retry(fn, label="operation"): - """Call fn(), retrying up to 3 times on rate-limit errors.""" - for attempt, wait in enumerate(_RETRY_WAIT, start=1): - try: - return fn() - except prawcore.exceptions.TooManyRequests as exc: - retry_after = getattr(exc, "retry_after", None) or wait - print(f" Rate limited on {label}. Waiting {retry_after}s (attempt {attempt}/3)…") - time.sleep(retry_after) - except praw.exceptions.APIException: - raise - return fn() - +from redditcleaner.drive_upload import maybe_upload_logs +from redditcleaner.utils import _with_retry AGE_THRESHOLD_DAYS = 14 @@ -65,7 +48,7 @@ def _load_credentials(): if all([client_id, client_secret, username, password]): return client_id, client_secret, username, password - cred_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Credentials.txt") + cred_path = os.path.join(os.getcwd(), "Credentials.txt") if os.path.exists(cred_path): with open(cred_path, encoding="utf-8") as f: lines = [line.strip() for line in f.readlines()] diff --git a/src/redditcleaner/cli/__init__.py b/src/redditcleaner/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/commentCleaner.py b/src/redditcleaner/cli/comment_cleaner.py similarity index 96% rename from commentCleaner.py rename to src/redditcleaner/cli/comment_cleaner.py index 698b441..fd18113 100644 --- a/commentCleaner.py +++ b/src/redditcleaner/cli/comment_cleaner.py @@ -1,259 +1,259 @@ -import argparse -import json -import time -from datetime import datetime, timedelta, timezone - -import praw -import prawcore - -from drive_upload import maybe_upload_logs -from utils import ( - _with_retry, - confirm_and_run, - get_days_old, - get_reddit_credentials, - initialize_reddit, -) - - -def delete_old_comments(reddit, username, days_old, comments_deleted, *, dry_run=False): - """ - Delete comments older than a specified number of days. - - Args: - reddit (praw.Reddit): Authenticated Reddit instance. - username (str): Reddit username. - days_old (int): Age limit for comments (in days). - comments_deleted (list): A list to store deleted comments. - dry_run (bool): If True, log matches but do not delete. - - Notes: - Since comments.new() is sorted newest-first, once a comment that meets - the age threshold is encountered every subsequent comment does too. - A ``past_cutoff`` flag skips redundant age checks after that transition. - """ - threshold_secs = days_old * 24 * 60 * 60 - now = time.time() - past_cutoff = False - - with open("deleted_comments.txt", "a", encoding="utf-8") as log_file: - for n, comment in enumerate( - reddit.redditor(username).comments.new(limit=None), 1 - ): - print(f"\r Scanning… {n} comment(s) fetched", end="", flush=True) - - if not past_cutoff: - if now - comment.created_utc <= threshold_secs: - continue # too new; older comments follow later in the stream - past_cutoff = True # all subsequent comments are also old enough - - if dry_run: - print( - f"\n [DRY RUN] Would delete (score={comment.score})" - f" in r/{comment.subreddit}: {comment.body[:60]!r}" - ) - comments_deleted.append(comment) - continue - - log_file.write( - json.dumps({ - "deleted_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), - "created_at": datetime.fromtimestamp( - comment.created_utc, tz=timezone.utc - ).strftime("%Y-%m-%dT%H:%M:%SZ"), - "id": comment.name, - "subreddit": str(comment.subreddit), - "score": comment.score, - "permalink": f"https://reddit.com{comment.permalink}", - "body": comment.body, - "source": "cli-mode-1", - }) + "\n" - ) - try: - _with_retry(lambda: comment.edit("."), "comment edit") - _with_retry(comment.delete, "comment delete") - comments_deleted.append(comment) - except (praw.exceptions.APIException, prawcore.exceptions.TooManyRequests) as e: - print(f"\n Error deleting comment: {e}") - - print() # newline after the progress counter - - -def remove_comments_with_negative_karma(reddit, username, comments_deleted, *, dry_run=False): - """ - Remove comments with negative karma. - - Args: - reddit (praw.Reddit): Authenticated Reddit instance. - username (str): Reddit username. - comments_deleted (list): A list to store deleted comments. - dry_run (bool): If True, log matches but do not delete. - - Notes: - This function will remove comments with a negative karma score. - """ - with open("deleted_comments.txt", "a", encoding="utf-8") as log_file: - for n, comment in enumerate( - reddit.redditor(username).comments.new(limit=None), 1 - ): - print(f"\r Scanning… {n} comment(s) fetched", end="", flush=True) - - if comment.score > 0: - continue - - if dry_run: - print( - f"\n [DRY RUN] Would delete (score={comment.score})" - f" in r/{comment.subreddit}: {comment.body[:60]!r}" - ) - comments_deleted.append(comment) - continue - - log_file.write( - json.dumps({ - "deleted_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), - "created_at": datetime.fromtimestamp( - comment.created_utc, tz=timezone.utc - ).strftime("%Y-%m-%dT%H:%M:%SZ"), - "id": comment.name, - "subreddit": str(comment.subreddit), - "score": comment.score, - "permalink": f"https://reddit.com{comment.permalink}", - "body": comment.body, - "source": "cli-mode-2", - }) + "\n" - ) - try: - _with_retry(lambda: comment.edit("."), "comment edit") - _with_retry(comment.delete, "comment delete") - comments_deleted.append(comment) - except (praw.exceptions.APIException, prawcore.exceptions.TooManyRequests) as e: - print(f"\n Error removing comment: {e}") - - print() - - -def remove_comments_with_one_karma_and_no_replies( - reddit, username, comments_deleted, *, dry_run=False -): - """ - Remove comments with one karma, no replies, and are at least a week old. - - Args: - reddit (praw.Reddit): Authenticated Reddit instance. - username (str): Reddit username. - comments_deleted (list): A list to store deleted comments. - dry_run (bool): If True, log matches but do not delete. - - Notes: - comment.refresh() is called so that comment.replies is populated; - PRAW does not fill it for listing results without an explicit refresh. - """ - one_week_ago = datetime.now(timezone.utc) - timedelta(days=7) - - with open("deleted_comments.txt", "a", encoding="utf-8") as log_file: - for n, comment in enumerate( - reddit.redditor(username).comments.new(limit=None), 1 - ): - print(f"\r Scanning… {n} comment(s) fetched", end="", flush=True) - - # comment.replies is not populated by default; refresh() fetches the full thread - comment.refresh() - created = datetime.fromtimestamp(comment.created_utc, tz=timezone.utc) - if not (comment.score <= 1 and len(comment.replies) == 0 and created < one_week_ago): - continue - - if dry_run: - print( - f"\n [DRY RUN] Would delete (score={comment.score})" - f" in r/{comment.subreddit}: {comment.body[:60]!r}" - ) - comments_deleted.append(comment) - continue - - log_file.write( - json.dumps({ - "deleted_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), - "created_at": created.strftime("%Y-%m-%dT%H:%M:%SZ"), - "id": comment.name, - "subreddit": str(comment.subreddit), - "score": comment.score, - "permalink": f"https://reddit.com{comment.permalink}", - "body": comment.body, - "source": "cli-mode-3", - }) + "\n" - ) - try: - _with_retry(lambda: comment.edit("."), "comment edit") - _with_retry(comment.delete, "comment delete") - comments_deleted.append(comment) - except (praw.exceptions.APIException, prawcore.exceptions.TooManyRequests) as e: - print(f"\n Error removing comment: {e}") - - print() - - -def main(): - parser = argparse.ArgumentParser(description="Interactive Reddit comment cleaner") - parser.add_argument( - "--dry-run", - action="store_true", - help="Preview which comments would be deleted without making any changes", - ) - args = parser.parse_args() - - client_id, client_secret, username, password = get_reddit_credentials() - - if not confirm_and_run(): - print("Script aborted.") - return - - reddit = initialize_reddit(client_id, client_secret, username, password) - - comments_deleted = [] - - while True: - action = input( - "Choose an action" - " (1 - Delete old comments," - " 2 - Remove comments with negative karma," - " 3 - Remove comments with 1 karma and no replies," - " 4 - Quit): " - ) - - if action == "1": - days_old = get_days_old("Enter how old (in days) the comments should be: ") - print(f"Working (Deleting comments older than {days_old} day(s))…") - delete_old_comments( - reddit, username, days_old, comments_deleted, dry_run=args.dry_run - ) - elif action == "2": - print("Working (Removing comments with negative karma)…") - remove_comments_with_negative_karma( - reddit, username, comments_deleted, dry_run=args.dry_run - ) - elif action == "3": - print("Working (Removing comments with 1 karma and no replies)…") - remove_comments_with_one_karma_and_no_replies( - reddit, username, comments_deleted, dry_run=args.dry_run - ) - elif action == "4": - break - else: - print("Invalid choice. Please select a valid option.") - continue - - time.sleep(1) - - if comments_deleted: - label = "would delete" if args.dry_run else "deleted" - print(f"The script {label} {len(comments_deleted)} comment(s).") - if not args.dry_run: - maybe_upload_logs("deleted_comments.txt") - comments_deleted = [] - else: - print("There were no comments to delete.") - - -if __name__ == "__main__": - main() +import argparse +import json +import time +from datetime import datetime, timedelta, timezone + +import praw +import prawcore + +from redditcleaner.drive_upload import maybe_upload_logs +from redditcleaner.utils import ( + _with_retry, + confirm_and_run, + get_days_old, + get_reddit_credentials, + initialize_reddit, +) + + +def delete_old_comments(reddit, username, days_old, comments_deleted, *, dry_run=False): + """ + Delete comments older than a specified number of days. + + Args: + reddit (praw.Reddit): Authenticated Reddit instance. + username (str): Reddit username. + days_old (int): Age limit for comments (in days). + comments_deleted (list): A list to store deleted comments. + dry_run (bool): If True, log matches but do not delete. + + Notes: + Since comments.new() is sorted newest-first, once a comment that meets + the age threshold is encountered every subsequent comment does too. + A ``past_cutoff`` flag skips redundant age checks after that transition. + """ + threshold_secs = days_old * 24 * 60 * 60 + now = time.time() + past_cutoff = False + + with open("deleted_comments.txt", "a", encoding="utf-8") as log_file: + for n, comment in enumerate( + reddit.redditor(username).comments.new(limit=None), 1 + ): + print(f"\r Scanning… {n} comment(s) fetched", end="", flush=True) + + if not past_cutoff: + if now - comment.created_utc <= threshold_secs: + continue # too new; older comments follow later in the stream + past_cutoff = True # all subsequent comments are also old enough + + if dry_run: + print( + f"\n [DRY RUN] Would delete (score={comment.score})" + f" in r/{comment.subreddit}: {comment.body[:60]!r}" + ) + comments_deleted.append(comment) + continue + + log_file.write( + json.dumps({ + "deleted_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "created_at": datetime.fromtimestamp( + comment.created_utc, tz=timezone.utc + ).strftime("%Y-%m-%dT%H:%M:%SZ"), + "id": comment.name, + "subreddit": str(comment.subreddit), + "score": comment.score, + "permalink": f"https://reddit.com{comment.permalink}", + "body": comment.body, + "source": "cli-mode-1", + }) + "\n" + ) + try: + _with_retry(lambda: comment.edit("."), "comment edit") + _with_retry(comment.delete, "comment delete") + comments_deleted.append(comment) + except (praw.exceptions.APIException, prawcore.exceptions.TooManyRequests) as e: + print(f"\n Error deleting comment: {e}") + + print() # newline after the progress counter + + +def remove_comments_with_negative_karma(reddit, username, comments_deleted, *, dry_run=False): + """ + Remove comments with negative karma. + + Args: + reddit (praw.Reddit): Authenticated Reddit instance. + username (str): Reddit username. + comments_deleted (list): A list to store deleted comments. + dry_run (bool): If True, log matches but do not delete. + + Notes: + This function will remove comments with a negative karma score. + """ + with open("deleted_comments.txt", "a", encoding="utf-8") as log_file: + for n, comment in enumerate( + reddit.redditor(username).comments.new(limit=None), 1 + ): + print(f"\r Scanning… {n} comment(s) fetched", end="", flush=True) + + if comment.score > 0: + continue + + if dry_run: + print( + f"\n [DRY RUN] Would delete (score={comment.score})" + f" in r/{comment.subreddit}: {comment.body[:60]!r}" + ) + comments_deleted.append(comment) + continue + + log_file.write( + json.dumps({ + "deleted_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "created_at": datetime.fromtimestamp( + comment.created_utc, tz=timezone.utc + ).strftime("%Y-%m-%dT%H:%M:%SZ"), + "id": comment.name, + "subreddit": str(comment.subreddit), + "score": comment.score, + "permalink": f"https://reddit.com{comment.permalink}", + "body": comment.body, + "source": "cli-mode-2", + }) + "\n" + ) + try: + _with_retry(lambda: comment.edit("."), "comment edit") + _with_retry(comment.delete, "comment delete") + comments_deleted.append(comment) + except (praw.exceptions.APIException, prawcore.exceptions.TooManyRequests) as e: + print(f"\n Error removing comment: {e}") + + print() + + +def remove_comments_with_one_karma_and_no_replies( + reddit, username, comments_deleted, *, dry_run=False +): + """ + Remove comments with one karma, no replies, and are at least a week old. + + Args: + reddit (praw.Reddit): Authenticated Reddit instance. + username (str): Reddit username. + comments_deleted (list): A list to store deleted comments. + dry_run (bool): If True, log matches but do not delete. + + Notes: + comment.refresh() is called so that comment.replies is populated; + PRAW does not fill it for listing results without an explicit refresh. + """ + one_week_ago = datetime.now(timezone.utc) - timedelta(days=7) + + with open("deleted_comments.txt", "a", encoding="utf-8") as log_file: + for n, comment in enumerate( + reddit.redditor(username).comments.new(limit=None), 1 + ): + print(f"\r Scanning… {n} comment(s) fetched", end="", flush=True) + + # comment.replies is not populated by default; refresh() fetches the full thread + comment.refresh() + created = datetime.fromtimestamp(comment.created_utc, tz=timezone.utc) + if not (comment.score <= 1 and len(comment.replies) == 0 and created < one_week_ago): + continue + + if dry_run: + print( + f"\n [DRY RUN] Would delete (score={comment.score})" + f" in r/{comment.subreddit}: {comment.body[:60]!r}" + ) + comments_deleted.append(comment) + continue + + log_file.write( + json.dumps({ + "deleted_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "created_at": created.strftime("%Y-%m-%dT%H:%M:%SZ"), + "id": comment.name, + "subreddit": str(comment.subreddit), + "score": comment.score, + "permalink": f"https://reddit.com{comment.permalink}", + "body": comment.body, + "source": "cli-mode-3", + }) + "\n" + ) + try: + _with_retry(lambda: comment.edit("."), "comment edit") + _with_retry(comment.delete, "comment delete") + comments_deleted.append(comment) + except (praw.exceptions.APIException, prawcore.exceptions.TooManyRequests) as e: + print(f"\n Error removing comment: {e}") + + print() + + +def main(): + parser = argparse.ArgumentParser(description="Interactive Reddit comment cleaner") + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview which comments would be deleted without making any changes", + ) + args = parser.parse_args() + + client_id, client_secret, username, password = get_reddit_credentials() + + if not confirm_and_run(): + print("Script aborted.") + return + + reddit = initialize_reddit(client_id, client_secret, username, password) + + comments_deleted = [] + + while True: + action = input( + "Choose an action" + " (1 - Delete old comments," + " 2 - Remove comments with negative karma," + " 3 - Remove comments with 1 karma and no replies," + " 4 - Quit): " + ) + + if action == "1": + days_old = get_days_old("Enter how old (in days) the comments should be: ") + print(f"Working (Deleting comments older than {days_old} day(s))…") + delete_old_comments( + reddit, username, days_old, comments_deleted, dry_run=args.dry_run + ) + elif action == "2": + print("Working (Removing comments with negative karma)…") + remove_comments_with_negative_karma( + reddit, username, comments_deleted, dry_run=args.dry_run + ) + elif action == "3": + print("Working (Removing comments with 1 karma and no replies)…") + remove_comments_with_one_karma_and_no_replies( + reddit, username, comments_deleted, dry_run=args.dry_run + ) + elif action == "4": + break + else: + print("Invalid choice. Please select a valid option.") + continue + + time.sleep(1) + + if comments_deleted: + label = "would delete" if args.dry_run else "deleted" + print(f"The script {label} {len(comments_deleted)} comment(s).") + if not args.dry_run: + maybe_upload_logs("deleted_comments.txt") + comments_deleted = [] + else: + print("There were no comments to delete.") + + +if __name__ == "__main__": + main() diff --git a/PostCleaner.py b/src/redditcleaner/cli/post_cleaner.py similarity index 97% rename from PostCleaner.py rename to src/redditcleaner/cli/post_cleaner.py index 13ed838..3505edd 100644 --- a/PostCleaner.py +++ b/src/redditcleaner/cli/post_cleaner.py @@ -6,8 +6,8 @@ import praw import prawcore -from drive_upload import maybe_upload_logs -from utils import ( +from redditcleaner.drive_upload import maybe_upload_logs +from redditcleaner.utils import ( _with_retry, confirm_and_run, get_days_old, diff --git a/drive_upload.py b/src/redditcleaner/drive_upload.py similarity index 100% rename from drive_upload.py rename to src/redditcleaner/drive_upload.py diff --git a/utils.py b/src/redditcleaner/utils.py similarity index 100% rename from utils.py rename to src/redditcleaner/utils.py diff --git a/src/redditcleaner/web/__init__.py b/src/redditcleaner/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/app.py b/src/redditcleaner/web/app.py similarity index 92% rename from web/app.py rename to src/redditcleaner/web/app.py index 0472405..cbde019 100644 --- a/web/app.py +++ b/src/redditcleaner/web/app.py @@ -1,6 +1,5 @@ import json import os -import sys from datetime import datetime, timezone import praw @@ -8,21 +7,17 @@ from flask import Flask, jsonify, redirect, render_template, request, session, url_for from flask_wtf.csrf import CSRFProtect +from redditcleaner.drive_upload import maybe_upload_logs +from redditcleaner.utils import _with_retry + app = Flask(__name__) app.secret_key = os.environ.get("FLASK_SECRET_KEY") or os.urandom(24) csrf = CSRFProtect(app) -# Log files are kept at the repo root, not inside web/ -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - -# Allow importing drive_upload and utils from the repo root -if BASE_DIR not in sys.path: - sys.path.insert(0, BASE_DIR) -from drive_upload import maybe_upload_logs # noqa: E402 -from utils import _with_retry # noqa: E402 - -DELETED_COMMENTS_FILE = os.path.join(BASE_DIR, "deleted_comments.txt") -DELETED_POSTS_FILE = os.path.join(BASE_DIR, "deleted_posts.txt") +# Log files are written to LOG_DIR (defaults to current working directory) +LOG_DIR = os.environ.get("LOG_DIR", os.getcwd()) +DELETED_COMMENTS_FILE = os.path.join(LOG_DIR, "deleted_comments.txt") +DELETED_POSTS_FILE = os.path.join(LOG_DIR, "deleted_posts.txt") def make_reddit(): diff --git a/web/templates/dashboard.html b/src/redditcleaner/web/templates/dashboard.html similarity index 100% rename from web/templates/dashboard.html rename to src/redditcleaner/web/templates/dashboard.html diff --git a/web/templates/index.html b/src/redditcleaner/web/templates/index.html similarity index 100% rename from web/templates/index.html rename to src/redditcleaner/web/templates/index.html diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index c40fa15..0000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -pytest>=7.0,<9 -pytest-mock>=3.0,<4 -flask>=2.3,<4 -flask-wtf>=1.1,<2 -praw>=7.6,<8 diff --git a/tests/test_drive_upload.py b/tests/test_drive_upload.py index 866f33f..9641946 100644 --- a/tests/test_drive_upload.py +++ b/tests/test_drive_upload.py @@ -1,12 +1,8 @@ """Tests for drive_upload.maybe_upload_logs.""" -import os -import sys from unittest.mock import patch -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from drive_upload import maybe_upload_logs +from redditcleaner.drive_upload import maybe_upload_logs class TestMaybeUploadLogs: @@ -31,7 +27,7 @@ def test_skips_when_key_set_but_folder_missing(self, monkeypatch): def test_returns_empty_list_on_upload_exception(self, monkeypatch): monkeypatch.setenv("GOOGLE_SERVICE_ACCOUNT_KEY", '{"type": "service_account"}') monkeypatch.setenv("GOOGLE_DRIVE_FOLDER_ID", "folder123") - with patch("drive_upload.upload_logs", side_effect=Exception("Network error")): + with patch("redditcleaner.drive_upload.upload_logs", side_effect=Exception("Network error")): result = maybe_upload_logs("some_file.txt") assert result == [] @@ -39,7 +35,7 @@ def test_calls_upload_logs_when_credentials_present(self, monkeypatch, tmp_path) monkeypatch.setenv("GOOGLE_SERVICE_ACCOUNT_KEY", '{"type": "service_account"}') monkeypatch.setenv("GOOGLE_DRIVE_FOLDER_ID", "folder123") fake_result = [{"name": "deleted_comments.txt", "url": "https://drive.google.com/file/d/abc/view"}] - with patch("drive_upload.upload_logs", return_value=fake_result) as mock_upload: + with patch("redditcleaner.drive_upload.upload_logs", return_value=fake_result) as mock_upload: result = maybe_upload_logs("deleted_comments.txt") mock_upload.assert_called_once_with("folder123", "deleted_comments.txt", date_suffix=None) assert result == fake_result diff --git a/tests/test_web_app.py b/tests/test_web_app.py index 2fbdaad..1714fa5 100644 --- a/tests/test_web_app.py +++ b/tests/test_web_app.py @@ -1,18 +1,10 @@ """Tests for the Flask web application (web/app.py).""" -import os -import sys from unittest.mock import MagicMock, patch import pytest -# Ensure repo root on path so drive_upload is importable from web/app.py -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -# web/app.py does sys.path manipulation itself, but we need the module importable -sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "web")) - -from app import app as flask_app +from redditcleaner.web.app import app as flask_app @pytest.fixture @@ -98,7 +90,7 @@ def test_returns_items_with_session(self, authed_client): mock_reddit.redditor.return_value.comments.new.return_value = [mock_comment] mock_reddit.redditor.return_value.submissions.new.return_value = [] - with patch("app.praw.Reddit", return_value=mock_reddit): + with patch("redditcleaner.web.app.praw.Reddit", return_value=mock_reddit): resp = authed_client.get("/api/items") assert resp.status_code == 200 @@ -116,8 +108,8 @@ def test_returns_401_without_session(self, client): assert resp.status_code == 401 def test_deletes_comment(self, authed_client, tmp_path, monkeypatch): - monkeypatch.setattr("app.DELETED_COMMENTS_FILE", str(tmp_path / "deleted_comments.txt")) - monkeypatch.setattr("app.DELETED_POSTS_FILE", str(tmp_path / "deleted_posts.txt")) + monkeypatch.setattr("redditcleaner.web.app.DELETED_COMMENTS_FILE", str(tmp_path / "deleted_comments.txt")) + monkeypatch.setattr("redditcleaner.web.app.DELETED_POSTS_FILE", str(tmp_path / "deleted_posts.txt")) mock_comment = MagicMock() mock_comment.created_utc = 1700000000.0 @@ -130,8 +122,8 @@ def test_deletes_comment(self, authed_client, tmp_path, monkeypatch): mock_reddit = MagicMock() mock_reddit.comment.return_value = mock_comment - with patch("app.praw.Reddit", return_value=mock_reddit), \ - patch("app.maybe_upload_logs", return_value=[]): + with patch("redditcleaner.web.app.praw.Reddit", return_value=mock_reddit), \ + patch("redditcleaner.web.app.maybe_upload_logs", return_value=[]): resp = authed_client.post("/api/delete", json={"comment_ids": ["abc"], "post_ids": []}) assert resp.status_code == 200 diff --git a/tests/test_weekly_cleanup.py b/tests/test_weekly_cleanup.py index 2cd2ede..2c9ac10 100644 --- a/tests/test_weekly_cleanup.py +++ b/tests/test_weekly_cleanup.py @@ -1,17 +1,13 @@ """Tests for weekly_cleanup.py — _should_delete and _load_credentials.""" import os -import sys from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import patch import pytest -# Ensure repo root is importable -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from weekly_cleanup import AGE_THRESHOLD_DAYS, _load_credentials, _should_delete +from redditcleaner.ci.weekly_cleanup import AGE_THRESHOLD_DAYS, _load_credentials, _should_delete # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -74,8 +70,8 @@ def test_reads_from_credentials_file(self, tmp_path, monkeypatch): monkeypatch.delenv("REDDIT_USERNAME", raising=False) monkeypatch.delenv("REDDIT_PASSWORD", raising=False) - with patch("weekly_cleanup.os.path.join", return_value=str(cred_file)), \ - patch("weekly_cleanup.os.path.exists", return_value=True): + with patch("redditcleaner.ci.weekly_cleanup.os.path.join", return_value=str(cred_file)), \ + patch("redditcleaner.ci.weekly_cleanup.os.path.exists", return_value=True): result = _load_credentials() assert result == ("fileid", "filesecret", "fileuser", "filepass") @@ -86,7 +82,7 @@ def test_raises_when_nothing_configured(self, monkeypatch): monkeypatch.delenv("REDDIT_USERNAME", raising=False) monkeypatch.delenv("REDDIT_PASSWORD", raising=False) - with patch("weekly_cleanup.os.path.exists", return_value=False): + with patch("redditcleaner.ci.weekly_cleanup.os.path.exists", return_value=False): with pytest.raises(RuntimeError, match="Reddit credentials not found"): _load_credentials() @@ -100,8 +96,8 @@ def test_partial_env_vars_falls_through_to_file(self, tmp_path, monkeypatch): cred_file = tmp_path / "Credentials.txt" cred_file.write_text("a\nb\nc\nd\n", encoding="utf-8") - with patch("weekly_cleanup.os.path.join", return_value=str(cred_file)), \ - patch("weekly_cleanup.os.path.exists", return_value=True): + with patch("redditcleaner.ci.weekly_cleanup.os.path.join", return_value=str(cred_file)), \ + patch("redditcleaner.ci.weekly_cleanup.os.path.exists", return_value=True): result = _load_credentials() assert result == ("a", "b", "c", "d") diff --git a/web/requirements.txt b/web/requirements.txt deleted file mode 100644 index bcd0bd7..0000000 --- a/web/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -flask>=2.3,<4 -flask-wtf>=1.1,<2 -praw>=7.6,<8 -google-api-python-client>=2.0,<3 -google-auth>=2.0,<3 -google-auth-httplib2>=0.1,<1