Freeze the app for user-friendly distribution & usage

This commit is contained in:
Collin Rapp
2020-05-10 11:26:09 -07:00
parent 176f3f6f0e
commit fb03e9fff5
10 changed files with 242 additions and 69 deletions

3
.gitignore vendored
View File

@@ -4,6 +4,9 @@
*.pyc *.pyc
__pycache__ __pycache__
build/
dist/
*.html *.html
*.out *.out
*.swp *.swp

View File

@@ -4,6 +4,7 @@ url = "https://pypi.org/simple"
verify_ssl = true verify_ssl = true
[dev-packages] [dev-packages]
pyinstaller = "*"
[packages] [packages]
toml = "*" toml = "*"

81
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "c5de2f495184b77e04ef9eeabbf802ca765c0a28bc36b8e6f84da0dcac053224" "sha256": "d2ab36131af79e2a9efe43cbaf017a804bbf43d83863ec9ff3cba1987535ee30"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -18,10 +18,10 @@
"default": { "default": {
"certifi": { "certifi": {
"hashes": [ "hashes": [
"sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304",
"sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"
], ],
"version": "==2019.11.28" "version": "==2020.4.5.1"
}, },
"chardet": { "chardet": {
"hashes": [ "hashes": [
@@ -32,32 +32,33 @@
}, },
"idna": { "idna": {
"hashes": [ "hashes": [
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb",
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"
], ],
"version": "==2.8" "version": "==2.9"
}, },
"praw": { "praw": {
"hashes": [ "hashes": [
"sha256:252246f8ea2ae6fba59bbf45de3fed568a80c086bca66747a2745dff5e11df4a", "sha256:65129169d560800261908415ed955f3cbc63648549820b3ccce0a823ffa2fd78",
"sha256:544904cb821afff43c22e2dad4245658d41135d84b3a9463a5e29dd132da6efe" "sha256:74e4b6c3f206342d05272ce1770ac7b9c48207c9a7ffea3d5251460b70f18188",
"sha256:dcdcf13b7f7ae2393afd914644bf16b254eaf5230c81adf2feafe1ec514307ca"
], ],
"index": "pypi", "index": "pypi",
"version": "==6.5.1" "version": "==7.0.0"
}, },
"prawcore": { "prawcore": {
"hashes": [ "hashes": [
"sha256:25dd14bf121bc0ad2ffc78e2322d9a01a516017105a5596cc21bb1e9a928b40c", "sha256:a982a49bc911fe0e3a9751319091c380f79d5e1ba1ba19cb8dbbce21ad8b0ca7",
"sha256:ab5558efb438aa73fc66c4178bfc809194dea3ce2addf4dec873de7e2fd2824e" "sha256:b907843ab969d759cbc03f1f749acea24d11859d6aed447b2fa1cd0eda9ecf34"
], ],
"version": "==1.0.1" "version": "==1.3.0"
}, },
"requests": { "requests": {
"hashes": [ "hashes": [
"sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
"sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"
], ],
"version": "==2.22.0" "version": "==2.23.0"
}, },
"six": { "six": {
"hashes": [ "hashes": [
@@ -76,17 +77,17 @@
}, },
"update-checker": { "update-checker": {
"hashes": [ "hashes": [
"sha256:59cfad7f9a0ee99f95f1dfc60f55bf184937bcab46a7270341c2c33695572453", "sha256:1ff5dc7aab340b4f7710bd6c69d08ff5a5351617cd4ba0eb8886ddb285e2104f",
"sha256:70e39446fccf77b21192cf7a8214051fa93a636dc3b5c8b602b589d100a168b8" "sha256:2def8db7f63bd45c7d19df5df570f3f3dfeb1a1f050869d7036529295db10e62"
], ],
"version": "==0.16" "version": "==0.17"
}, },
"urllib3": { "urllib3": {
"hashes": [ "hashes": [
"sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527",
"sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"
], ],
"version": "==1.25.8" "version": "==1.25.9"
}, },
"websocket-client": { "websocket-client": {
"hashes": [ "hashes": [
@@ -96,5 +97,39 @@
"version": "==0.57.0" "version": "==0.57.0"
} }
}, },
"develop": {} "develop": {
"altgraph": {
"hashes": [
"sha256:1f05a47122542f97028caf78775a095fbe6a2699b5089de8477eb583167d69aa",
"sha256:c623e5f3408ca61d4016f23a681b9adb100802ca3e3da5e718915a9e4052cebe"
],
"version": "==0.17"
},
"future": {
"hashes": [
"sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"
],
"version": "==0.18.2"
},
"pefile": {
"hashes": [
"sha256:a5d6e8305c6b210849b47a6174ddf9c452b2888340b8177874b862ba6c207645"
],
"version": "==2019.4.18"
},
"pyinstaller": {
"hashes": [
"sha256:3730fa80d088f8bb7084d32480eb87cbb4ddb64123363763cf8f2a1378c1c4b7"
],
"index": "pypi",
"version": "==3.6"
},
"pywin32-ctypes": {
"hashes": [
"sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942",
"sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"
],
"version": "==0.2.0"
}
}
} }

3
PointsBot.py Normal file
View File

@@ -0,0 +1,3 @@
"""Dummy file used for either runnning or freezing the bot."""
import pointsbot
pointsbot.run()

37
PointsBot.spec Normal file
View File

@@ -0,0 +1,37 @@
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(['PointsBot.py'],
pathex=['C:\\Users\\Collin\\Documents\\git\\PointsBot'],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=['.\\pyinstaller-hooks\\'],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
[],
exclude_binaries=True,
name='PointsBot',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True )
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='PointsBot')

13
freeze.cmd Normal file
View File

@@ -0,0 +1,13 @@
@echo off
REM FOR /F "tokens=* USEBACKQ" %%F IN (`pipenv --venv`) DO (
REM SET pipenvdir=%%F
REM )
REM --add-data "%pipenvdir%\Lib\site-packages\praw\praw.ini;site-packages\praw\praw.ini" ^
REM --noconfirm ^
pyinstaller ^
--onedir ^
--additional-hooks-dir .\pyinstaller-hooks\ ^
PointsBot.py

5
freeze.sh Normal file
View File

@@ -0,0 +1,5 @@
pyinstaller \
--noconfirm \
--onedir \
--additional-hooks-dir ./pyinstaller-hooks/ \
PointsBot.py

View File

@@ -1,3 +1,6 @@
import logging
import os
import os.path
import re import re
import praw import praw
@@ -9,8 +12,13 @@ from . import config, database, level, reply
USER_AGENT = 'PointsBot (by u/GlipGlorp7)' USER_AGENT = 'PointsBot (by u/GlipGlorp7)'
# TODO put this in config
LOG_FILEPATH = os.path.abspath(os.path.join(os.path.expanduser('~'),
'.pointsbot',
'pointsbot.log.txt'))
# The pattern that determines whether a post is marked as solved # The pattern that determines whether a post is marked as solved
# Could also just use re.IGNORECASE flag # Could also use re.IGNORECASE flag instead
SOLVED_PAT = re.compile('![Ss]olved') SOLVED_PAT = re.compile('![Ss]olved')
MOD_SOLVED_PAT = re.compile('/[Ss]olved') MOD_SOLVED_PAT = re.compile('/[Ss]olved')
@@ -18,6 +26,13 @@ MOD_SOLVED_PAT = re.compile('/[Ss]olved')
def run(): def run():
logging.basicConfig(filename=LOG_FILEPATH,
level=logging.DEBUG,
format='%(asctime)s %(module)s:%(levelname)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S')
print_welcome_message()
cfg = config.load() cfg = config.load()
levels = cfg.levels levels = cfg.levels
db = database.Database(cfg.database_path) db = database.Database(cfg.database_path)
@@ -30,21 +45,26 @@ def run():
username=cfg.username, username=cfg.username,
password=cfg.password, password=cfg.password,
user_agent=USER_AGENT) user_agent=USER_AGENT)
subreddit = reddit.subreddit(cfg.subreddit) logging.info('Connected to Reddit as %s', reddit.user.me())
if not reddit.read_only:
logging.info('Has write access to Reddit')
else:
logging.info('Has read-only access to Reddit')
print_level(0, f'Connected to Reddit as {reddit.user.me()}') subreddit = reddit.subreddit(cfg.subreddit)
print_level(1, f'Read-only? {reddit.read_only}') logging.info('Watching subreddit %s', subreddit.title)
print_level(0, f'Watching subreddit {subreddit.title}') if subreddit.moderator(redditor=reddit.user.me()):
is_mod = bool(subreddit.moderator(redditor=reddit.user.me())) logging.info('Is moderator for monitored subreddit')
print_level(1, f'Is mod? {is_mod}') else:
logging.warning('Is NOT moderator for monitored subreddit')
monitor_comments(subreddit, db, levels) monitor_comments(subreddit, db, levels)
# Ignoring other potential exceptions for now, since we may not be able # Ignoring other potential exceptions for now, since we may not be able
# to recover from them as well as from this one # to recover from them as well as from this one
except prawcore.exceptions.RequestException as e: except prawcore.exceptions.RequestException as e:
print('Unable to connect; attempting again....') log.error('Unable to connect; attempting again....')
except prawcore.exceptions.ServerError as e: except prawcore.exceptions.ServerError as e:
print('Lost connection to Reddit; attempting to reconnect....') log.error('Lost connection to Reddit; attempting to reconnect....')
def monitor_comments(subreddit, db, levels): def monitor_comments(subreddit, db, levels):
@@ -55,57 +75,70 @@ def monitor_comments(subreddit, db, levels):
if comm is None: if comm is None:
continue continue
print_level(0, '\nFound comment') logging.info('Received comment')
print_level(1, f'Comment text: "{comm.body}"') # TODO more debug info about comment, eg author
logging.debug('Comment text: "%s"', comm.body)
# print_level(0, '\nFound comment')
# print_level(1, f'Comment text: "{comm.body}"')
if not marks_as_solved(comm): if not marks_as_solved(comm):
print_level(1, 'Not a "![Ss]olved" comment') logging.info('Comment does not mark issue as solved')
# print_level(1, 'Not a "![Ss]olved" comment')
continue continue
logging.info('Comment marks issues as solved')
if is_mod_comment(comm): if is_mod_comment(comm):
print_level(1, 'Mod comment') logging.info('Comment was submitted by mod')
# print_level(1, 'Mod comment')
elif not is_first_solution(comm): elif not is_first_solution(comm):
# Skip this "!solved" comment and wait for the next # Skip this "!solved" comment
print_level(1, 'Not the first solution') logging.info('Comment is NOT the first to mark the issue as solved')
# print_level(1, 'Not the first solution')
continue continue
print_level(1, 'This is the first solution found') logging.info('Comment is the first to mark the issue as solved')
print_solution_info(comm) # print_level(1, 'This is the first solution found')
log_solution_info(comm)
solver = find_solver(comm) solver = find_solver(comm)
db.add_point(solver) db.add_point(solver)
print_level(1, f'Added point for {solver.name}') logging.info('Added point for user "%s"', solver.name)
# print_level(1, f'Added point for {solver.name}')
points = db.get_points(solver) points = db.get_points(solver)
print_level(1, f'Total points for {solver.name}: {points}') logging.info('Total points for user "%s": %d', solver.name, points)
# print_level(1, f'Total points for {solver.name}: {points}')
level_info = level.user_level_info(points, levels) level_info = level.user_level_info(points, levels)
# Reply to the comment marking the submission as solved # Reply to the comment marking the submission as solved
reply_body = reply.make(solver, points, level_info) reply_body = reply.make(solver, points, level_info)
try: try:
comm.reply(reply_body) comm.reply(reply_body)
print_level(1, f'Replied to comment with: "{reply_body}"') logging.info('Replied to the comment')
logging.debug('Reply body: %s', reply_body)
# print_level(1, f'Replied to comment with: "{reply_body}"')
except praw.exceptions.APIException as e: except praw.exceptions.APIException as e:
print_level(1, 'Unable to reply to comment') logging.error('Unable to reply to comment: %s', e)
print_level(2, f'{e}')
db.remove_point(solver) db.remove_point(solver)
print_level(1, f'Removed point awarded to {solver.name}') logging.error('Removed point that was just awarded to user "%s"', solver.name)
print_level(1, 'Skipping comment') logging.error('Skipping comment')
continue continue
# Check if (non-mod) user flair should be updated to new level # Check if (non-mod) user flair should be updated to new level
lvl = level_info.current lvl = level_info.current
if lvl and lvl.points == points: if lvl and lvl.points == points:
print_level(1, f'User reached level: {lvl.name}') logging.info('User reached level: %s', lvl.name)
if not subreddit.moderator(redditor=solver): if not subreddit.moderator(redditor=solver):
print_level(2, 'Setting flair') # print_level(2, 'Setting flair')
print_level(3, f'Flair text: {lvl.name}') logging.info('User is not mod; setting flair')
print_level(3, f'Flair template ID: {lvl.flair_template_id}') logging.info('Flair text: %s', lvl.name)
logging.info('Flair template ID: %s', lvl.flair_template_id)
subreddit.flair.set(solver, subreddit.flair.set(solver,
text=lvl.name, text=lvl.name,
flair_template_id=lvl.flair_template_id) flair_template_id=lvl.flair_template_id)
else: else:
print_level(2, 'Solver is mod; don\'t alter flair') logging.info('Solver is mod; don\'t alter flair')
### Reddit Comment Functions ### ### Reddit Comment Functions ###
@@ -154,20 +187,40 @@ def find_solver(solved_comment):
return solved_comment.parent().author return solved_comment.parent().author
### Debugging & Logging ### ### Print Functions ###
def print_separator_line():
print('#' * 80)
def print_welcome_message():
print_separator_line()
print('\n*** Welcome to PointsBot! ***\n')
print_separator_line()
print('\nThis bot will monitor the subreddit specified in the '
'configuration file as long as this program is running.')
print('\nAny Reddit activity that occurs while this program is not running '
'will be missed. You can work around this by using features '
'mentioned in the README.')
print('\nThe output from this program can be referenced if any issues are '
'to occur, and the relevant error message or crash report can be '
'sent to the developer by reporting an issue on the Github page.')
print('\nFuture updates will hopefully resolve these issues, but for the '
"moment, this is what we've got to work with! :)\n")
def print_level(num_levels, string): def print_level(num_levels, string):
print('\t' * num_levels + string) print('\t' * num_levels + string)
def print_solution_info(comm): def log_solution_info(comm):
print_level(1, 'Submission solved') logging.info('Submission solved')
print_level(2, 'Solution comment:') logging.debug('Solution comment:')
print_level(3, f'Author: {comm.parent().author.name}') logging.debug('Author: %s', comm.parent().author.name)
print_level(3, f'Body: {comm.parent().body}') logging.debug('Body: %s', comm.parent().body)
print_level(2, '"Solved" comment:') logging.debug('"Solved" comment:')
print_level(3, f'Author: {comm.author.name}') logging.debug('Author: %s', comm.author.name)
print_level(3, f'Body: {comm.body}') logging.debug('Body: %s', comm.body)

View File

@@ -115,19 +115,39 @@ def interactive_config(dest):
print('#' * 80 + '\nCONFIGURING THE BOT\n' + '#' * 80) print('#' * 80 + '\nCONFIGURING THE BOT\n' + '#' * 80)
print('\nType a value for each field, then press enter.') print('\nType a value for each field, then press enter.')
print('\nIf the field is specified as optional, leave blank to skip.\n') print('\nIf a field is specified as optional, then you can skip it by just '
'pressing enter.\n')
configvals['core']['subreddit'] = input('subreddit? ') configvals['core']['subreddit'] = input('name of subreddit to monitor? ')
print() print()
configvals['filepaths']['database'] = input('database filename? (optional) ') configvals['filepaths']['database'] = input('database filename? (optional) ')
print() print('\n*** Bot account details ***\n')
configvals['credentials']['client_id'] = input('client_id? ') configvals['credentials']['client_id'] = input('client_id? ')
configvals['credentials']['client_secret'] = input('client_secret? ') configvals['credentials']['client_secret'] = input('client_secret? ')
configvals['credentials']['username'] = input('username? ') configvals['credentials']['username'] = input('bot username? ')
configvals['credentials']['password'] = input('password? ') configvals['credentials']['password'] = input('bot password? ')
add_another_level = True print('\n*** Flair Levels ***\n')
while add_another_level: print('These fields will determine the different levels that your '
'subreddit users can achieve by earning points.')
print('\nFor each level, you should provide...')
print("\t- Level name: the text that appears in the user's flair")
print('\t- Level points: the number of points needed to reach the level')
print('\t- Flair template ID: (optional) the flair template ID in your')
print('\t subreddit to be used for this level flair')
print('\nThese may be provided in any order; the bot will sort them later.')
print('\nDo not provide more than one level with the same number of points.')
print('\nNote that at the moment, providing a level points value of zero '
'will not set a default flair, because users must solve at least one '
'issue before the bot will keep track of their points and set their '
'flair for the first time.')
print('\nFor any more questions, please refer to the README on the Github '
'page.')
# add_another_level = True
response = 'y'
while response.lower().startswith('y'):
print('\n*** Adding a level ***')
level = {} level = {}
level['name'] = input('\nLevel name? ') level['name'] = input('\nLevel name? ')
level['points'] = int(input('Level points? ')) level['points'] = int(input('Level points? '))
@@ -135,7 +155,7 @@ def interactive_config(dest):
configvals['levels'].append(level) configvals['levels'].append(level)
response = input('\nAdd another level? (y/n) ') response = input('\nAdd another level? (y/n) ')
add_another_level = response.lower().startswith('y') # add_another_level = response.lower().startswith('y')
with open(dest, 'w') as f: with open(dest, 'w') as f:
toml.dump(configvals, f) toml.dump(configvals, f)

View File

@@ -0,0 +1,3 @@
from PyInstaller.utils.hooks import collect_data_files
datas = collect_data_files('praw')