22"""
33reddit_cache_v2.py
44
5- This script uses PRAW (Python Reddit API Wrapper) to fetch and cache the newest 100 posts
5+ This script uses PRAW (Python Reddit API Wrapper) to fetch and cache the newest 1000 posts
66from one or more subreddits, and generates various reports based on the local cache.
77It is a new version that replaces direct HTTP requests with PRAW for more robust,
88authenticated interactions with Reddit's API.
1313 - Generates reports (flair, monthly digest, show posts) from the cached data.
1414 - Checks for unformatted code in post selftexts and prompts the moderator interactively.
1515 - Retrieves the number of posts waiting in the mod queue and the number of unread modmail conversations.
16- - Supports multiple output formats: JSON, human-readable ANSI report, and Markdown report.
16+ - Supports multiple output formats: machine-readable JSON, human-readable ANSI-colored report, and Markdown-formatted report.
1717
1818Configuration:
1919 - PRAW will load credentials from praw.ini or from environment variables:
@@ -62,12 +62,14 @@ def format_help(self) -> str:
6262# -- Configuration and File I/O Helpers --
6363
6464def get_cache_folder (subreddit : str ) -> str :
65+ """Return the cache folder path for a given subreddit under 'caches'."""
6566 safe_subreddit = re .sub (r'[^\w-]' , '_' , subreddit )
6667 folder = os .path .join ("caches" , safe_subreddit )
6768 os .makedirs (folder , exist_ok = True )
6869 return folder
6970
7071def get_config () -> Tuple [configparser .ConfigParser , str ]:
72+ """Load the configuration file (caches/app.ini). Create it if it doesn't exist."""
7173 config = configparser .ConfigParser ()
7274 config_path = os .path .join ("caches" , "app.ini" )
7375 if os .path .exists (config_path ):
@@ -77,10 +79,12 @@ def get_config() -> Tuple[configparser.ConfigParser, str]:
7779 return config , config_path
7880
7981def save_config (config : configparser .ConfigParser , config_path : str ) -> None :
82+ """Save the configuration to the specified config_path."""
8083 with open (config_path , "w" , encoding = "utf-8" ) as configfile :
8184 config .write (configfile )
8285
8386def load_cached_posts (subreddit : str ) -> List [Dict [str , Any ]]:
87+ """Load cached posts from the given subreddit's cache folder."""
8488 folder = get_cache_folder (subreddit )
8589 posts = []
8690 for filename in os .listdir (folder ):
@@ -98,6 +102,7 @@ def load_cached_posts(subreddit: str) -> List[Dict[str, Any]]:
98102# -- PRAW API Wrappers --
99103
100104def submission_to_dict (submission : praw .models .Submission ) -> Dict [str , Any ]:
105+ """Convert a PRAW submission object into a dictionary with selected fields."""
101106 return {
102107 "id" : submission .id ,
103108 "title" : submission .title ,
@@ -108,8 +113,12 @@ def submission_to_dict(submission: praw.models.Submission) -> Dict[str, Any]:
108113 }
109114
110115def fetch_posts (subreddit : str ) -> Optional [List [Dict [str , Any ]]]:
116+ """
117+ Fetch the newest 1000 posts for the given subreddit using PRAW.
118+ Returns a list of post dictionaries or None if an error occurs.
119+ """
111120 try :
112- submissions = reddit .subreddit (subreddit ).new (limit = 100 )
121+ submissions = reddit .subreddit (subreddit ).new (limit = 1000 )
113122 posts = [submission_to_dict (sub ) for sub in submissions ]
114123 if not posts :
115124 logger .error (f"No posts found for r/{ subreddit } ." )
@@ -120,6 +129,10 @@ def fetch_posts(subreddit: str) -> Optional[List[Dict[str, Any]]]:
120129 return None
121130
122131def cache_post (subreddit : str , post_data : Dict [str , Any ]) -> Tuple [Dict [str , Any ], bool ]:
132+ """
133+ Cache a post's data locally under caches/<subreddit>.
134+ Returns the post data and a flag indicating whether it was newly cached.
135+ """
123136 folder = get_cache_folder (subreddit )
124137 post_id = post_data .get ("id" )
125138 if not post_id :
@@ -140,6 +153,7 @@ def cache_post(subreddit: str, post_data: Dict[str, Any]) -> Tuple[Dict[str, Any
140153 return post_data , True
141154
142155def fetch_modqueue_count (subreddit : str ) -> int :
156+ """Return the number of posts currently waiting in the moderator queue for the given subreddit."""
143157 try :
144158 mod_items = list (reddit .subreddit (subreddit ).mod .modqueue (limit = None ))
145159 return len (mod_items )
@@ -148,6 +162,7 @@ def fetch_modqueue_count(subreddit: str) -> int:
148162 return 0
149163
150164def fetch_modmail_count (subreddit : str ) -> int :
165+ """Return the number of unread modmail conversations for the given subreddit."""
151166 try :
152167 conversations = list (reddit .subreddit (subreddit ).modmail .conversations (limit = None ))
153168 unread_count = sum (1 for conv in conversations if conv .state == "new" )
@@ -159,16 +174,18 @@ def fetch_modmail_count(subreddit: str) -> int:
159174# -- Report Generation --
160175
161176def generate_flair_report (subreddit : str , report_limit : Optional [int ] = None ) -> Dict [str , int ]:
177+ """Generate a summary report of unique flair texts from the cached posts."""
162178 posts = load_cached_posts (subreddit )
163179 if report_limit is not None :
164180 posts = posts [:report_limit ]
165- flair_counts = {}
181+ flair_counts : Dict [ str , int ] = {}
166182 for post in posts :
167183 flair = post .get ("link_flair_text" ) or "None"
168184 flair_counts [flair ] = flair_counts .get (flair , 0 ) + 1
169185 return flair_counts
170186
171187def generate_show_report (subreddit : str , n : int ) -> List [Dict [str , Any ]]:
188+ """Generate a report showing selected fields for the last n cached posts."""
172189 posts_list = load_cached_posts (subreddit )
173190 selected = posts_list [:n ]
174191 return [
@@ -183,6 +200,10 @@ def generate_show_report(subreddit: str, n: int) -> List[Dict[str, Any]]:
183200
184201def generate_monthly_digest_report (subreddit : str , digest_pattern : str = "Monthly Digest" ,
185202 limit : Optional [int ] = None ) -> Dict [str , Any ]:
203+ """
204+ Generate a Monthly Digest report section by scanning cached posts whose titles match a pattern.
205+ Returns a dictionary containing the header, narrative, and digest posts, or a message if none are found.
206+ """
186207 posts_list = load_cached_posts (subreddit )
187208 pattern = re .compile (digest_pattern , re .IGNORECASE )
188209 posts_list = [post for post in posts_list if pattern .search (post .get ("title" , "" ))]
@@ -212,6 +233,9 @@ def generate_monthly_digest_report(subreddit: str, digest_pattern: str = "Monthl
212233# -- Code Formatting Helpers --
213234
214235def remove_fenced_code (text : str ) -> str :
236+ """
237+ Remove fenced code blocks (delimited by lines starting with ```) from text.
238+ """
215239 lines = text .splitlines ()
216240 result_lines = []
217241 inside = False
@@ -224,6 +248,9 @@ def remove_fenced_code(text: str) -> str:
224248 return "\n " .join (result_lines )
225249
226250def remove_indented_code (text : str ) -> str :
251+ """
252+ Remove indented code blocks (lines indented by 4+ spaces or a tab) from text.
253+ """
227254 lines = text .splitlines ()
228255 result_lines = []
229256 for line in lines :
@@ -233,52 +260,103 @@ def remove_indented_code(text: str) -> str:
233260 return "\n " .join (result_lines )
234261
235262def remove_inline_code (text : str ) -> str :
263+ """
264+ Remove inline code spans (enclosed in single backticks) from text.
265+ """
236266 return re .sub (r"`[^`]+`" , "" , text )
237267
238268def clean_text (text : str ) -> str :
269+ """
270+ Unescape HTML entities and remove fenced, indented, and inline code blocks from text.
271+ """
239272 unescaped = html .unescape (text )
240273 cleaned = remove_fenced_code (unescaped )
241274 cleaned = remove_indented_code (cleaned )
242275 cleaned = remove_inline_code (cleaned )
243276 return cleaned
244277
278+ # List of inline code patterns (not anchored to the start) for detecting code anywhere in a line.
279+ inline_code_patterns = [
280+ re .compile (r'(?:#\s*)?include\s*<[^>]+>' , re .IGNORECASE ),
281+ re .compile (r'\bvoid\s+\w+\s*\([^)]*\)\s*{' , re .IGNORECASE ),
282+ re .compile (r'\bfor\s*\([^)]*\)' , re .IGNORECASE ),
283+ re .compile (r'\bwhile\s*\([^)]*\)' , re .IGNORECASE ),
284+ re .compile (r'\bif\s*\([^)]*\)' , re .IGNORECASE ),
285+ re .compile (r'\bSerial\.println\s*\(' , re .IGNORECASE ),
286+ re .compile (r'\bpinMode\s*\(' , re .IGNORECASE ),
287+ re .compile (r'\bdigitalWrite\s*\(' , re .IGNORECASE ),
288+ re .compile (r'\banalogRead\s*\(' , re .IGNORECASE ),
289+ re .compile (r'\banalogWrite\s*\(' , re .IGNORECASE ),
290+ re .compile (r'printf\s*\(' , re .IGNORECASE )
291+ ]
292+
293+ def count_inline_code_patterns (line : str ) -> int :
294+ """
295+ Count the number of occurrences of code-like patterns in a line.
296+ """
297+ count = 0
298+ for pattern in inline_code_patterns :
299+ count += len (pattern .findall (line ))
300+ return count
301+
245302def is_code_line (line : str ) -> bool :
246- code_patterns = [
247- re .compile (r'^\s*(?:#\s*)?include\s*<[^>]+>' , re .IGNORECASE ),
248- re .compile (r'^\s*\bvoid\s+\w+\s*\([^)]*\)\s*{' , re .IGNORECASE ),
249- re .compile (r'^\s*\bfor\s*\([^)]*\)' , re .IGNORECASE ),
250- re .compile (r'^\s*\bwhile\s*\([^)]*\)' , re .IGNORECASE ),
251- re .compile (r'^\s*\bif\s*\([^)]*\)' , re .IGNORECASE ),
252- re .compile (r'^\s*\bSerial\.println\s*\(' , re .IGNORECASE ),
253- re .compile (r'^\s*\bpinMode\s*\(' , re .IGNORECASE ),
254- re .compile (r'^\s*\bdigitalWrite\s*\(' , re .IGNORECASE ),
255- re .compile (r'^\s*\banalogRead\s*\(' , re .IGNORECASE ),
256- re .compile (r'^\s*\banalogWrite\s*\(' , re .IGNORECASE ),
257- re .compile (r'^\s*printf\s*\(' , re .IGNORECASE )
258- ]
259- for pattern in code_patterns :
303+ """
304+ Check if a line likely contains Arduino/C/C++ code using common patterns.
305+ """
306+ for pattern in inline_code_patterns :
260307 if pattern .search (line ):
261308 return True
262309 return False
263310
264- def has_unformatted_code (text : str ) -> bool :
311+ def has_unformatted_code (text : str , inline_threshold : int = 3 , multiline_threshold : int = 3 ) -> bool :
312+ """
313+ Determine if text contains unformatted code.
314+
315+ This function now checks in two ways:
316+ 1. **Inline Check:**
317+ If any single non-empty line contains at least `inline_threshold` matches of code-like patterns,
318+ it is flagged as unformatted.
319+ 2. **Multiline Check:**
320+ If there are at least `multiline_threshold` consecutive lines that look like code, it is flagged.
321+
322+ **Additional Heuristic:**
323+ If the total number of non-empty lines exceeds 50 and less than 30% of them look like code, then
324+ the text is presumed to be well-formatted (even if some lines match) and will not be flagged.
325+
326+ The thresholds can be adjusted via parameters.
327+ """
265328 cleaned = clean_text (text )
266- lines = cleaned .splitlines ()
267- code_run = 0
329+ lines = [line for line in cleaned .splitlines () if line .strip () != "" ]
330+
331+ # Heuristic: For long posts, if only a small fraction of lines appear as code, assume it is well formatted.
332+ total_lines = len (lines )
333+ if total_lines > 50 :
334+ code_lines = sum (1 for line in lines if is_code_line (line ))
335+ if (code_lines / total_lines ) < 0.3 :
336+ return False
337+
338+ # Inline check: Each non-empty line is checked for inline code pattern occurrences.
339+ for line in lines :
340+ if count_inline_code_patterns (line ) >= inline_threshold :
341+ return True
342+
343+ # Multiline check: Check for consecutive lines that look like code.
344+ consecutive = 0
268345 for line in lines :
269- if line .strip () == "" :
270- continue
271346 if is_code_line (line ):
272- code_run += 1
273- if code_run >= 3 :
347+ consecutive += 1
348+ if consecutive >= multiline_threshold :
274349 return True
275350 else :
276- code_run = 0
351+ consecutive = 0
277352 return False
278353
279354# -- Output Functions --
280355
281356def print_markdown (final_output : Dict [str , Any ], filters_applied : Dict [str , Any ]) -> None :
357+ """
358+ Print a Markdown-formatted report of the final output.
359+ """
282360 md_lines = []
283361 md_lines .append ("# Monthly Digest Report\n " )
284362 for subreddit , result in final_output ["results" ].items ():
@@ -344,11 +422,14 @@ def print_markdown(final_output: Dict[str, Any], filters_applied: Dict[str, Any]
344422 md_lines .append (f"- **Total network retrievals (over time):** { gs .get ('global_network_retrievals' , 0 )} " )
345423 md_lines .append (f"- **Total cached posts (global):** { gs .get ('global_cached_posts' , 0 )} \n " )
346424 md_lines .append ("## Filters and options applied" )
347- for key , value in filters_applied .items ():
425+ for key , value in final_output . get ( " filters_applied" , {}) .items ():
348426 md_lines .append (f"- **{ key } :** { value } " )
349427 print ("\n " .join (md_lines ))
350428
351429def print_human_readable (final_output : Dict [str , Any ], filters_applied : Dict [str , Any ]) -> None :
430+ """
431+ Print a human-readable, colorful, ANSI report of the final output.
432+ """
352433 print (f"{ Fore .GREEN } === Human Readable Report ==={ Style .RESET_ALL } " )
353434 for subreddit , result in final_output ["results" ].items ():
354435 print (f"{ Fore .BLUE } Subreddit: { subreddit } { Style .RESET_ALL } " )
@@ -413,10 +494,29 @@ def print_human_readable(final_output: Dict[str, Any], filters_applied: Dict[str
413494 print (f" { Fore .LIGHTGREEN_EX } Total network retrievals (over time): { gs .get ('global_network_retrievals' , 0 )} { Style .RESET_ALL } " )
414495 print (f" { Fore .LIGHTGREEN_EX } Total cached posts (global): { gs .get ('global_cached_posts' , 0 )} { Style .RESET_ALL } " )
415496 print (f"\n { Fore .YELLOW } Filters applied:{ Style .RESET_ALL } " )
416- for key , value in filters_applied .items ():
497+ for key , value in final_output . get ( " filters_applied" , {}) .items ():
417498 print (f" { key } : { value } " )
418499
500+ # -- Code Formatting Check --
501+
419502def check_code_format_violations (subreddit : str , limit : Optional [int ] = None ) -> List [Dict [str , Any ]]:
503+ """
504+ Interactively scan cached posts for code formatting violations.
505+
506+ For each post, any code outside properly formatted blocks is inspected.
507+ A violation is flagged if either:
508+ - A single non-empty line contains at least `inline_threshold` occurrences of code-like patterns, or
509+ - There are at least `multiline_threshold` consecutive lines that look like code.
510+
511+ An additional heuristic prevents flagging if the post is very long (more than 50 non-empty lines)
512+ and less than 30% of lines appear as code, assuming the code is well formatted.
513+
514+ In non-interactive mode (if the environment variable TEST_NONINTERACTIVE is set),
515+ the response is automatically "y" (flagging the post).
516+ """
517+ inline_threshold = 3
518+ multiline_threshold = 3
519+
420520 config , config_path = get_config ()
421521 no_violation_ids = config ["CodeFormat" ] if "CodeFormat" in config else {}
422522 violations : List [Dict [str , Any ]] = []
@@ -428,7 +528,7 @@ def check_code_format_violations(subreddit: str, limit: Optional[int] = None) ->
428528 if post_id in no_violation_ids :
429529 continue
430530 selftext = post .get ("selftext" , "" )
431- if has_unformatted_code (selftext ):
531+ if has_unformatted_code (selftext , inline_threshold , multiline_threshold ):
432532 print (f"\n { Fore .CYAN } Potential Code Format Violation Detected:{ Style .RESET_ALL } " )
433533 print (f"Post ID: { post_id } " )
434534 print (f"Title: { post .get ('title' , '' )} " )
@@ -440,14 +540,13 @@ def check_code_format_violations(subreddit: str, limit: Optional[int] = None) ->
440540 print ("[DEBUG] TEST_NONINTERACTIVE is set; automatically flagging this post." )
441541 else :
442542 response = input ("Does this post contain unformatted code? (y/n/s/c): " ).strip ().lower ()
443-
444543 if response == "y" :
445544 violations .append ({
446545 "id" : post_id ,
447546 "title" : post .get ("title" , "" ),
448547 "violation" : "Post contains unformatted source code. Please format your code in proper code blocks."
449- })
450- no_violation_ids [post_id ] = "flagged" # Record that this post was flagged.
548+ })
549+ no_violation_ids [post_id ] = "flagged"
451550 elif response == "n" :
452551 no_violation_ids [post_id ] = "n"
453552 elif response == "s" :
@@ -459,9 +558,11 @@ def check_code_format_violations(subreddit: str, limit: Optional[int] = None) ->
459558 save_config (config , config_path )
460559 return violations
461560
561+ # -- Main Program --
562+
462563def main () -> None :
463564 help_description = (
464- f"{ Fore .CYAN } Fetch and cache the newest 100 posts from one or more subreddits using PRAW, displaying only new posts and summary stats.\n "
565+ f"{ Fore .CYAN } Fetch and cache the newest 1000 posts from one or more subreddits using PRAW, displaying only new posts and summary stats.\n "
465566 "If multiple subreddits are specified, a global summary is also provided.\n \n "
466567 "Positional arguments:\n "
467568 f" { Fore .MAGENTA } subreddits{ Style .RESET_ALL } : One or more subreddit names to fetch posts from (default: arduino).\n \n "
0 commit comments