redditadmin 0.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ from .core import RedditAdmin, get_reddit_admin
2
+ from .program import *
3
+ from .plugin import *
4
+ from .utility import *
redditadmin/core.py ADDED
@@ -0,0 +1,440 @@
1
+ import logging
2
+ import os
3
+ import signal
4
+ import sys
5
+ import time
6
+ from abc import ABC, abstractmethod
7
+ from logging.handlers import TimedRotatingFileHandler
8
+ from pathlib import Path
9
+ from typing import List
10
+
11
+ from .utility.botcredentials import BotCredentials
12
+ from .utility.exceptions import BotInitializationError, InvalidBotCredentialsError
13
+ from .plugin.asynchronouspluginsexecutor import AsynchronousPluginsExecutor
14
+ from .plugin.exceptions import PluginsExecutorInitializationError
15
+ from .plugin.plugin import Plugin
16
+ from .plugin.pluginsexecutor import PluginsExecutor
17
+ from .plugin.redditinterfacefactory import RedditInterfaceFactory
18
+
19
+
20
+ class RedditAdmin(ABC):
21
+ """Type encapsulating a RedditAdmin instance"""
22
+
23
+ def __init__(self, *args):
24
+ pass
25
+
26
+ @abstractmethod
27
+ def run(self, bot_credentials: BotCredentials, listen: bool = False):
28
+ """Run the bot"""
29
+
30
+ raise NotImplementedError
31
+
32
+ @abstractmethod
33
+ def stop(self):
34
+ """Shutdown the bot"""
35
+
36
+ raise NotImplementedError
37
+
38
+
39
+ class RedditAdminImplementation(RedditAdmin):
40
+ """Reddit Admin Bot"""
41
+
42
+ __plugins: List[Plugin]
43
+ __pluginsExecutor: PluginsExecutor
44
+ __mainLogger: logging.Logger
45
+ __defaultConsoleLoggingLevel: int
46
+
47
+ __RESOURCES_PATH = os.path.join(
48
+ os.path.dirname(os.path.abspath(__file__)),
49
+ 'resources'
50
+ )
51
+
52
+ # Bot initialization commands
53
+ # -------------------------------------------------------------------------------
54
+ # -------------------------------------------------------------------------------
55
+
56
+ def __init__(self, plugins: List[Plugin]):
57
+ super().__init__(self)
58
+ self.__plugins = plugins
59
+
60
+ def __initialize_logging(self, log_file_name: str):
61
+ """Initialize the bot's logging apparatus"""
62
+
63
+ # Disabling any 3rd party loggers
64
+ for _ in logging.root.manager.loggerDict:
65
+ logging.getLogger(_).setLevel(logging.CRITICAL)
66
+
67
+ # Initializing the root logger
68
+ logging.basicConfig(level=logging.DEBUG)
69
+ root_logger = logging.getLogger()
70
+
71
+ # Initializing the core bot application logger
72
+ self.__mainLogger = logging.getLogger(__name__)
73
+
74
+ # Clearing any existing log handlers for program loggers
75
+ for logger in [root_logger, self.__mainLogger]:
76
+ if len(logger.handlers):
77
+ logger.handlers.clear()
78
+
79
+ # Setting up log handlers
80
+ log_file_handler = TimedRotatingFileHandler(
81
+ filename=log_file_name,
82
+ when='D',
83
+ utc=True
84
+ )
85
+ console_handler = logging.StreamHandler()
86
+ log_file_handler.set_name('log_file')
87
+ console_handler.set_name('console')
88
+ log_file_handler.setFormatter(
89
+ logging.Formatter(
90
+ '[%(asctime)s] %(name)-16s : '
91
+ '%(levelname)-8s - %(message)s'
92
+ )
93
+ )
94
+ console_handler.setFormatter(
95
+ logging.Formatter(
96
+ '%(name)-16s : %(message)s'
97
+ )
98
+ )
99
+ log_file_handler.setLevel(logging.DEBUG)
100
+ console_handler.setLevel(logging.DEBUG)
101
+
102
+ # Adding the handlers to the root logger
103
+ root_logger.addHandler(log_file_handler)
104
+ root_logger.addHandler(console_handler)
105
+
106
+ # Setting the default console logging level global variable
107
+ self.__defaultConsoleLoggingLevel = console_handler.level
108
+
109
+ def ___get_new_bot_credentials(self) -> BotCredentials:
110
+ """Convenience method to retrieve bot credentials from user input"""
111
+
112
+ try:
113
+ # Prompt for new valid credentials
114
+ while True:
115
+
116
+ # Pause console logging while listening for input
117
+ self.__pause_console_logging()
118
+
119
+ user_agent = input("Enter User Agent: ")
120
+ client_id = input("Enter Client ID: ")
121
+ client_secret = input("Enter Client Secret: ")
122
+ username = input("Enter Username: ")
123
+ password = input("Enter Password: ")
124
+
125
+ # Resume console logging
126
+ self.__resume_console_logging()
127
+
128
+ return BotCredentials(
129
+ user_agent, client_id,
130
+ client_secret, username,
131
+ password
132
+ )
133
+
134
+ # Handle if listening interrupted
135
+ except (KeyboardInterrupt, EOFError) as ex:
136
+ self.__resume_console_logging()
137
+ raise ex
138
+
139
+ def __get_reddit_interface_factory(self, bot_credentials: BotCredentials) \
140
+ -> RedditInterfaceFactory:
141
+ """ Initialize Reddit Interface Factory"""
142
+
143
+ # Attempting to retrieve a valid RedditInterfaceFactory
144
+ # instance from provided credentials
145
+
146
+ try:
147
+ reddit_interface_factory = RedditInterfaceFactory(
148
+ bot_credentials
149
+ )
150
+ # Handle if credential authentication fails
151
+ except InvalidBotCredentialsError:
152
+ self.__mainLogger.error(
153
+ "The provided credentials are invalid. "
154
+ "Please enter new valid credentials"
155
+ )
156
+ try:
157
+ new_bot_credentials = self.___get_new_bot_credentials()
158
+ reddit_interface_factory = self.__get_reddit_interface_factory(new_bot_credentials)
159
+ except (KeyboardInterrupt, EOFError):
160
+ raise BotInitializationError(
161
+ "Retrieval of bot credentials from user input "
162
+ "aborted"
163
+ )
164
+
165
+ return reddit_interface_factory
166
+
167
+ def __initialize_plugins_executor(self, bot_credentials: BotCredentials) \
168
+ -> PluginsExecutor:
169
+ """Initialize the Plugins Executor"""
170
+
171
+ # Initializing the Plugins Executor
172
+
173
+ reddit_interface_factory = self.__get_reddit_interface_factory(bot_credentials)
174
+
175
+ try:
176
+ plugins_executor = AsynchronousPluginsExecutor(
177
+ plugins=self.__plugins,
178
+ reddit_interface_factory=reddit_interface_factory
179
+ )
180
+
181
+ # Handle if there is an error initializing the Programs Executor
182
+ except PluginsExecutorInitializationError as ex:
183
+ raise BotInitializationError(
184
+ "An error occurred while initializing "
185
+ "the Programs Executor.", ex
186
+ )
187
+
188
+ return plugins_executor
189
+
190
+ def __initialize_bot(self, bot_credentials: BotCredentials):
191
+ """Initialize the bot"""
192
+
193
+ log_file = Path(os.path.join(
194
+ self.__RESOURCES_PATH, 'logs', 'reddit-admin.log'
195
+ ))
196
+ log_file.parent.mkdir(exist_ok=True, parents=True)
197
+
198
+ # Setting up logging apparatus
199
+ self.__initialize_logging(str(log_file.resolve()))
200
+
201
+ self.__mainLogger.info("Initializing the bot")
202
+
203
+ try:
204
+
205
+ # Initializing the Programs Executor
206
+ self.__pluginsExecutor = self.__initialize_plugins_executor(
207
+ bot_credentials
208
+ )
209
+
210
+ # -------------------------------------------------------------------------------
211
+
212
+ # Handle if an initialization error occurs
213
+ except BotInitializationError as er:
214
+ self.__mainLogger.critical(
215
+ "A fatal error occurred during the "
216
+ "bot's initialization. The application "
217
+ "will now exit. Error(s): " + str(er),
218
+ exc_info=True
219
+ )
220
+ sys.exit(2) # TODO: May need future cleaning up
221
+
222
+ self.__mainLogger.info("Bot successfully initialized")
223
+
224
+ # -------------------------------------------------------------------------------
225
+
226
+ # Bot runtime commands
227
+ # -------------------------------------------------------------------------------
228
+ # -------------------------------------------------------------------------------
229
+
230
+ def __pause_console_logging(self):
231
+ """Pause console logging across entire application"""
232
+
233
+ for handler in logging.getLogger().handlers:
234
+ if handler.name == "console":
235
+ handler.setLevel(logging.CRITICAL)
236
+ return
237
+ self.__mainLogger.warning(
238
+ "Failed to pause logging because "
239
+ "the console logger was not found"
240
+ )
241
+
242
+ def __resume_console_logging(self):
243
+ """Resume console logging across entire application"""
244
+
245
+ for handler in logging.getLogger().handlers:
246
+ if handler.name == "console":
247
+ handler.setLevel(self.__defaultConsoleLoggingLevel)
248
+ return
249
+ self.__mainLogger.warning(
250
+ "Failed to resume logging because "
251
+ "the console logger was not found"
252
+ )
253
+
254
+ def __start_command_listener(self):
255
+ """Start the bot command listener"""
256
+
257
+ try:
258
+ while not self.__is_bot_shut_down():
259
+ # Pause console logging while bot is
260
+ # listening for commands
261
+ self.__pause_console_logging()
262
+
263
+ command = input('Enter bot command: ')
264
+
265
+ # Resume console logging once command
266
+ # entered
267
+ self.__resume_console_logging()
268
+
269
+ self.__process_bot_command(command)
270
+
271
+ except BaseException as ex:
272
+ self.__resume_console_logging()
273
+ raise ex
274
+
275
+ def __process_bot_command(self, command: str):
276
+ """Process a bot command"""
277
+
278
+ # For blank command
279
+ if command == '' or command == '\n':
280
+ return
281
+
282
+ # For program command
283
+ elif command.startswith('run '):
284
+ self.__pluginsExecutor.execute_program(command.split('run ', 1)[1])
285
+
286
+ # For program status request
287
+ elif command == 'status':
288
+
289
+ print('\nPrograms status:')
290
+
291
+ # Printing all program statuses
292
+ for _program, status in self.__pluginsExecutor \
293
+ .get_program_statuses() \
294
+ .items():
295
+ print('{}\t\t: {}'.format(
296
+ _program, status
297
+ ))
298
+ print()
299
+
300
+ # For shutdown command
301
+ elif (
302
+ command == 'shutdown' or
303
+ command == 'quit' or
304
+ command == 'exit'
305
+ ):
306
+ self.__shut_down_bot()
307
+
308
+ else:
309
+ self.__mainLogger.debug(
310
+ "'{}' is not a valid bot command".format(command)
311
+ )
312
+
313
+ @staticmethod
314
+ def __kill_bot():
315
+ """Forcefully shut down the bot"""
316
+
317
+ # Windows kill command
318
+ if (
319
+ sys.platform.startswith('win32') or
320
+ sys.platform.startswith('cygwin')
321
+ ):
322
+ os.kill(os.getpid(), signal.CTRL_BREAK_EVENT)
323
+
324
+ # Linux kill command
325
+ os.kill(os.getpid(), signal.SIGKILL)
326
+
327
+ def __shut_down_bot(self, wait=True, shutdown_exit_code=0):
328
+ """Shut down the bot"""
329
+
330
+ if wait:
331
+ self.__mainLogger.info(
332
+ 'Shutting down the bot. Please wait a bit while the '
333
+ 'remaining tasks ({}) are being finished off'.format(
334
+ ", ".join(
335
+ {
336
+ _program: status
337
+ for (_program, status) in self.__pluginsExecutor
338
+ .get_program_statuses()
339
+ .items()
340
+ if status != "DONE"
341
+ }.keys()
342
+ )
343
+ )
344
+ )
345
+ try:
346
+ self.__pluginsExecutor.shut_down(True)
347
+ self.__mainLogger.info('Bot successfully shut down')
348
+ if shutdown_exit_code != 0:
349
+ sys.exit(shutdown_exit_code)
350
+
351
+ # Handle keyboard interrupt midway through graceful shutdown
352
+ except KeyboardInterrupt:
353
+
354
+ self.__mainLogger.warning(
355
+ 'Graceful shutdown aborted.'
356
+ )
357
+ self.__pluginsExecutor.shut_down(False)
358
+ self.__mainLogger.info('Bot shut down')
359
+
360
+ # Killing the process (only way to essentially stop all threads)
361
+ self.__kill_bot()
362
+
363
+ else:
364
+ self.__pluginsExecutor.shut_down(False)
365
+ self.__mainLogger.info('Bot shut down')
366
+
367
+ self.__kill_bot()
368
+
369
+ def __is_bot_shut_down(self):
370
+ """Check if bot is shutdown"""
371
+
372
+ return self.__pluginsExecutor and self.__pluginsExecutor.is_shut_down()
373
+
374
+ def __start_bot(self, bot_credentials: BotCredentials, listen: bool):
375
+ """Start up the bot"""
376
+
377
+ # Initializing the bot
378
+ self.__initialize_bot(bot_credentials)
379
+ self.__mainLogger.info('The bot is now running')
380
+
381
+ try:
382
+ if listen:
383
+ self.__start_command_listener()
384
+
385
+ # Handle forced shutdown request
386
+ except (KeyboardInterrupt, EOFError):
387
+ self.__mainLogger.warning(
388
+ 'Forced bot shutdown requested. Please wait a bit wait while '
389
+ 'a graceful shutdown is attempted or press '
390
+ 'Ctrl+C to exit immediately'
391
+ )
392
+ self.__shut_down_bot(True, 1)
393
+
394
+ # Handle unknown exception while bot is running
395
+ except BaseException as ex:
396
+ self.__mainLogger.critical(
397
+ "A fatal error just occurred while the bot was "
398
+ "running. Please wait a bit wait while "
399
+ "a graceful shutdown is attempted or press "
400
+ "Ctrl+C to exit immediately: " + str(ex.args), exc_info=True
401
+ )
402
+ self.__shut_down_bot(True, 2)
403
+
404
+ def run(self, bot_credentials: BotCredentials, listen: bool = False):
405
+
406
+ # Setting up interrupt signal handlers
407
+ signal.signal(signal.SIGINT, signal.default_int_handler)
408
+ signal.signal(signal.SIGTERM, signal.default_int_handler)
409
+
410
+ # Start bot
411
+ self.__start_bot(bot_credentials, listen)
412
+
413
+ try:
414
+ # Wait for tasks to complete before shutdown
415
+ while True:
416
+ if not (
417
+ "RUNNING" in self.__pluginsExecutor
418
+ .get_program_statuses().values()
419
+ ):
420
+ break
421
+ time.sleep(1)
422
+ # Handle shutdown by Keyboard interrupt
423
+ except KeyboardInterrupt:
424
+ pass
425
+ finally:
426
+ # Shut bot down if not already
427
+ if not self.__is_bot_shut_down():
428
+ self.__shut_down_bot()
429
+
430
+ def stop(self):
431
+
432
+ self.__shut_down_bot()
433
+
434
+ # -------------------------------------------------------------------------------
435
+
436
+
437
+ def get_reddit_admin(plugins: List[Plugin]) -> RedditAdmin:
438
+ """Get a Reddit Admin instance"""
439
+
440
+ return RedditAdminImplementation(plugins=plugins)
@@ -0,0 +1,3 @@
1
+ from .plugin import Plugin
2
+ from .pluginsexecutor import PluginsExecutor
3
+ from .redditinterfacefactory import RedditInterfaceFactory
@@ -0,0 +1,203 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ import concurrent.futures
4
+ from concurrent.futures import ThreadPoolExecutor, Future
5
+ from typing import Dict, List
6
+ from .pluginsexecutor import PluginsExecutor
7
+ from .redditinterfacefactory import RedditInterfaceFactory
8
+ from .plugin import Plugin
9
+ from .exceptions import PluginsExecutorInitializationError
10
+
11
+
12
+ class AsynchronousPluginsExecutor(PluginsExecutor):
13
+ """
14
+ Class responsible for asynchronously executing
15
+ multiple plugins in different threads
16
+ """
17
+
18
+ __executor: ThreadPoolExecutor
19
+ __plugins: Dict[str, Plugin]
20
+ __executedPrograms: Dict[str, Future]
21
+ __redditInterfaceFactory: RedditInterfaceFactory
22
+
23
+ def __init__(
24
+ self,
25
+ plugins: List[Plugin],
26
+ reddit_interface_factory: RedditInterfaceFactory,
27
+ executor=ThreadPoolExecutor(),
28
+ ):
29
+ super().__init__("Asynchronous Plugins Executor")
30
+ self.__executor = executor
31
+ self.__plugins = dict(
32
+ map(
33
+ lambda plugin: (plugin.get_program_command(), plugin), plugins
34
+ )
35
+ )
36
+ self.__executedPrograms = {}
37
+ self.__redditInterfaceFactory = reddit_interface_factory
38
+ self.__initialize_plugins_executor()
39
+
40
+ def __initialize_plugins_executor(self):
41
+ """Initialize the plugin executor"""
42
+
43
+ self._pluginsExecutorLogger.debug('Initializing Plugins Executor')
44
+
45
+ try:
46
+ # Retrieving initial program commands
47
+ self._pluginsExecutorLogger.debug(
48
+ "Retrieving initial program commands"
49
+ )
50
+
51
+ # Executing initial program commands
52
+ self._pluginsExecutorLogger.debug(
53
+ "Executing initial program commands"
54
+ )
55
+ self.execute_programs()
56
+
57
+ # Handle in case the program executor fails to initialize
58
+ except PluginsExecutorInitializationError as ex:
59
+ self._pluginsExecutorLogger.critical(
60
+ "A terminal error occurred while initializing the Programs "
61
+ "Executor. Error(s): " + str(ex)
62
+ )
63
+ raise ex
64
+
65
+ self._isPluginsExecutorShutDown = False
66
+ self._pluginsExecutorLogger.info(
67
+ "Programs Executor initialized"
68
+ )
69
+
70
+ def execute_program(self, program_command):
71
+
72
+ # Confirm if shut down first
73
+ if self._inform_if_shut_down():
74
+ return
75
+
76
+ # Checking if there are duplicate running program
77
+ if program_command in self.__executedPrograms.keys():
78
+ if not self.__executedPrograms[program_command].done():
79
+ self._pluginsExecutorLogger.warning(
80
+ "Did not run the '{}' program command "
81
+ "because an identical command is"
82
+ " still running".format(program_command)
83
+ )
84
+ return
85
+
86
+ # Generating an asynchronous worker thread for the program
87
+ try:
88
+ task = self.__executor.submit(
89
+ self.__process_program,
90
+ program_command
91
+ )
92
+ except RuntimeError:
93
+ self._pluginsExecutorLogger.error(
94
+ "Failed to execute '{}' because the executor is "
95
+ "shutting down or is shut down".format(program_command)
96
+ )
97
+ return
98
+
99
+ try:
100
+
101
+ raise task.exception(0.1)
102
+
103
+ # Add to running program if task was started successfully
104
+ except concurrent.futures.TimeoutError:
105
+
106
+ self.__executedPrograms[program_command] = task
107
+
108
+ # Handle if provided program could not be parsed
109
+ except ValueError as ex:
110
+ self._pluginsExecutorLogger.error(
111
+ "Did not run the '{}' program command "
112
+ "because there was an error parsing the "
113
+ "program command. Error(s): {}".format(
114
+ program_command, str(ex.args)
115
+ )
116
+ )
117
+
118
+ # Handle if plugin task failed to run
119
+ except TypeError:
120
+ self._pluginsExecutorLogger.error(
121
+ "Failed to run the plugin '{}'".format(
122
+ program_command
123
+ )
124
+ )
125
+
126
+ def execute_programs(self):
127
+ """Execute multiple program"""
128
+
129
+ # Confirm if shut down first
130
+ if self._inform_if_shut_down():
131
+ return
132
+
133
+ for program_command in self.__plugins.keys():
134
+ self.execute_program(program_command)
135
+
136
+ def __process_program(self, program_command):
137
+ """Synthesize the provided program"""
138
+
139
+ program_command_breakdown = program_command.split()
140
+ program_name = program_command_breakdown[0]
141
+
142
+ try:
143
+
144
+ if program_name in self.__plugins.keys():
145
+ reddit_interface = self.__redditInterfaceFactory.get_reddit_interface()
146
+ self._pluginsExecutorLogger.info(
147
+ "Running program '{}'".format(program_name)
148
+ )
149
+ self.__plugins[program_name].get_program(reddit_interface).execute()
150
+
151
+ # Completion message determination
152
+ if self.is_shut_down():
153
+ self._pluginsExecutorLogger.info(
154
+ "{} program instance successfully shut down".format(
155
+ program_name
156
+ )
157
+ )
158
+ else:
159
+ self._pluginsExecutorLogger.info(
160
+ "{} program instance completed".format(
161
+ program_name
162
+ )
163
+ )
164
+
165
+ # Raise error if provided program does not exist
166
+ else:
167
+ raise ValueError(
168
+ "Program '{}' is not recognized".format(program_name)
169
+ )
170
+
171
+ # Handle if provided program not found
172
+ except ValueError as ex:
173
+ raise ex
174
+
175
+ # Handle if unexpected exception crashes a program TODO: Revisit
176
+ except Exception as ex:
177
+ self._pluginsExecutorLogger.error(
178
+ "An unexpected error just caused the '{}' "
179
+ "program to crash. Error: {}".format(
180
+ program_name, str(ex.args)
181
+ ), exc_info=True
182
+ )
183
+
184
+ def get_program_statuses(self):
185
+ """Get the executed program statuses"""
186
+
187
+ program_statuses = \
188
+ {
189
+ program: ("RUNNING" if not task.done() else "DONE")
190
+ for (program, task) in self.__executedPrograms.items()
191
+ }
192
+ return program_statuses
193
+
194
+ def shut_down(self, wait):
195
+ """Shut down the plugin executor"""
196
+
197
+ super().shut_down()
198
+ for plugin in self.__plugins.values():
199
+ plugin.shut_down()
200
+ self.__executor.shutdown(wait)
201
+ self._pluginsExecutorLogger.info(
202
+ "Programs executor successfully shut down"
203
+ )
@@ -0,0 +1,11 @@
1
+ from ..utility.exceptions import InitializationError
2
+
3
+
4
+ class PluginsExecutorInitializationError(InitializationError):
5
+ """
6
+ Class to encapsulate an error in the initialization
7
+ of a Plugins Executor module
8
+ """
9
+
10
+ def __init__(self, *args):
11
+ super().__init__(*args)
@@ -0,0 +1,62 @@
1
+ import logging
2
+ from abc import ABC, abstractmethod
3
+ from typing import TypeVar, Generic
4
+
5
+ from ..program.program import Program
6
+ from ..utility.redditinterface import RedditInterface
7
+ from ..utility.exceptions import InitializationError
8
+
9
+ T = TypeVar("T", bound=Program)
10
+
11
+
12
+ class Plugin(Generic[T], ABC):
13
+ """
14
+ Class responsible for generating multiple
15
+ instances of a specific program
16
+ """
17
+
18
+ _programCommand: str
19
+ _pluginLogger: logging.Logger
20
+ _isPluginShutDown: bool
21
+
22
+ def __init__(
23
+ self,
24
+ program_command: str,
25
+ ):
26
+ self._programCommand = program_command
27
+ self._pluginLogger = logging.getLogger(
28
+ program_command
29
+ )
30
+ self._isPluginShutDown = False
31
+
32
+ @abstractmethod
33
+ def get_program(self, reddit_interface: RedditInterface) -> T:
34
+ """Get new program instance"""
35
+
36
+ raise NotImplementedError
37
+
38
+ def get_program_command(self) -> str:
39
+ """Get the program command string"""
40
+ return self._programCommand
41
+
42
+ def is_shut_down(self) -> bool:
43
+ """Check if plugin is shut down"""
44
+ return self._isPluginShutDown
45
+
46
+ def shut_down(self):
47
+ """Shut down the plugin"""
48
+ self._isPluginShutDown = True
49
+
50
+ def __eq__(self, value) -> bool:
51
+ return isinstance(value, Plugin) and \
52
+ self.get_program_command() == value.get_program_command()
53
+
54
+
55
+ class PluginInitializationError(InitializationError):
56
+ """
57
+ Class to encapsulate an error in the initialization
58
+ of a plugin module
59
+ """
60
+
61
+ def __init__(self, *args):
62
+ super().__init__(*args)
@@ -0,0 +1,54 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ import logging
4
+ from abc import ABC, abstractmethod
5
+ from typing import Dict
6
+
7
+
8
+ class PluginsExecutor(ABC):
9
+ """
10
+ Class responsible for executing plugins
11
+ """
12
+
13
+ _isPluginsExecutorShutDown: bool
14
+ _pluginsExecutorLogger: logging.Logger
15
+
16
+ def __init__(self, plugins_executor_name: str):
17
+ self._pluginsExecutorLogger = logging.getLogger(
18
+ plugins_executor_name
19
+ )
20
+ self._isPluginsExecutorShutDown = False
21
+
22
+ @abstractmethod
23
+ def execute_program(self, program_command):
24
+ """Execute the provided program command"""
25
+
26
+ raise NotImplementedError
27
+
28
+ @abstractmethod
29
+ def get_program_statuses(self) -> Dict[str, str]:
30
+ """Get the executed program statuses"""
31
+
32
+ raise NotImplementedError
33
+
34
+ def shut_down(self, *args):
35
+ """Shut down the plugins executor"""
36
+
37
+ self._isPluginsExecutorShutDown = True
38
+
39
+ def is_shut_down(self) -> bool:
40
+ """Check if the Plugins Executor is shut down"""
41
+
42
+ return self._isPluginsExecutorShutDown
43
+
44
+ def _inform_if_shut_down(self):
45
+ """
46
+ Convenience method to check shutdown status and log
47
+ if plugins executor is shut down
48
+ """
49
+
50
+ if self._isPluginsExecutorShutDown:
51
+ self._pluginsExecutorLogger.warning(
52
+ "The plugins executor cannot execute any more program "
53
+ "after it has been shut down"
54
+ )
@@ -0,0 +1,49 @@
1
+ import praw
2
+
3
+ from ..utility.botcredentials import BotCredentials
4
+ from ..utility.exceptions import InvalidBotCredentialsError
5
+ from ..utility.redditinterface import RedditInterface
6
+
7
+
8
+ class RedditInterfaceFactory:
9
+ """Factory for RedditInterface objects"""
10
+
11
+ __botCredentials: BotCredentials
12
+
13
+ def __init__(
14
+ self,
15
+ bot_credentials: BotCredentials
16
+ ):
17
+ praw_reddit = praw.Reddit(
18
+ user_agent=bot_credentials.get_user_agent,
19
+ client_id=bot_credentials.get_client_id,
20
+ client_secret=bot_credentials.get_client_secret,
21
+ username=bot_credentials.getusername,
22
+ password=bot_credentials.get_password
23
+ )
24
+ if not self.__authenticated(praw_reddit):
25
+ raise InvalidBotCredentialsError
26
+
27
+ self.__botCredentials = bot_credentials
28
+
29
+ @staticmethod
30
+ def __authenticated(praw_reddit_instance: praw.Reddit) -> bool:
31
+ """
32
+ Convenience method to authenticate bot credentials
33
+ provided to Reddit instance
34
+ """
35
+
36
+ return not praw_reddit_instance.read_only
37
+
38
+ def get_reddit_interface(self) -> RedditInterface:
39
+ """Retrieve new Reddit Interface"""
40
+
41
+ bot_credentials = self.__botCredentials
42
+ praw_reddit = praw.Reddit(
43
+ user_agent=bot_credentials.get_user_agent,
44
+ client_id=bot_credentials.get_client_id,
45
+ client_secret=bot_credentials.get_client_secret,
46
+ username=bot_credentials.getusername,
47
+ password=bot_credentials.get_password
48
+ )
49
+ return RedditInterface(praw_reddit)
@@ -0,0 +1,3 @@
1
+ from .program import Program, RecurringProgram
2
+ from .streamprocessingprogram import StreamProcessingProgram, StreamFactory, SubmissionStreamFactory, \
3
+ CommentStreamFactory, CustomStreamFactory
@@ -0,0 +1,45 @@
1
+ import logging
2
+ import time
3
+ from abc import ABC, abstractmethod
4
+ from typing import Callable
5
+
6
+
7
+ class Program(ABC):
8
+ """Class representing a simple program"""
9
+
10
+ def __init__(self, program_name: str):
11
+ self._programLogger = logging.getLogger(
12
+ program_name
13
+ )
14
+
15
+ @abstractmethod
16
+ def execute(self, *args, **kwargs):
17
+ """Execute the program"""
18
+
19
+ raise NotImplementedError()
20
+
21
+
22
+ class RecurringProgram(Program, ABC):
23
+ """Class encapsulating a looping program type"""
24
+
25
+ def __init__(
26
+ self,
27
+ program_name: str,
28
+ stop_condition: Callable[..., bool],
29
+ cooldown: float = 0
30
+ ):
31
+ super().__init__(program_name)
32
+ self._stopCondition = stop_condition
33
+ self._cooldown = cooldown
34
+
35
+ def execute(self, *args, **kwargs):
36
+ while not self._stopCondition():
37
+ self._run_nature_core(*args, **kwargs)
38
+ if self._cooldown and self._cooldown > 0:
39
+ time.sleep(self._cooldown)
40
+
41
+ @abstractmethod
42
+ def _run_nature_core(self, *args, **kwargs):
43
+ """Run core program"""
44
+
45
+ raise NotImplementedError()
@@ -0,0 +1,139 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Generator, Callable
3
+
4
+ from praw.models import Subreddit, ListingGenerator
5
+ from praw.models.util import stream_generator
6
+
7
+ from .program import RecurringProgram
8
+ from ..utility.decorators import consumestransientapierrors
9
+
10
+
11
+ class StreamFactory(ABC):
12
+ """
13
+ Class responsible for producing
14
+ new Reddit Object streams at request
15
+ """
16
+
17
+ @abstractmethod
18
+ def get_new_stream(self) -> Generator:
19
+ """Produce new stream"""
20
+
21
+ raise NotImplementedError()
22
+
23
+
24
+ class StreamProcessingProgram(RecurringProgram, ABC):
25
+ """
26
+ Class encapsulating a stream processing
27
+ program
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ stream_factory: StreamFactory,
33
+ stop_condition: Callable[..., bool],
34
+ program_name: str
35
+ ):
36
+ super().__init__(program_name, stop_condition)
37
+ self.__streamFactory = stream_factory
38
+
39
+ @consumestransientapierrors
40
+ def execute(self, *args, **kwargs):
41
+
42
+ # In case we somehow run out of
43
+ # new items in the stream (IYKYK)
44
+ while not self._stopCondition():
45
+
46
+ stream = self.__streamFactory.get_new_stream()
47
+
48
+ # "New item listener" loop
49
+ for streamItem in stream:
50
+
51
+ # Handle "pause" token
52
+ if streamItem is None:
53
+
54
+ # Exit the loop if stop condition satisfied
55
+ if self._stopCondition():
56
+ break
57
+
58
+ self._run_pause_handler()
59
+ continue
60
+
61
+ self._run_nature_core(streamItem)
62
+
63
+ def _run_pause_handler(self, *args):
64
+ """Execute when stream is paused"""
65
+ pass
66
+
67
+
68
+ class SubmissionStreamFactory(StreamFactory):
69
+ """
70
+ Class responsible for producing
71
+ new Submission streams at request
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ subreddit: Subreddit,
77
+ pause_after: int = 0,
78
+ skip_existing: bool = False
79
+ ):
80
+ super().__init__()
81
+ self.__subreddit = subreddit
82
+ self.__pause_after = pause_after
83
+ self.__skip_existing = skip_existing
84
+
85
+ def get_new_stream(self) -> Generator:
86
+ return self.__subreddit.stream.submissions(
87
+ pause_after=self.__pause_after,
88
+ skip_existing=self.__skip_existing
89
+ )
90
+
91
+
92
+ class CommentStreamFactory(StreamFactory):
93
+ """
94
+ Class responsible for producing
95
+ new Comment streams at request
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ subreddit: Subreddit,
101
+ pause_after: int = 0,
102
+ skip_existing: bool = False
103
+ ):
104
+ super().__init__()
105
+ self.__subreddit = subreddit
106
+ self.__pause_after = pause_after
107
+ self.__skip_existing = skip_existing
108
+
109
+ def get_new_stream(self) -> Generator:
110
+ return self.__subreddit.stream.comments(
111
+ pause_after=self.__pause_after,
112
+ skip_existing=self.__skip_existing
113
+ )
114
+
115
+
116
+ class CustomStreamFactory(StreamFactory):
117
+ """
118
+ Class responsible for producing new
119
+ stream of custom Reddit objects according to
120
+ the provided Listing Generator
121
+ """
122
+
123
+ def __init__(
124
+ self,
125
+ listing_generator_callback: Callable[..., ListingGenerator],
126
+ pause_after: int = 0,
127
+ skip_existing: bool = False
128
+ ):
129
+ super().__init__()
130
+ self.__listingGeneratorCallback = listing_generator_callback
131
+ self.__pause_after = pause_after
132
+ self.__skip_existing = skip_existing
133
+
134
+ def get_new_stream(self) -> Generator:
135
+ return stream_generator(
136
+ self.__listingGeneratorCallback,
137
+ pause_after=self.__pause_after,
138
+ skip_existing=self.__skip_existing
139
+ )
@@ -0,0 +1,5 @@
1
+ from .botcredentials import BotCredentials
2
+ from .contributionsutility import retrieve_submissions_from_subreddit, retrieve_select_submissions, is_removed
3
+ from .decorators import consumestransientapierrors
4
+ from .redditinterface import RedditInterface
5
+ from .redditsubmission import RedditSubmission
@@ -0,0 +1,63 @@
1
+ class BotCredentials:
2
+ """
3
+ Class holding the bot's credentials
4
+ """
5
+
6
+ __user_agent: str
7
+ __client_id: str
8
+ __client_secret: str
9
+ __username: str
10
+ __password: str
11
+
12
+ def __init__(
13
+ self,
14
+ user_agent,
15
+ client_id,
16
+ client_secret,
17
+ username,
18
+ password
19
+ ):
20
+ self.__user_agent = user_agent
21
+ self.__client_id = client_id
22
+ self.__client_secret = client_secret
23
+ self.__username = username
24
+ self.__password = password
25
+
26
+ @property
27
+ def get_user_agent(self):
28
+ """Retrieve the bot's User Agent"""
29
+
30
+ return self.__user_agent
31
+
32
+ @property
33
+ def get_client_id(self):
34
+ """Retrieve the bot's Client ID"""
35
+
36
+ return self.__client_id
37
+
38
+ @property
39
+ def get_client_secret(self):
40
+ """Retrieve the bot's Client Secret"""
41
+
42
+ return self.__client_secret
43
+
44
+ @property
45
+ def getusername(self):
46
+ """Retrieve the bot's Username"""
47
+
48
+ return self.__username
49
+
50
+ @property
51
+ def get_password(self):
52
+ """Retrieve the bot's Password"""
53
+
54
+ return self.__password
55
+
56
+ def clear_credentials(self):
57
+ """Convenience method to clear the bot's credentials"""
58
+
59
+ self.__user_agent = ""
60
+ self.__client_id = ""
61
+ self.__client_secret = ""
62
+ self.__username = ""
63
+ self.__password = ""
@@ -0,0 +1,64 @@
1
+ """
2
+ Module providing various utility methods
3
+ for submissions and comments
4
+ """
5
+ from typing import List, Union
6
+
7
+ from praw import Reddit
8
+ from praw.models import Submission, Comment
9
+
10
+
11
+ # TODO: Unpushshift
12
+ def retrieve_submissions_from_subreddit(
13
+ reddit: Reddit,
14
+ subreddit_name: str,
15
+ from_time: str,
16
+ filters: List[str]
17
+ ) -> List[Submission]:
18
+ """
19
+ Retrieves all submissions from a given subreddit
20
+ after the provided time containing only filtered info
21
+ """
22
+ # return list(
23
+ # reddit.subreddit.search_submissions(
24
+ # subreddit=subredditName,
25
+ # after=fromTime,
26
+ # filter=filters
27
+ # )
28
+ # )
29
+ raise NotImplementedError
30
+
31
+
32
+ def retrieve_select_submissions(
33
+ praw_reddit: Reddit,
34
+ submission_ids: List[str]
35
+ ) -> List[Submission]:
36
+ """
37
+ Retrieves submissions with the given
38
+ submissionIds
39
+ """
40
+
41
+ submissions = []
42
+
43
+ for submissionId in submission_ids:
44
+ submissions.append(
45
+ praw_reddit.submission(submissionId)
46
+ )
47
+
48
+ return submissions
49
+
50
+
51
+ def is_removed(
52
+ contribution: Union[Submission, Comment]
53
+ ) -> bool:
54
+ """
55
+ Checks if provided comment or
56
+ submission is removed
57
+ """
58
+
59
+ try:
60
+ author = contribution.author
61
+ except AttributeError:
62
+ author = None
63
+ return author is None or author == '[Deleted]' or \
64
+ contribution.banned_by is not None
@@ -0,0 +1,50 @@
1
+ """
2
+ Module containing utility decorators which may
3
+ be used by program
4
+ """
5
+
6
+ import functools
7
+ import time
8
+
9
+ from prawcore.exceptions import RequestException, ServerError
10
+
11
+
12
+ def consumestransientapierrors(_execute_function=None, *, timeout: int = 30):
13
+ """
14
+ Decorator responsible for consuming common transient
15
+ errors which may occur while connecting to the
16
+ Reddit API during the running of the provided
17
+ program
18
+ """
19
+
20
+ def subsuming_function(execute_function):
21
+
22
+ @functools.wraps(execute_function)
23
+ def wrapper(*args, **kwargs):
24
+ try:
25
+ program_logger = getattr(args[0], '_programLogger', None)
26
+ except IndexError:
27
+ program_logger = None
28
+ while True:
29
+ try:
30
+ function_value = execute_function(*args, **kwargs)
31
+ return function_value
32
+ # Handle for problems connecting to the Reddit API
33
+ except (RequestException, ServerError) as ex:
34
+ message = "Failed to connect to the Reddit API: {}".format(
35
+ ex.args
36
+ )
37
+ if program_logger:
38
+ program_logger.warning(
39
+ message
40
+ )
41
+ else:
42
+ print(message)
43
+ time.sleep(timeout)
44
+ return wrapper
45
+
46
+ # Handle if decorator is called with arguments
47
+ if _execute_function is None:
48
+ return subsuming_function
49
+ else:
50
+ return subsuming_function(_execute_function)
@@ -0,0 +1,28 @@
1
+ class InitializationError(Exception):
2
+ """
3
+ Class to encapsulate an error in the
4
+ initialization of a module
5
+ """
6
+
7
+ def __init__(self, *args):
8
+ super().__init__(self, args)
9
+
10
+
11
+ class BotInitializationError(InitializationError):
12
+ """
13
+ Class to encapsulate an error in the
14
+ initialization of a bot module
15
+ """
16
+
17
+ def __init__(self, *args):
18
+ super().__init__(*args)
19
+
20
+
21
+ class InvalidBotCredentialsError(Exception):
22
+ """
23
+ Class encapsulating an exception raised
24
+ when provided bot credentials are invalid
25
+ """
26
+
27
+ def __init__(self, *args):
28
+ super().__init__(self, args)
@@ -0,0 +1,19 @@
1
+ # -*- coding: utf-8 -*
2
+ from praw import Reddit
3
+
4
+
5
+ class RedditInterface:
6
+ """
7
+ Class holding tools to interface with the Reddit API
8
+ """
9
+
10
+ __prawReddit: Reddit
11
+
12
+ def __init__(self, praw_reddit: Reddit):
13
+ self.__prawReddit = praw_reddit
14
+
15
+ @property
16
+ def get_praw_reddit(self):
17
+ """Retrieve the interface's PrawReddit instance"""
18
+
19
+ return self.__prawReddit
@@ -0,0 +1,36 @@
1
+ from praw.models import Submission
2
+
3
+
4
+ class RedditSubmission:
5
+ """
6
+ Class encapsulating a submission
7
+ """
8
+
9
+ __submissionId: str
10
+
11
+ def __init__(self, submission_id):
12
+ self.__submissionId = submission_id
13
+
14
+ @property
15
+ def get_submission_id(self):
16
+ return self.__submissionId
17
+
18
+ @classmethod
19
+ def get_submission_from_id(
20
+ cls, submission_id: str
21
+ ):
22
+ """
23
+ Returns a SimpleSubmission object from
24
+ the provided submissionId
25
+ """
26
+ return RedditSubmission(submission_id)
27
+
28
+ @classmethod
29
+ def get_submission_from_praw_submission(
30
+ cls, praw_submission: Submission
31
+ ):
32
+ """
33
+ Returns a SimpleSubmission object from
34
+ the provided PRAW submission
35
+ """
36
+ return RedditSubmission(praw_submission.id)
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: redditadmin
3
+ Version: 0.0.4
4
+ Summary: Extensible Python administrative bot
5
+ Project-URL: Homepage, https://github.com/Grod56/reddit-admin
6
+ Project-URL: Issues, https://github.com/Grod56/reddit-admin/issues
7
+ Author-email: Grod56 <providenceuniversalstudios@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+
15
+ Extensible Reddit admin bot
@@ -0,0 +1,22 @@
1
+ redditadmin/__init__.py,sha256=H9tJpqCduGTfE__euVT9Qp5vWzS6VI3iXmGfbl_cixw,120
2
+ redditadmin/core.py,sha256=iGgN74nYgRLvrkAue9hk-Ptdmpr0oUCx8xWdW5ROBaY,15173
3
+ redditadmin/plugin/__init__.py,sha256=qHKyhBz5ovgef0lcXtIUsP7ZbryDrXCxRylyGJJjgTo,134
4
+ redditadmin/plugin/asynchronouspluginsexecutor.py,sha256=WkEbTFciqgJ2cQ59AgXJBsPpPntugJxF2KapB1NNmwY,7096
5
+ redditadmin/plugin/exceptions.py,sha256=tNwWiitjeCXVFTL9_y3fXF_Dzjk-CUHd2uqvl0_UUmI,298
6
+ redditadmin/plugin/plugin.py,sha256=_Zr1mhTP6y1cZDxxd8vcRYodSeiOViprmZ7TGJwaA6A,1673
7
+ redditadmin/plugin/pluginsexecutor.py,sha256=i758rqdlFysJh63ZzVY3fqQJriH2hc-gubiuK9wa24Y,1496
8
+ redditadmin/plugin/redditinterfacefactory.py,sha256=20RInQUmvHFnbE7-SttjfYa1dsRTD0lgdh5mQVieFUw,1654
9
+ redditadmin/program/__init__.py,sha256=86ezrveWRark-dpsSjuD_dPvsxDbh7uqHE-Porlhd_E,200
10
+ redditadmin/program/program.py,sha256=pGvx03PWkfQsfdRfQ-g5DbXSG0NjDuGQBrTU3baQJaE,1205
11
+ redditadmin/program/streamprocessingprogram.py,sha256=r-VuHmNdEiJq6sAoDDUub-VlX04lVCjgYCBfiiw9gPE,3888
12
+ redditadmin/utility/__init__.py,sha256=bZlqoWdMCmR7COJiiSY6RamzyqQSvbLHyifdvrBW4Ac,302
13
+ redditadmin/utility/botcredentials.py,sha256=qkrKtqXvA0h1NSwy70ESxsZyyXwXPiB5USpMt-56UwY,1462
14
+ redditadmin/utility/contributionsutility.py,sha256=FHOlMJE3Vdk5H2j-_b0TzGP9rhGn-tAxQqx4_-hk8mg,1492
15
+ redditadmin/utility/decorators.py,sha256=T0Mir1zX3_tHVDuU3UaQ1Mg1ayapaL84_GSbuifg5lY,1661
16
+ redditadmin/utility/exceptions.py,sha256=8XliprLrcYtKYji6JPYw6nfC3yA0i551iOSV3Op06A4,652
17
+ redditadmin/utility/redditinterface.py,sha256=kWmUrdQffujf3j9bDE9wCH3FAmvckeaOWcl9NV3MRy0,415
18
+ redditadmin/utility/redditsubmission.py,sha256=tB830FGKmnFMBg5GXrMcGKYdGtSSYgt_uCCaLnNYlC4,862
19
+ redditadmin-0.0.4.dist-info/METADATA,sha256=lBDvuLj28a18VyjP2ae02Xx8wZudDQ38YkbwckcbcLc,523
20
+ redditadmin-0.0.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
+ redditadmin-0.0.4.dist-info/licenses/LICENSE,sha256=kieFEKjHWxFgNnmvKA_eXHmaQQZ9ZgEI_5m14IiVzAo,1091
22
+ redditadmin-0.0.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Garikai Gumbo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.