From 2f744d978be028898313da21d22aeed7dd040323 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 16:57:43 +0100 Subject: [PATCH 01/36] Create empty src/__init__.py --- src/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/__init__.py diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ + From 44f58dbd298da03fa99cbf96e91b20e7d60fff0a Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 17:00:45 +0100 Subject: [PATCH 02/36] Rename api.py to src/api.py --- api.py => src/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename api.py => src/api.py (99%) diff --git a/api.py b/src/api.py similarity index 99% rename from api.py rename to src/api.py index 0e5df89..088b07b 100644 --- a/api.py +++ b/src/api.py @@ -63,4 +63,4 @@ def get_post_replies_count(post_id, headers): response = requests.get(f"{BASE_URL}/{post_id}/replies", headers=headers) response.raise_for_status() replies = response.json().get('data', []) - return len(replies) \ No newline at end of file + return len(replies) From 45fe3dc7bd813b92a2649fbeb7c9c959f8baf65e Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 17:01:34 +0100 Subject: [PATCH 03/36] Rename utils.py to src/utils.py --- utils.py => src/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename utils.py => src/utils.py (85%) diff --git a/utils.py b/src/utils.py similarity index 85% rename from utils.py rename to src/utils.py index 3117e82..7f1063b 100644 --- a/utils.py +++ b/src/utils.py @@ -7,4 +7,4 @@ def convert_to_locale(timestamp): if timestamp == 'N/A': return timestamp dt = datetime.strptime(timestamp, '%Y-%m-%dT%H:%M:%S%z') - return dt.strftime('%Y-%m-%d %H:%M:%S') \ No newline at end of file + return dt.strftime('%Y-%m-%d %H:%M:%S') From 4cb72406c078548495af0cbc00446f2f0067f88f Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 17:02:19 +0100 Subject: [PATCH 04/36] Rename test_main.py to tests/test_main.py --- test_main.py => tests/test_main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename test_main.py => tests/test_main.py (99%) diff --git a/test_main.py b/tests/test_main.py similarity index 99% rename from test_main.py rename to tests/test_main.py index 192a049..8a81835 100644 --- a/test_main.py +++ b/tests/test_main.py @@ -70,4 +70,4 @@ def test_send_draft(self): self.assertIn("Draft with ID 999 not found.", result.stdout) if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() From d9735805b24699f9480554b00f78f8f459e636ff Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 17:05:29 +0100 Subject: [PATCH 05/36] Fix import from src.api and src.utils in main.py --- main.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index ae517db..b57eeae 100644 --- a/main.py +++ b/main.py @@ -6,8 +6,9 @@ from rich.table import Table from datetime import datetime, timedelta, timezone from dotenv import load_dotenv -from api import get_user_id, get_user_profile, get_user_posts, get_post_insights, fetch_all_posts, create_post, get_post_replies, get_post_replies_count -from utils import convert_to_locale + +from src.api import get_user_id, get_user_profile, get_user_posts, get_post_insights, fetch_all_posts, create_post, get_post_replies, get_post_replies_count +from src.utils import convert_to_locale app = typer.Typer() console = Console() From 91c28e7a9ba535129dd734d294992a149cde1792 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 17:13:20 +0100 Subject: [PATCH 06/36] Fix import from .utils in src/api.py for src/utils.py --- src/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api.py b/src/api.py index 088b07b..85056b3 100644 --- a/src/api.py +++ b/src/api.py @@ -1,5 +1,5 @@ import requests -from utils import convert_to_locale +from .utils import convert_to_locale BASE_URL = "https://graph.threads.net/v1.0" From 06fd7581a6295ad5451b8aa4bc5a9738d33a3766 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 17:14:14 +0100 Subject: [PATCH 07/36] Update api.py Add empty line between pip module and local src module --- src/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api.py b/src/api.py index 85056b3..336d84f 100644 --- a/src/api.py +++ b/src/api.py @@ -1,4 +1,5 @@ import requests + from .utils import convert_to_locale BASE_URL = "https://graph.threads.net/v1.0" From d5c2ae704558bc18f344edafbf913ebefe79c830 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 17:21:24 +0100 Subject: [PATCH 08/36] Update test_main.py Add empty line between pip module and local src module --- tests/test_main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_main.py b/tests/test_main.py index 8a81835..f9644b0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,6 +3,7 @@ import json from unittest.mock import patch from typer.testing import CliRunner + from main import app TEST_DRAFTS_FILE = "test-drafts.json" From 4daae5a702854f22304aa067353f703ebc4a6560 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 17:27:38 +0100 Subject: [PATCH 09/36] Extract app from main.py to src/app.py and fix import of src module --- src/app.py | 321 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 src/app.py diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..11238bc --- /dev/null +++ b/src/app.py @@ -0,0 +1,321 @@ +import typer +import os +import threading +import json +from rich.console import Console +from rich.table import Table +from datetime import datetime, timedelta, timezone +from dotenv import load_dotenv + +from .api import get_user_id, get_user_profile, get_user_posts, get_post_insights, fetch_all_posts, create_post, get_post_replies, get_post_replies_count +from .utils import convert_to_locale + +app = typer.Typer() +console = Console() +load_dotenv() + +ACCESS_TOKEN = os.getenv("ACCESS_TOKEN") +HEADERS = { + 'Authorization': f'Bearer {ACCESS_TOKEN}' +} +DRAFTS_FILE = 'drafts.json' +SERVER_PROCESS_TIME = 10 + +@app.command() +def get_profile(): + """ + Retrieve and display user profile information, including the last post made by the user. + """ + user_id = get_user_id(HEADERS) + profile = get_user_profile(user_id, HEADERS) + last_post = get_user_posts(user_id, HEADERS, limit=1)[0] + + profile_table = Table(title=f'{profile["username"]}\'s Profile') + profile_table.add_column("Field", style="cyan", no_wrap=True) + profile_table.add_column("Value", style="magenta") + + profile_table.add_row("ID", profile.get("id", "N/A")) + profile_table.add_row("Username", profile.get("username", "N/A")) + profile_table.add_row("Profile Picture URL", profile.get("threads_profile_picture_url", "N/A")) + profile_table.add_row("Biography", profile.get("threads_biography", "N/A")) + if last_post: + profile_table.add_row("Last Post ID", last_post.get("id", "N/A")) + profile_table.add_row("Post Type", last_post.get("media_type", "N/A")) + profile_table.add_row("Post Text", last_post.get("text", "N/A")) + profile_table.add_row("Post Permalink", last_post.get("permalink", "N/A")) + profile_table.add_row("Post Timestamp", convert_to_locale(last_post.get("timestamp", "N/A"))) + else: + profile_table.add_row("Message", "No posts found") + + console.print(profile_table) + +@app.command() +def get_recent_posts(limit: int = 5): + """ + Retrieve the most recent posts. + """ + user_id = get_user_id(HEADERS) + posts = get_user_posts(user_id, HEADERS, limit=limit) + + table = Table(title="Recent Posts") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Username", style="cyan", no_wrap=True) + table.add_column("Timestamp", style="magenta") + table.add_column("Type", style="green") + table.add_column("Text", style="yellow") + table.add_column("Permalink", style="blue") + table.add_column("Replies", style="red") + + for post in posts: + if post.get('media_type') == 'REPOST_FACADE': + continue + timestamp = convert_to_locale(post.get('timestamp', 'N/A')) + replies_count = get_post_replies_count(post['id'], HEADERS) + table.add_row( + post.get('id', 'N/A'), + post.get('username', 'N/A'), + timestamp, + post.get('media_type', 'N/A'), + post.get('text', 'N/A'), + post.get('permalink', 'N/A'), + str(replies_count) + ) + + console.print(table) + +@app.command() +def get_top_liked_posts(limit: int = 5, time_range: str = None): + """ + Retrieve the top liked posts of all time or within a specific time range. + """ + user_id = get_user_id(HEADERS) + all_posts = fetch_all_posts(user_id, HEADERS) + + if time_range: + now = datetime.now(timezone.utc) + if time_range.endswith('w'): + weeks = int(time_range[:-1]) + start_time = now - timedelta(weeks=weeks) + elif time_range.endswith('d'): + days = int(time_range[:-1]) + start_time = now - timedelta(days=days) + elif time_range.endswith('h'): + hours = int(time_range[:-1]) + start_time = now - timedelta(hours=hours) + elif time_range.endswith('m'): + months = int(time_range[:-1]) + start_time = now - timedelta(days=30 * months) + else: + typer.echo("Invalid time range format. Use '2w' for 2 weeks, '7d' for 7 days, '24h' for 24 hours, or '7m' for 7 months.") + return + + all_posts = [post for post in all_posts if datetime.strptime(post['timestamp'], '%Y-%m-%dT%H:%M:%S%z') >= start_time] + + posts_with_likes = [] + for post in all_posts: + if post.get('media_type') == 'REPOST_FACADE': + continue + insights = get_post_insights(post['id'], HEADERS) + if 'likes' in insights: + posts_with_likes.append((post, insights['likes'])) + + posts_with_likes.sort(key=lambda x: x[1], reverse=True) + top_liked_posts = posts_with_likes[:limit] + + table = Table(title="Top Liked Posts") + table.add_column("Username", style="cyan", no_wrap=True) + table.add_column("Timestamp", style="magenta") + table.add_column("Type", style="green") + table.add_column("Text", style="yellow") + table.add_column("Permalink", style="blue") + table.add_column("Likes", style="red") + table.add_column("Replies", style="green") + table.add_column("Reposts", style="blue") + table.add_column("Quotes", style="yellow") + table.add_column("Views", style="cyan") + + for post, likes in top_liked_posts: + timestamp = convert_to_locale(post.get('timestamp', 'N/A')) + insights = get_post_insights(post['id'], HEADERS) + table.add_row( + post.get('username', 'N/A'), + timestamp, + post.get('media_type', 'N/A'), + post.get('text', 'N/A'), + post.get('permalink', 'N/A'), + str(insights.get('likes', 'N/A')), + str(insights.get('replies', 'N/A')), + str(insights.get('reposts', 'N/A')), + str(insights.get('quotes', 'N/A')), + str(insights.get('views', 'N/A')) + ) + + console.print(table) + +@app.command() +def create_text_post(text: str): + """ + Create a post with text. + """ + user_id = get_user_id(HEADERS) + payload = { + "media_type": "TEXT", + "text": text + } + post = create_post(user_id, HEADERS, payload) + typer.echo(f"Post created with ID: {post['id']}") + +@app.command() +def create_image_post(text: str, image_url: str): + """ + Create a post with an image. + """ + user_id = get_user_id(HEADERS) + payload = { + "media_type": "IMAGE", + "image_url": image_url, + "text": text + } + post = create_post(user_id, HEADERS, payload) + typer.echo(f"Post created with ID: {post['id']}") + +@app.command() +def get_latest_replies(media_id: str, limit: int = 5): + """ + Retrieve the latest replies for a specific media post. + """ + replies = get_post_replies(media_id, HEADERS, limit=limit) + + table = Table(title="Latest Replies") + table.add_column("Username", style="cyan", no_wrap=True) + table.add_column("Media ID", style="cyan", no_wrap=True) + table.add_column("Timestamp", style="magenta") + table.add_column("Text", style="yellow") + table.add_column("Permalink", style="blue") + + for reply in replies: + timestamp = convert_to_locale(reply.get('timestamp', 'N/A')) + table.add_row( + reply.get('username', 'N/A'), + reply.get('id', 'N/A'), + timestamp, + reply.get('text', 'N/A'), + reply.get('permalink', 'N/A') + ) + + console.print(table) + +@app.command() +def send_reply(media_id: str, text: str): + """ + Send a reply to a specific media post. + """ + user_id = get_user_id(HEADERS) + payload = { + "media_type": "TEXT", + "text": text, + "reply_to_id": media_id + } + reply = create_post(user_id, HEADERS, payload) + typer.echo(f"Reply created with ID: {reply['id']}") + +def job_create_text_post(text: str): + """ + Job function to create a post with text. + """ + create_text_post(text) + +@app.command() +def schedule_post(text: str, post_time: str): + """ + Schedule a post with text at a specific time. + """ + post_time_dt = datetime.strptime(post_time, '%Y-%m-%d %H:%M:%S') + current_time = datetime.now() + delay = (post_time_dt - current_time).total_seconds() + + if delay <= 0: + typer.echo("Scheduled time must be in the future.") + return + + timer = threading.Timer(delay, job_create_text_post, [text]) + timer.start() + typer.echo(f"Post scheduled for {post_time} with text: '{text}'") + +@app.command() +def create_draft(text: str, drafts_file: str = DRAFTS_FILE): + ''' + Create a draft with the given text and save it to the drafts file. + ''' + if os.path.exists(drafts_file): + with open(drafts_file, 'r') as file: + drafts = json.load(file) + else: + drafts = [] + + next_id = max([draft['id'] for draft in drafts], default=0) + 1 + + draft = { + "id": next_id, + "text": text, + "timestamp": datetime.now().strftime('%Y-%m-%d %H:%M:%S') + } + drafts.append(draft) + + with open(drafts_file, 'w') as file: + json.dump(drafts, file, indent=4) + + typer.echo(f"Draft created with ID: {next_id}") + +@app.command() +def get_drafts(drafts_file: str = DRAFTS_FILE): + ''' + Get all drafts from the drafts file. + ''' + if not os.path.exists(drafts_file): + typer.echo("No drafts found.") + return + + with open(drafts_file, 'r') as file: + drafts = json.load(file) + + table = Table(title="Drafts") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Text", style="yellow") + table.add_column("Timestamp", style="magenta") + + for draft in drafts: + table.add_row( + str(draft['id']), + draft['text'], + draft['timestamp'] + ) + + console.print(table) + +@app.command() +def send_draft(draft_id: int, drafts_file: str = DRAFTS_FILE): + ''' + Send a draft with the given ID and remove it from the drafts file. + ''' + if not os.path.exists(drafts_file): + typer.echo("No drafts found.") + raise typer.Exit(1) + + with open(drafts_file, 'r') as file: + drafts = json.load(file) + + draft = next((draft for draft in drafts if draft['id'] == draft_id), None) + + if draft is None: + typer.echo(f"Draft with ID {draft_id} not found.") + raise typer.Exit(1) + + create_text_post(draft['text']) + + drafts = [draft for draft in drafts if draft['id'] != draft_id] + + with open(drafts_file, 'w') as file: + json.dump(drafts, file, indent=4) + + typer.echo(f"Draft with ID {draft_id} sent and removed from drafts.") From 0f51ec7fa30e5c3a02a4e4136b128049e4046f36 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 17:31:37 +0100 Subject: [PATCH 10/36] Create src/env.py --- src/env.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/env.py diff --git a/src/env.py b/src/env.py new file mode 100644 index 0000000..f50eb3b --- /dev/null +++ b/src/env.py @@ -0,0 +1,5 @@ +from dotenv import load_dotenv + +load_dotenv() + +ACCESS_TOKEN = os.getenv("ACCESS_TOKEN") From 6a11b2ef123311ccf815e7e93663eb59c6f16c4d Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 17:39:50 +0100 Subject: [PATCH 11/36] Update env.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added import os and défaut empty string value. --- src/env.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/env.py b/src/env.py index f50eb3b..db3ab23 100644 --- a/src/env.py +++ b/src/env.py @@ -1,5 +1,7 @@ +import os + from dotenv import load_dotenv load_dotenv() -ACCESS_TOKEN = os.getenv("ACCESS_TOKEN") +ACCESS_TOKEN = os.getenv("ACCESS_TOKEN", "") From f08855183b5f59800ba23facbece35f63f6cdfc1 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 18:28:00 +0100 Subject: [PATCH 12/36] Update app.py - Reorder import. - Import access token and draft file from src/env.py . - Load new empty draft file from xdg cache dir if not exist or from user env DRAFTS_FILE if exist. - if user draft file is filename and not exist , use the user name from xdg cache dir as new file. --- src/app.py | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/app.py b/src/app.py index 11238bc..7f37525 100644 --- a/src/app.py +++ b/src/app.py @@ -1,26 +1,51 @@ -import typer import os import threading +from datetime import datetime, timedelta, timezone import json + +import typer from rich.console import Console from rich.table import Table -from datetime import datetime, timedelta, timezone -from dotenv import load_dotenv +from .env import ACCESS_TOKEN, DRAFTS_FILE from .api import get_user_id, get_user_profile, get_user_posts, get_post_insights, fetch_all_posts, create_post, get_post_replies, get_post_replies_count from .utils import convert_to_locale -app = typer.Typer() -console = Console() -load_dotenv() - ACCESS_TOKEN = os.getenv("ACCESS_TOKEN") + +if not ACCESS_TOKEN: + print("Error: The required ACCESS_TOKEN is not set. Please set it in your environment or in your .env file.") + sys.exit(1) + +# Determine if DRAFTS_FILE contains a path separator. +if not os.path.exists(DRAFTS_FILE) and os.path.sep not in DRAFTS_FILE: + # Determine the cache directory + # XDG_CACHE_HOME defaults to HOME/.cache if not set + xdg_cache_home = os.getenv('XDG_CACHE_HOME') + if not xdg_cache_home: + home = os.getenv('HOME') + if not home: + raise EnvironmentError("HOME environment variable is not set.") + xdg_cache_home = os.path.join(home, '.cache') + # Define final path: XDG_CACHE_HOME/threads-cli/DRAFTS_FILE + drafts_dir = os.path.join(xdg_cache_home, "threads-cli") + os.makedirs(drafts_dir, exist_ok=True) # Create directory if it doesn't exist + DRAFTS_FILE = os.path.join(drafts_dir, DRAFTS_FILE) +# If it's not there, create an empty JSON file. +if not os.path.exists(DRAFTS_FILE): + with open(DRAFTS_FILE, "w") as f: + # Create an empty list or dict, depending on your needs. + # Here we write an empty dict. + json.dump({}, f) + HEADERS = { 'Authorization': f'Bearer {ACCESS_TOKEN}' } -DRAFTS_FILE = 'drafts.json' SERVER_PROCESS_TIME = 10 +app = typer.Typer() +console = Console() + @app.command() def get_profile(): """ From 95e294fcf2259f3c1d809d7d1c86bec90a6c6fdd Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 18:30:16 +0100 Subject: [PATCH 13/36] Update env.py Load DRAFTS_FILE env default to 'drafts.json' --- src/env.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/env.py b/src/env.py index db3ab23..0265ba6 100644 --- a/src/env.py +++ b/src/env.py @@ -5,3 +5,4 @@ load_dotenv() ACCESS_TOKEN = os.getenv("ACCESS_TOKEN", "") +DRAFTS_FILE = os.getenv("DRAFTS_FILE", "drafts.json") From a41591330b55f97d168f0c62c76df1bbd1e50d77 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 18:34:17 +0100 Subject: [PATCH 14/36] Update app.py Fix typo --- src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.py b/src/app.py index 7f37525..a7c5416 100644 --- a/src/app.py +++ b/src/app.py @@ -17,7 +17,7 @@ print("Error: The required ACCESS_TOKEN is not set. Please set it in your environment or in your .env file.") sys.exit(1) -# Determine if DRAFTS_FILE contains a path separator. +# Determine if DRAFTS_FILE not exist and contains a path separator. if not os.path.exists(DRAFTS_FILE) and os.path.sep not in DRAFTS_FILE: # Determine the cache directory # XDG_CACHE_HOME defaults to HOME/.cache if not set From a0fa149a59487ff23821991b75de43bc6fe2c8c8 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 18:56:29 +0100 Subject: [PATCH 15/36] Create .env.example With access token and draft file env. --- .env.example | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3fc00df --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# .env.example + +# ACCESS_TOKEN is used for authenticating API requests to Meta's Threads app and Threads.net, +# Provide your token by setting this environment variable. +# Required: No default. +ACCESS_TOKEN="" + +# DRAFTS_FILE specifies the file name or path for saving draft data. +# Optional: Default = "drafts.json" +#DRAFTS_FILE="drafts.json" From 885ea7f9eadf5500204499145d97c28e4f872de2 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 19:00:01 +0100 Subject: [PATCH 16/36] Update app.py Explain draft file env logic --- src/app.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/app.py b/src/app.py index a7c5416..510dc13 100644 --- a/src/app.py +++ b/src/app.py @@ -17,6 +17,17 @@ print("Error: The required ACCESS_TOKEN is not set. Please set it in your environment or in your .env file.") sys.exit(1) +# DRAFTS_FILE specifies the file name or path for saving draft data. +# If DRAFTS_FILE does not exist and contains only a simple filename (without any path separator), +# the application uses the XDG Base Directory Specification to determine the cache directory: +# - It first checks for the XDG_CACHE_HOME environment variable. +# - If not set, it defaults to HOME/.cache. +# Then, a sub-folder named "threads-cli" is created within the cache directory, +# and DRAFTS_FILE is placed inside that sub-folder. +# If DRAFTS_FILE exists as a simple filename, it is used as provided. +# If DRAFTS_FILE already contains a path separator (i.e., it's a complete path), +# the application will use it exactly as given. + # Determine if DRAFTS_FILE not exist and contains a path separator. if not os.path.exists(DRAFTS_FILE) and os.path.sep not in DRAFTS_FILE: # Determine the cache directory From e5310957b1110298ee02cd342c741a463fbe7fc4 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 19:05:19 +0100 Subject: [PATCH 17/36] Move BASE_URL to src/env.py --- src/api.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/api.py b/src/api.py index 336d84f..673b93a 100644 --- a/src/api.py +++ b/src/api.py @@ -1,9 +1,8 @@ import requests +from .env import BASE_URL from .utils import convert_to_locale -BASE_URL = "https://graph.threads.net/v1.0" - def get_user_id(headers): response = requests.get(f"{BASE_URL}/me?fields=id", headers=headers) response.raise_for_status() From 2fba4f79d7e755a80b490f41998aa6bab7a39f34 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 19:08:05 +0100 Subject: [PATCH 18/36] Load BASE_URL from env --- src/env.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/env.py b/src/env.py index 0265ba6..9c56374 100644 --- a/src/env.py +++ b/src/env.py @@ -5,4 +5,6 @@ load_dotenv() ACCESS_TOKEN = os.getenv("ACCESS_TOKEN", "") +BASE_URL = os.getenv("BASE_URL", "https://graph.threads.net/v1.0") + DRAFTS_FILE = os.getenv("DRAFTS_FILE", "drafts.json") From b3ecf6c5b737ecca1ede0347319038cb7888342e Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 19:10:05 +0100 Subject: [PATCH 19/36] Update .env.example Add BASE_URL to env example --- .env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.env.example b/.env.example index 3fc00df..45e565a 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,10 @@ # Required: No default. ACCESS_TOKEN="" +# BASE_URL specifies the API endpoint for accessing Threads.net. +# It defaults to "https://graph.threads.net/v1.0", but you can override it by setting the variable. +BASE_URL="https://graph.threads.net/v1.0" + # DRAFTS_FILE specifies the file name or path for saving draft data. # Optional: Default = "drafts.json" #DRAFTS_FILE="drafts.json" From 1f80b22bf5feecbc1491e018f0905a2ba795ea8c Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 19:13:49 +0100 Subject: [PATCH 20/36] Split long import statement from .api into multiple lines using parentheses --- src/app.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app.py b/src/app.py index 510dc13..e88a3aa 100644 --- a/src/app.py +++ b/src/app.py @@ -8,7 +8,16 @@ from rich.table import Table from .env import ACCESS_TOKEN, DRAFTS_FILE -from .api import get_user_id, get_user_profile, get_user_posts, get_post_insights, fetch_all_posts, create_post, get_post_replies, get_post_replies_count +from .api import ( + get_user_id, + get_user_profile, + get_user_posts, + get_post_insights, + fetch_all_posts, + create_post, + get_post_replies, + get_post_replies_count, +) from .utils import convert_to_locale ACCESS_TOKEN = os.getenv("ACCESS_TOKEN") From ddd808f240c2892d6a800a0325544c7944cf1a69 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 19:17:19 +0100 Subject: [PATCH 21/36] Fix typo in .env.example comment --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 45e565a..8991bf4 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,7 @@ # Required: No default. ACCESS_TOKEN="" -# BASE_URL specifies the API endpoint for accessing Threads.net. +# BASE_URL specifies the API endpoint for accessing Meta's Threads app. # It defaults to "https://graph.threads.net/v1.0", but you can override it by setting the variable. BASE_URL="https://graph.threads.net/v1.0" From 2fbe4ec309bfaee2107457965826bdbeae7b1c8e Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 19:24:55 +0100 Subject: [PATCH 22/36] Update main.py Import app variable from src/app.py --- main.py | 323 +------------------------------------------------------- 1 file changed, 2 insertions(+), 321 deletions(-) diff --git a/main.py b/main.py index b57eeae..bdc9249 100644 --- a/main.py +++ b/main.py @@ -1,324 +1,5 @@ -import typer -import os -import threading -import json -from rich.console import Console -from rich.table import Table -from datetime import datetime, timedelta, timezone -from dotenv import load_dotenv - -from src.api import get_user_id, get_user_profile, get_user_posts, get_post_insights, fetch_all_posts, create_post, get_post_replies, get_post_replies_count -from src.utils import convert_to_locale - -app = typer.Typer() -console = Console() -load_dotenv() - -ACCESS_TOKEN = os.getenv("ACCESS_TOKEN") -HEADERS = { - 'Authorization': f'Bearer {ACCESS_TOKEN}' -} -DRAFTS_FILE = 'drafts.json' -SERVER_PROCESS_TIME = 10 - -@app.command() -def get_profile(): - """ - Retrieve and display user profile information, including the last post made by the user. - """ - user_id = get_user_id(HEADERS) - profile = get_user_profile(user_id, HEADERS) - last_post = get_user_posts(user_id, HEADERS, limit=1)[0] - - profile_table = Table(title=f'{profile["username"]}\'s Profile') - profile_table.add_column("Field", style="cyan", no_wrap=True) - profile_table.add_column("Value", style="magenta") - - profile_table.add_row("ID", profile.get("id", "N/A")) - profile_table.add_row("Username", profile.get("username", "N/A")) - profile_table.add_row("Profile Picture URL", profile.get("threads_profile_picture_url", "N/A")) - profile_table.add_row("Biography", profile.get("threads_biography", "N/A")) - if last_post: - profile_table.add_row("Last Post ID", last_post.get("id", "N/A")) - profile_table.add_row("Post Type", last_post.get("media_type", "N/A")) - profile_table.add_row("Post Text", last_post.get("text", "N/A")) - profile_table.add_row("Post Permalink", last_post.get("permalink", "N/A")) - profile_table.add_row("Post Timestamp", convert_to_locale(last_post.get("timestamp", "N/A"))) - else: - profile_table.add_row("Message", "No posts found") - - console.print(profile_table) - -@app.command() -def get_recent_posts(limit: int = 5): - """ - Retrieve the most recent posts. - """ - user_id = get_user_id(HEADERS) - posts = get_user_posts(user_id, HEADERS, limit=limit) - - table = Table(title="Recent Posts") - table.add_column("ID", style="cyan", no_wrap=True) - table.add_column("Username", style="cyan", no_wrap=True) - table.add_column("Timestamp", style="magenta") - table.add_column("Type", style="green") - table.add_column("Text", style="yellow") - table.add_column("Permalink", style="blue") - table.add_column("Replies", style="red") - - for post in posts: - if post.get('media_type') == 'REPOST_FACADE': - continue - timestamp = convert_to_locale(post.get('timestamp', 'N/A')) - replies_count = get_post_replies_count(post['id'], HEADERS) - table.add_row( - post.get('id', 'N/A'), - post.get('username', 'N/A'), - timestamp, - post.get('media_type', 'N/A'), - post.get('text', 'N/A'), - post.get('permalink', 'N/A'), - str(replies_count) - ) - - console.print(table) - -@app.command() -def get_top_liked_posts(limit: int = 5, time_range: str = None): - """ - Retrieve the top liked posts of all time or within a specific time range. - """ - user_id = get_user_id(HEADERS) - all_posts = fetch_all_posts(user_id, HEADERS) - - if time_range: - now = datetime.now(timezone.utc) - if time_range.endswith('w'): - weeks = int(time_range[:-1]) - start_time = now - timedelta(weeks=weeks) - elif time_range.endswith('d'): - days = int(time_range[:-1]) - start_time = now - timedelta(days=days) - elif time_range.endswith('h'): - hours = int(time_range[:-1]) - start_time = now - timedelta(hours=hours) - elif time_range.endswith('m'): - months = int(time_range[:-1]) - start_time = now - timedelta(days=30 * months) - else: - typer.echo("Invalid time range format. Use '2w' for 2 weeks, '7d' for 7 days, '24h' for 24 hours, or '7m' for 7 months.") - return - - all_posts = [post for post in all_posts if datetime.strptime(post['timestamp'], '%Y-%m-%dT%H:%M:%S%z') >= start_time] - - posts_with_likes = [] - for post in all_posts: - if post.get('media_type') == 'REPOST_FACADE': - continue - insights = get_post_insights(post['id'], HEADERS) - if 'likes' in insights: - posts_with_likes.append((post, insights['likes'])) - - posts_with_likes.sort(key=lambda x: x[1], reverse=True) - top_liked_posts = posts_with_likes[:limit] - - table = Table(title="Top Liked Posts") - table.add_column("Username", style="cyan", no_wrap=True) - table.add_column("Timestamp", style="magenta") - table.add_column("Type", style="green") - table.add_column("Text", style="yellow") - table.add_column("Permalink", style="blue") - table.add_column("Likes", style="red") - table.add_column("Replies", style="green") - table.add_column("Reposts", style="blue") - table.add_column("Quotes", style="yellow") - table.add_column("Views", style="cyan") - - for post, likes in top_liked_posts: - timestamp = convert_to_locale(post.get('timestamp', 'N/A')) - insights = get_post_insights(post['id'], HEADERS) - table.add_row( - post.get('username', 'N/A'), - timestamp, - post.get('media_type', 'N/A'), - post.get('text', 'N/A'), - post.get('permalink', 'N/A'), - str(insights.get('likes', 'N/A')), - str(insights.get('replies', 'N/A')), - str(insights.get('reposts', 'N/A')), - str(insights.get('quotes', 'N/A')), - str(insights.get('views', 'N/A')) - ) - - console.print(table) - -@app.command() -def create_text_post(text: str): - """ - Create a post with text. - """ - user_id = get_user_id(HEADERS) - payload = { - "media_type": "TEXT", - "text": text - } - post = create_post(user_id, HEADERS, payload) - typer.echo(f"Post created with ID: {post['id']}") - -@app.command() -def create_image_post(text: str, image_url: str): - """ - Create a post with an image. - """ - user_id = get_user_id(HEADERS) - payload = { - "media_type": "IMAGE", - "image_url": image_url, - "text": text - } - post = create_post(user_id, HEADERS, payload) - typer.echo(f"Post created with ID: {post['id']}") - -@app.command() -def get_latest_replies(media_id: str, limit: int = 5): - """ - Retrieve the latest replies for a specific media post. - """ - replies = get_post_replies(media_id, HEADERS, limit=limit) - - table = Table(title="Latest Replies") - table.add_column("Username", style="cyan", no_wrap=True) - table.add_column("Media ID", style="cyan", no_wrap=True) - table.add_column("Timestamp", style="magenta") - table.add_column("Text", style="yellow") - table.add_column("Permalink", style="blue") - - for reply in replies: - timestamp = convert_to_locale(reply.get('timestamp', 'N/A')) - table.add_row( - reply.get('username', 'N/A'), - reply.get('id', 'N/A'), - timestamp, - reply.get('text', 'N/A'), - reply.get('permalink', 'N/A') - ) - - console.print(table) - -@app.command() -def send_reply(media_id: str, text: str): - """ - Send a reply to a specific media post. - """ - user_id = get_user_id(HEADERS) - payload = { - "media_type": "TEXT", - "text": text, - "reply_to_id": media_id - } - reply = create_post(user_id, HEADERS, payload) - typer.echo(f"Reply created with ID: {reply['id']}") - -def job_create_text_post(text: str): - """ - Job function to create a post with text. - """ - create_text_post(text) - -@app.command() -def schedule_post(text: str, post_time: str): - """ - Schedule a post with text at a specific time. - """ - post_time_dt = datetime.strptime(post_time, '%Y-%m-%d %H:%M:%S') - current_time = datetime.now() - delay = (post_time_dt - current_time).total_seconds() - - if delay <= 0: - typer.echo("Scheduled time must be in the future.") - return - - timer = threading.Timer(delay, job_create_text_post, [text]) - timer.start() - typer.echo(f"Post scheduled for {post_time} with text: '{text}'") - -@app.command() -def create_draft(text: str, drafts_file: str = DRAFTS_FILE): - ''' - Create a draft with the given text and save it to the drafts file. - ''' - if os.path.exists(drafts_file): - with open(drafts_file, 'r') as file: - drafts = json.load(file) - else: - drafts = [] - - next_id = max([draft['id'] for draft in drafts], default=0) + 1 - - draft = { - "id": next_id, - "text": text, - "timestamp": datetime.now().strftime('%Y-%m-%d %H:%M:%S') - } - drafts.append(draft) - - with open(drafts_file, 'w') as file: - json.dump(drafts, file, indent=4) - - typer.echo(f"Draft created with ID: {next_id}") - -@app.command() -def get_drafts(drafts_file: str = DRAFTS_FILE): - ''' - Get all drafts from the drafts file. - ''' - if not os.path.exists(drafts_file): - typer.echo("No drafts found.") - return - - with open(drafts_file, 'r') as file: - drafts = json.load(file) - - table = Table(title="Drafts") - table.add_column("ID", style="cyan", no_wrap=True) - table.add_column("Text", style="yellow") - table.add_column("Timestamp", style="magenta") - - for draft in drafts: - table.add_row( - str(draft['id']), - draft['text'], - draft['timestamp'] - ) - - console.print(table) - -@app.command() -def send_draft(draft_id: int, drafts_file: str = DRAFTS_FILE): - ''' - Send a draft with the given ID and remove it from the drafts file. - ''' - if not os.path.exists(drafts_file): - typer.echo("No drafts found.") - raise typer.Exit(1) - - with open(drafts_file, 'r') as file: - drafts = json.load(file) - - draft = next((draft for draft in drafts if draft['id'] == draft_id), None) - - if draft is None: - typer.echo(f"Draft with ID {draft_id} not found.") - raise typer.Exit(1) - - create_text_post(draft['text']) - - drafts = [draft for draft in drafts if draft['id'] != draft_id] - - with open(drafts_file, 'w') as file: - json.dump(drafts, file, indent=4) - - typer.echo(f"Draft with ID {draft_id} sent and removed from drafts.") +import src.env +from src.app import app def main(): app() From c58d991456374e7a5f471488dd43f905fe15a2e1 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 20:02:48 +0100 Subject: [PATCH 23/36] Update test_main.py Reorder import. Load app from src/app.py. Add commented Alternative Method to Modify sys.path for importing app from main.py , non ideal, when tests rely on running the application entry point. --- tests/test_main.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index f9644b0..77954dc 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,10 +1,29 @@ -import unittest import os import json +import unittest from unittest.mock import patch + from typer.testing import CliRunner -from main import app +# Import from the local package located in the 'src' directory +from src.app import app + +# Import the 'app' object from the local module in the 'src' package. +from src.app import app + +# --- Alternative Method to Modify sys.path for Testing --- +# This approach is used to allow importing main.py, which is located in the project's root directory, +# when such tests rely on running the application entry point. +# Note: main.py defines a main() function and runs it if executed directly by the user. +# Modifying sys.path is less ideal because it involves changing the import path at runtime, which can lead to +# maintenance issues or conflicts with the module namespace. + +# # Uncomment the line below to update sys.path, adding the project root directory. +# import sys +# sys.path.insert(0, os.path.abspath( +# os.path.join(os.path.dirname(__file__), '..') +# )) +# from main import app TEST_DRAFTS_FILE = "test-drafts.json" From 411d7e378eee1e590072efd54511dd30d61562ed Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 20:09:38 +0100 Subject: [PATCH 24/36] Create draft_utils.py --- src/draft_utils.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/draft_utils.py diff --git a/src/draft_utils.py b/src/draft_utils.py new file mode 100644 index 0000000..d3ef53c --- /dev/null +++ b/src/draft_utils.py @@ -0,0 +1,46 @@ +import os +import json + +def ensure_drafts_file(drafts_file: str) -> str: + """ + Ensure the drafts file exists following these rules: + + - If the provided drafts_file does not exist and it is a simple filename (i.e., it does not contain + any path separator), then use the XDG Base Directory Specification to determine a cache directory: + * It checks for the XDG_CACHE_HOME environment variable. + * If not set, defaults to HOME/.cache. + Then, a sub-folder named "threads-cli" is created within that cache directory, and + drafts_file is placed inside that sub-folder. + + - If drafts_file is a simple filename, but a file does not exist at that location, or if it is given as a full + path, the application uses that path exactly as given. + + - If the drafts file does not exist, create an empty JSON file (with an empty dict as content by default). + + Returns: + The final path to the drafts file. + + Raises: + EnvironmentError: If the required HOME environment variable is not set when needed. + """ + # Check if drafts_file doesn't exist and is a simple filename (without path separator). + if not os.path.exists(drafts_file) and os.path.sep not in drafts_file: + # Determine the cache directory using XDG_CACHE_HOME if available, + # otherwise default to HOME/.cache. + xdg_cache_home = os.getenv('XDG_CACHE_HOME') + if not xdg_cache_home: + home = os.getenv('HOME') + if not home: + raise EnvironmentError("HOME environment variable is not set.") + xdg_cache_home = os.path.join(home, '.cache') + # Define the final path: XDG_CACHE_HOME/threads-cli/drafts_file + drafts_dir = os.path.join(xdg_cache_home, "threads-cli") + os.makedirs(drafts_dir, exist_ok=True) + drafts_file = os.path.join(drafts_dir, drafts_file) + + # If the drafts file still does not exist, create an empty JSON file. + if not os.path.exists(drafts_file): + with open(drafts_file, "w") as f: + json.dump({}, f) + + return drafts_file From 0f99aaa85f0c6178dd6b11697e21ffcab51f1353 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 20:19:04 +0100 Subject: [PATCH 25/36] Import ensure_drafts_file from src/draft_uyils.py and use it # Ensure DRAFTS_FILE path is resolved and the file exists, following XDG Base Directory Specification if necessary --- src/app.py | 34 +++------------------------------- 1 file changed, 3 insertions(+), 31 deletions(-) diff --git a/src/app.py b/src/app.py index e88a3aa..7fb47af 100644 --- a/src/app.py +++ b/src/app.py @@ -19,6 +19,7 @@ get_post_replies_count, ) from .utils import convert_to_locale +from .draft_utils import ensure_drafts_file ACCESS_TOKEN = os.getenv("ACCESS_TOKEN") @@ -26,37 +27,8 @@ print("Error: The required ACCESS_TOKEN is not set. Please set it in your environment or in your .env file.") sys.exit(1) -# DRAFTS_FILE specifies the file name or path for saving draft data. -# If DRAFTS_FILE does not exist and contains only a simple filename (without any path separator), -# the application uses the XDG Base Directory Specification to determine the cache directory: -# - It first checks for the XDG_CACHE_HOME environment variable. -# - If not set, it defaults to HOME/.cache. -# Then, a sub-folder named "threads-cli" is created within the cache directory, -# and DRAFTS_FILE is placed inside that sub-folder. -# If DRAFTS_FILE exists as a simple filename, it is used as provided. -# If DRAFTS_FILE already contains a path separator (i.e., it's a complete path), -# the application will use it exactly as given. - -# Determine if DRAFTS_FILE not exist and contains a path separator. -if not os.path.exists(DRAFTS_FILE) and os.path.sep not in DRAFTS_FILE: - # Determine the cache directory - # XDG_CACHE_HOME defaults to HOME/.cache if not set - xdg_cache_home = os.getenv('XDG_CACHE_HOME') - if not xdg_cache_home: - home = os.getenv('HOME') - if not home: - raise EnvironmentError("HOME environment variable is not set.") - xdg_cache_home = os.path.join(home, '.cache') - # Define final path: XDG_CACHE_HOME/threads-cli/DRAFTS_FILE - drafts_dir = os.path.join(xdg_cache_home, "threads-cli") - os.makedirs(drafts_dir, exist_ok=True) # Create directory if it doesn't exist - DRAFTS_FILE = os.path.join(drafts_dir, DRAFTS_FILE) -# If it's not there, create an empty JSON file. -if not os.path.exists(DRAFTS_FILE): - with open(DRAFTS_FILE, "w") as f: - # Create an empty list or dict, depending on your needs. - # Here we write an empty dict. - json.dump({}, f) +# Ensure DRAFTS_FILE path is resolved and the file exists, following XDG Base Directory Specification if necessary. +DRAFTS_FILE = ensure_drafts_file(DRAFTS_FILE) HEADERS = { 'Authorization': f'Bearer {ACCESS_TOKEN}' From bbb333b002e58f5bf8eb85cb32a9f8a5d64f81ad Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Wed, 7 May 2025 20:29:48 +0100 Subject: [PATCH 26/36] Import ensure_drafts_file from src/draft_utils.py Implement unique test drafts file generation --- tests/test_main.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_main.py b/tests/test_main.py index 77954dc..a82c32a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,4 +1,5 @@ import os +import uuid import json import unittest from unittest.mock import patch @@ -25,7 +26,12 @@ # )) # from main import app -TEST_DRAFTS_FILE = "test-drafts.json" +# Generate a unique test drafts file name +TEST_DRAFTS_FILE = f"test-drafts-{uuid.uuid4().hex[:6]}.json" + +# Ensure DRAFTS_FILE path is resolved and the file exists, following XDG Base Directory Specification if necessary. +TEST_DRAFTS_FILE = ensure_drafts_file(TEST_DRAFTS_FILE) +os.remove(TEST_DRAFTS_FILE) class TestThreadsCLI(unittest.TestCase): def setUp(self): From 5f9ef65c2e2618a60010a9b5fec59b08a0aaa954 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Thu, 8 May 2025 06:14:12 +0100 Subject: [PATCH 27/36] Update .env.example Update comments of ACCESS_TOKEN and BASE_URL --- .env.example | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 8991bf4..68753a1 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,12 @@ # .env.example # ACCESS_TOKEN is used for authenticating API requests to Meta's Threads app and Threads.net, -# Provide your token by setting this environment variable. # Required: No default. ACCESS_TOKEN="" # BASE_URL specifies the API endpoint for accessing Meta's Threads app. -# It defaults to "https://graph.threads.net/v1.0", but you can override it by setting the variable. -BASE_URL="https://graph.threads.net/v1.0" +# Optional: Default= "https://graph.threads.net/v1.0" +#BASE_URL="https://graph.threads.net/v1.0" # DRAFTS_FILE specifies the file name or path for saving draft data. # Optional: Default = "drafts.json" From 640feca1eb3bb2508c00385c621ba205a06882a2 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Thu, 8 May 2025 06:15:41 +0100 Subject: [PATCH 28/36] Update test_main.py Remove import app duplicate line --- tests/test_main.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index a82c32a..ebb685c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6,9 +6,6 @@ from typer.testing import CliRunner -# Import from the local package located in the 'src' directory -from src.app import app - # Import the 'app' object from the local module in the 'src' package. from src.app import app From 3667cdd71170f239e2caf106a09a308ad85b5fa2 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Thu, 8 May 2025 06:18:20 +0100 Subject: [PATCH 29/36] Update test_main.py Fix typo in comment : TEST_DRAFTS_FILE instead of DRAFTS_FILE --- tests/test_main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_main.py b/tests/test_main.py index ebb685c..c109250 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -26,7 +26,7 @@ # Generate a unique test drafts file name TEST_DRAFTS_FILE = f"test-drafts-{uuid.uuid4().hex[:6]}.json" -# Ensure DRAFTS_FILE path is resolved and the file exists, following XDG Base Directory Specification if necessary. +# Ensure TEST_DRAFTS_FILE path is resolved and the file exists, following XDG Base Directory Specification if necessary. TEST_DRAFTS_FILE = ensure_drafts_file(TEST_DRAFTS_FILE) os.remove(TEST_DRAFTS_FILE) From ef909b7b3015c41d460605dce822d8b004aab3ba Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Thu, 8 May 2025 06:30:00 +0100 Subject: [PATCH 30/36] Fix : ./src/app.py:28:5: F821 undefined name 'sys' --- src/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app.py b/src/app.py index 7fb47af..de693eb 100644 --- a/src/app.py +++ b/src/app.py @@ -1,4 +1,5 @@ import os +import sys import threading from datetime import datetime, timedelta, timezone import json From 21d33250ca74468462f188a4a9a24589310fcc5e Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Thu, 8 May 2025 06:34:02 +0100 Subject: [PATCH 31/36] Fix ./tests/test_main.py:30:20: F821 undefined name 'ensure_drafts_file' ./tests/test_main.py:30:20: F821 undefined name 'ensure_drafts_file' TEST_DRAFTS_FILE = ensure_drafts_file(TEST_DRAFTS_FILE) ^ 1 F821 undefined name 'ensure_drafts_file' --- tests/test_main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index c109250..52973bb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6,6 +6,8 @@ from typer.testing import CliRunner +from src.draft_utils import ensure_drafts_file + # Import the 'app' object from the local module in the 'src' package. from src.app import app From 5c746ffde158c49174f360c9cb315b6cc2c4c48e Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Thu, 8 May 2025 06:55:18 +0100 Subject: [PATCH 32/36] chore(tests): add tests/conftest.py to adjust PYTHONPATH for src module Added tests/conftest.py to prepend the project root to sys.path, ensuring that the src package is discoverable during test runs. --- tests/conftest.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..26ff74e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +import sys +import os + +# Append project root directory to sys.path to import modules from src +sys.path.insert(0, os.path.abspath( + os.path.join(os.path.dirname(__file__), '..') +)) From 5420ead0b9846be92470d0105ed215ffa873ea1c Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Thu, 8 May 2025 07:07:14 +0100 Subject: [PATCH 33/36] chore(tests): remove sys.path modification warning from test_main.py Removed the warning comment regarding changing the import path at runtime, as tests/conftest.py now handles PYTHONPATH adjustments. --- tests/test_main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index 52973bb..bb60b17 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -15,8 +15,6 @@ # This approach is used to allow importing main.py, which is located in the project's root directory, # when such tests rely on running the application entry point. # Note: main.py defines a main() function and runs it if executed directly by the user. -# Modifying sys.path is less ideal because it involves changing the import path at runtime, which can lead to -# maintenance issues or conflicts with the module namespace. # # Uncomment the line below to update sys.path, adding the project root directory. # import sys From 11341b868fbc770f9fc527933cba5c2df6e4f8d0 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Thu, 8 May 2025 07:12:33 +0100 Subject: [PATCH 34/36] chore(ci): add ACCESS_TOKEN env var to pytest workflow step Modified the Test with pytest workflow step to include the ACCESS_TOKEN environment variable from secrets. --- .github/workflows/python-app.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index c19ce10..e9eb30d 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -39,5 +39,7 @@ jobs: # 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 - name: Test with pytest + env: + ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} run: | pytest From dafa05f1e7bac1abe881bd65a740e1b2bff39eec Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Thu, 8 May 2025 13:38:59 +0100 Subject: [PATCH 35/36] Update .env.example Comment default ACCESS_TOKEN to not override environment variables --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 68753a1..75f7f4a 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ # ACCESS_TOKEN is used for authenticating API requests to Meta's Threads app and Threads.net, # Required: No default. -ACCESS_TOKEN="" +#ACCESS_TOKEN="" # BASE_URL specifies the API endpoint for accessing Meta's Threads app. # Optional: Default= "https://graph.threads.net/v1.0" From 9b80a3c250aa9183396d1898713d0542f5c2aab6 Mon Sep 17 00:00:00 2001 From: Muhammad Amin Boubaker Date: Thu, 8 May 2025 12:37:47 +0100 Subject: [PATCH 36/36] Fix(tests): correct patch paths for create_post and create_text_post Resolved AttributeError in test_main.py by updating mock.patch targets to use the correct module path (src.app.* instead of main.*). This ensures that create_post and create_text_post are properly mocked during CLI command tests. * modified: tests/test_main.py Signed-off-by: Muhammad Amin Boubaker --- tests/test_main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index bb60b17..3dafed1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -49,7 +49,7 @@ def test_get_recent_posts(self): self.assertIn("Recent Posts", result.stdout) def test_create_text_post(self): - with patch("main.create_post") as mock_create_post: + with patch("src.app.create_post") as mock_create_post: mock_create_post.return_value = {"id": "123"} result = self.runner.invoke(app, ["create-text-post", "Test post"]) self.assertEqual(result.exit_code, 0) @@ -79,7 +79,7 @@ def test_send_draft(self): draft_id = drafts[0]["id"] # Test sending an existing draft - with patch("main.create_text_post") as mock_create_text_post: + with patch("src.app.create_text_post") as mock_create_text_post: result = self.runner.invoke(app, ["send-draft", str(draft_id), "--drafts-file", TEST_DRAFTS_FILE]) self.assertEqual(result.exit_code, 0) self.assertIn(f"Draft with ID {draft_id} sent and removed from drafts.", result.stdout)