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.
- redditadmin/__init__.py +4 -0
- redditadmin/core.py +440 -0
- redditadmin/plugin/__init__.py +3 -0
- redditadmin/plugin/asynchronouspluginsexecutor.py +203 -0
- redditadmin/plugin/exceptions.py +11 -0
- redditadmin/plugin/plugin.py +62 -0
- redditadmin/plugin/pluginsexecutor.py +54 -0
- redditadmin/plugin/redditinterfacefactory.py +49 -0
- redditadmin/program/__init__.py +3 -0
- redditadmin/program/program.py +45 -0
- redditadmin/program/streamprocessingprogram.py +139 -0
- redditadmin/utility/__init__.py +5 -0
- redditadmin/utility/botcredentials.py +63 -0
- redditadmin/utility/contributionsutility.py +64 -0
- redditadmin/utility/decorators.py +50 -0
- redditadmin/utility/exceptions.py +28 -0
- redditadmin/utility/redditinterface.py +19 -0
- redditadmin/utility/redditsubmission.py +36 -0
- redditadmin-0.0.4.dist-info/METADATA +15 -0
- redditadmin-0.0.4.dist-info/RECORD +22 -0
- redditadmin-0.0.4.dist-info/WHEEL +4 -0
- redditadmin-0.0.4.dist-info/licenses/LICENSE +21 -0
redditadmin/__init__.py
ADDED
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,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,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,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.
|