From 275f16e99763deb84287792825ea043a27b0045b Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Fri, 28 Aug 2020 21:14:44 +1000 Subject: [PATCH 01/20] make corrections to docs --- docs/install.md | 4 ++-- docs/upgrade.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/install.md b/docs/install.md index 4c79b9e..1aa7a9b 100644 --- a/docs/install.md +++ b/docs/install.md @@ -14,7 +14,7 @@ pip3 install ephemetoot If you do not have permission to install python modules, you may need to use the `--user` flag. Generally this is not advisable, since you will need to run ephemetoot with the same user since it will only be installed for that user and not globally: ```shell -pip3 install . --user +pip3 install ephemetoot --user ``` ## Obtain an access token @@ -27,7 +27,7 @@ Now you've installed `ephemetoot`, in order to actually use it you will need an 4. Enter an application name (e.g. 'ephemetoot'), and give the app both 'read' and 'write' Scopes 5. Click `SUBMIT` 6. Click on the name of the new app, which should be a link -7. Copy the `Your access token` string +7. Copy the `Your access token` string - you will need this for your configuration file (see below) **NOTE**: Anyone who has your access token and the domain name of your Mastodon server will be able to: * read all your private and direct toots, diff --git a/docs/upgrade.md b/docs/upgrade.md index b1de63b..a95853b 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -4,12 +4,13 @@ ### Note for users upgrading from Version 2 to Version 3 -To upgrade from Version 2.x to Version 3.x you should be able to simply follow the _Upgrading with pypi_ instructions below. However the safest procedure is: +To upgrade from Version 2.x to Version 3.x you will need to remove your existing install. 1. save a copy of your `config.yaml` file somewhere safe 2. run `pip uninstall ephemetoot` 3. run `pip install ephemetoot` -4. do a test run with `ephemetoot --test` +4. check your config file is in the current directory +5. do a test run with `ephemetoot --test` ### Upgrading with pypi To upgrade to a new version, the easiest way is to use pip to download the latest version from pypi (remembering that for your machine you may need to substitute `pip` for `pip3`): From beac59d4406fde2912cf35d8b5b7cc7b1fe93876 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Fri, 28 Aug 2020 21:16:07 +1000 Subject: [PATCH 02/20] add try/except for missing config file --- ephemetoot/console.py | 50 ++++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/ephemetoot/console.py b/ephemetoot/console.py index f4c05d1..3697451 100644 --- a/ephemetoot/console.py +++ b/ephemetoot/console.py @@ -43,7 +43,7 @@ parser.add_argument( "--archive-deleted", action="store_true", help="Only archive toots that are being deleted" ) parser.add_argument( - "--config", action="store", metavar="filepath", default="config.yaml", help="Filepath of your config file, absolute or relative to the current directory. If no --config path is provided, ephemetoot will use 'config.yaml'." + "--config", action="store", metavar="filepath", default="config.yaml", help="Filepath of your config file, absolute or relative to the current directory. If no --config path is provided, ephemetoot will use 'config.yaml'in the current directory" ) parser.add_argument( "--datestamp", action="store_true", help="Include a datetime stamp for every action (e.g. deleting a toot)" @@ -52,7 +52,7 @@ parser.add_argument( "--hide-skipped", "--hide_skipped", action="store_true", help="Do not write to log when skipping saved toots" ) parser.add_argument( - "--init", action="store_true", help="Initialise creation of a config file saved in the current directory." + "--init", action="store_true", help="Create a config file that is saved in the current directory" ) parser.add_argument( "--pace", action="store_true", help="Slow deletion actions to match API rate limit to avoid pausing" @@ -73,7 +73,7 @@ parser.add_argument( "--time", action="store", metavar=('hour', 'minute'), nargs="*", help="Hour and minute to schedule: e.g. 9 30 for 9.30am" ) parser.add_argument( - "--version", action="store_true", help="Display the version number" + "--version", action="store_true", help="Display the version numbers of the installed and latest versions" ) options = parser.parse_args() @@ -85,25 +85,31 @@ else: config_file = os.path.join( os.getcwd(), options.config ) def main(): - if options.init: - func.init() - elif options.version: - func.version(vnum) - elif options.schedule: - func.schedule(options) - else: - if not options.quiet: - print('') - print('============= EPHEMETOOT v' + vnum + ' ================') - print('Running at ' + str( datetime.now(timezone.utc).strftime('%a %d %b %Y %H:%M:%S %z') )) - print('================================================') - print('') - if options.test: - print("This is a test run...\n") - with open(config_file) as config: - for accounts in yaml.safe_load_all(config): - for user in accounts: - func.checkToots(user, options) + try: + + if options.init: + func.init() + elif options.version: + func.version(vnum) + elif options.schedule: + func.schedule(options) + else: + if not options.quiet: + print('') + print('============= EPHEMETOOT v' + vnum + ' ================') + print('Running at ' + str( datetime.now(timezone.utc).strftime('%a %d %b %Y %H:%M:%S %z') )) + print('================================================') + print('') + if options.test: + print("This is a test run...\n") + with open(config_file) as config: + for accounts in yaml.safe_load_all(config): + for user in accounts: + func.checkToots(user, options) + + except FileNotFoundError as err: + print("๐Ÿ•ต๏ธ Missing config file") + print("Run \033[92mephemetoot --init\033[0m to create a new one\n") if __name__ == '__main__': main() \ No newline at end of file From e13646e459e2284640f0cae5cf4cad5f9e8adf7a Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Fri, 28 Aug 2020 21:25:46 +1000 Subject: [PATCH 03/20] apply black formatting --- ephemetoot/console.py | 92 ++++++++++++++++++++++++++----------- ephemetoot/ephemetoot.py | 97 +++++++++++++++++++--------------------- ephemetoot/plist.py | 4 +- 3 files changed, 113 insertions(+), 80 deletions(-) diff --git a/ephemetoot/console.py b/ephemetoot/console.py index 3697451..a174842 100644 --- a/ephemetoot/console.py +++ b/ephemetoot/console.py @@ -30,7 +30,7 @@ import yaml from argparse import ArgumentParser from datetime import datetime, timezone import os -import pkg_resources +import pkg_resources # import funtions from ephemetoot import ephemetoot as func @@ -40,49 +40,81 @@ vnum = pkg_resources.require("ephemetoot")[0].version parser = ArgumentParser() parser.add_argument( - "--archive-deleted", action="store_true", help="Only archive toots that are being deleted" + "--archive-deleted", + action="store_true", + help="Only archive toots that are being deleted", ) parser.add_argument( - "--config", action="store", metavar="filepath", default="config.yaml", help="Filepath of your config file, absolute or relative to the current directory. If no --config path is provided, ephemetoot will use 'config.yaml'in the current directory" + "--config", + action="store", + metavar="filepath", + default="config.yaml", + help="Filepath of your config file, absolute or relative to the current directory. If no --config path is provided, ephemetoot will use 'config.yaml'in the current directory", ) parser.add_argument( - "--datestamp", action="store_true", help="Include a datetime stamp for every action (e.g. deleting a toot)" + "--datestamp", + action="store_true", + help="Include a datetime stamp for every action (e.g. deleting a toot)", ) parser.add_argument( - "--hide-skipped", "--hide_skipped", action="store_true", help="Do not write to log when skipping saved toots" + "--hide-skipped", + "--hide_skipped", + action="store_true", + help="Do not write to log when skipping saved toots", ) parser.add_argument( - "--init", action="store_true", help="Create a config file that is saved in the current directory" + "--init", + action="store_true", + help="Create a config file that is saved in the current directory", ) parser.add_argument( - "--pace", action="store_true", help="Slow deletion actions to match API rate limit to avoid pausing" + "--pace", + action="store_true", + help="Slow deletion actions to match API rate limit to avoid pausing", +) +parser.add_argument("--quiet", action="store_true", help="Suppress most logging") +parser.add_argument( + "--retry-mins", + action="store", + metavar="minutes", + nargs="?", + const="1", + default="1", + type=int, + help="Number of minutes to wait between retries, when an error is thrown", ) parser.add_argument( - "--quiet", action="store_true", help="Suppress most logging" -) -parser.add_argument( - "--retry-mins", action="store", metavar="minutes", nargs="?", const="1", default="1", type=int, help="Number of minutes to wait between retries, when an error is thrown" -) -parser.add_argument( - "--schedule", action="store", metavar="filepath", nargs="?", const=".", help="Save and load plist file on MacOS" + "--schedule", + action="store", + metavar="filepath", + nargs="?", + const=".", + help="Save and load plist file on MacOS", ) parser.add_argument( "--test", action="store_true", help="Do a test run without deleting any toots" ) parser.add_argument( - "--time", action="store", metavar=('hour', 'minute'), nargs="*", help="Hour and minute to schedule: e.g. 9 30 for 9.30am" + "--time", + action="store", + metavar=("hour", "minute"), + nargs="*", + help="Hour and minute to schedule: e.g. 9 30 for 9.30am", ) parser.add_argument( - "--version", action="store_true", help="Display the version numbers of the installed and latest versions" + "--version", + action="store_true", + help="Display the version numbers of the installed and latest versions", ) options = parser.parse_args() -if options.config[0] == '~': +if options.config[0] == "~": config_file = os.path.expanduser(options.config) -elif options.config[0] == '/': +elif options.config[0] == "/": config_file = options.config -else: - config_file = os.path.join( os.getcwd(), options.config ) +else: + config_file = os.path.join(os.getcwd(), options.config) + def main(): try: @@ -95,11 +127,16 @@ def main(): func.schedule(options) else: if not options.quiet: - print('') - print('============= EPHEMETOOT v' + vnum + ' ================') - print('Running at ' + str( datetime.now(timezone.utc).strftime('%a %d %b %Y %H:%M:%S %z') )) - print('================================================') - print('') + print("") + print("============= EPHEMETOOT v" + vnum + " ================") + print( + "Running at " + + str( + datetime.now(timezone.utc).strftime("%a %d %b %Y %H:%M:%S %z") + ) + ) + print("================================================") + print("") if options.test: print("This is a test run...\n") with open(config_file) as config: @@ -111,5 +148,6 @@ def main(): print("๐Ÿ•ต๏ธ Missing config file") print("Run \033[92mephemetoot --init\033[0m to create a new one\n") -if __name__ == '__main__': - main() \ No newline at end of file + +if __name__ == "__main__": + main() diff --git a/ephemetoot/ephemetoot.py b/ephemetoot/ephemetoot.py index afb4310..fbf6d4b 100644 --- a/ephemetoot/ephemetoot.py +++ b/ephemetoot/ephemetoot.py @@ -19,6 +19,7 @@ import requests # local from ephemetoot import plist + def init(): init_start = "\033[96m" @@ -32,76 +33,64 @@ def init(): conf_user = "" while len(conf_user) < 1: conf_user = input( - init_start - + "Username" - + init_eg - + "(without the '@' - e.g. alice):" - + init_end + init_start + + "Username" + + init_eg + + "(without the '@' - e.g. alice):" + + init_end ) conf_url = "" while len(conf_url) < 1: conf_url = input( - init_start - + "Base URL" - + init_eg - + "(e.g. example.social):" - + init_end + init_start + "Base URL" + init_eg + "(e.g. example.social):" + init_end ) conf_days = "" while conf_days.isdigit() == False: conf_days = input( - init_start - + "Days to keep" - + init_eg - + "(default 365):" - + init_end + init_start + "Days to keep" + init_eg + "(default 365):" + init_end ) conf_keep_pinned = "" while conf_keep_pinned not in ["y", "n"]: conf_keep_pinned = input( - init_start - + "Keep pinned toots?" - + init_eg - + "(y or n):" - + init_end + init_start + "Keep pinned toots?" + init_eg + "(y or n):" + init_end ) conf_pinned = "true" if conf_keep_pinned == "y" else "false" conf_keep_toots = input( - init_start + init_start + "Toots to keep" - + init_eg + + init_eg + " (optional list of IDs separated by commas):" + init_end - ) + ) conf_keep_hashtags = input( - init_start + init_start + "Hashtags to keep" - + init_eg + + init_eg + " (optional list separated by commas):" + init_end - ) + ) conf_keep_visibility = input( - init_start + init_start + "Visibility to keep" - + init_eg - + " (optional list separated by commas):" + + init_eg + + " (optional list separated by commas):" + init_end - ) + ) conf_archive = input( - init_start + init_start + "Archive path" - + init_eg + + init_eg + " (optional filepath for archive):" + init_end - ) + ) # write out the config file with open("config.yaml", "w") as configfile: @@ -114,28 +103,29 @@ def init(): configfile.write("\n keep_pinned: " + conf_pinned) if len(conf_keep_toots) > 0: - keep_list = conf_keep_toots.split(',') + keep_list = conf_keep_toots.split(",") configfile.write("\n toots_to_keep:") for toot in keep_list: - configfile.write("\n - " + toot.strip()) + configfile.write("\n - " + toot.strip()) if len(conf_keep_hashtags) > 0: - tag_list = conf_keep_hashtags.split(',') - configfile.write("\n hashtags_to_keep:") + tag_list = conf_keep_hashtags.split(",") + configfile.write("\n hashtags_to_keep:") for tag in tag_list: - configfile.write("\n - " + tag.strip()) + configfile.write("\n - " + tag.strip()) if len(conf_keep_visibility) > 0: - viz_list = conf_keep_visibility.split(',') - configfile.write("\n visibility_to_keep:") + viz_list = conf_keep_visibility.split(",") + configfile.write("\n visibility_to_keep:") for mode in viz_list: - configfile.write("\n - " + mode.strip()) + configfile.write("\n - " + mode.strip()) if len(conf_archive) > 0: configfile.write("\n archive: " + conf_archive) configfile.close() + def version(vnum): try: latest = requests.get( @@ -147,11 +137,14 @@ def version(vnum): print("-------------------------------") print("Using: \033[92mVersion " + vnum + "\033[0m") print("Latest: \033[92m" + latest_version + "\033[0m") - print("To upgrade to the most recent version run \033[92mpip3 install --update ephemetoot\033[0m") + print( + "To upgrade to the most recent version run \033[92mpip3 install --update ephemetoot\033[0m" + ) except Exception as e: print("Something went wrong:") + def schedule(options): try: @@ -172,7 +165,11 @@ def schedule(options): lines[23] = " " + options.time[1] + "" # write out file directly to ~/Library/LaunchAgents - f = open(os.path.expanduser("~/Library/LaunchAgents/") + "ephemetoot.scheduler.plist", mode="w") + f = open( + os.path.expanduser("~/Library/LaunchAgents/") + + "ephemetoot.scheduler.plist", + mode="w", + ) for line in lines: if line == lines[-1]: f.write(line) @@ -185,12 +182,12 @@ def schedule(options): ["launchctl unload ~/Library/LaunchAgents/ephemetoot.scheduler.plist"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - shell=True + shell=True, ) # load the new file subprocess.run( ["launchctl load ~/Library/LaunchAgents/ephemetoot.scheduler.plist"], - shell=True + shell=True, ) print("โฐ Scheduled!") except Exception as e: @@ -569,7 +566,9 @@ def checkToots(config, options, retry_count=0): except MastodonAPIError as e: if e.args[1] == 401: - print("\n๐Ÿ™… User and/or access token does not exist or has been deleted (401)") + print( + "\n๐Ÿ™… User and/or access token does not exist or has been deleted (401)" + ) elif e.args[1] == 404: print("\n๐Ÿ”ญ Can't find that server (404)") else: @@ -579,11 +578,7 @@ def checkToots(config, options, retry_count=0): if retry_count == 0: print("\n๐Ÿ“ก ephemetoot cannot connect to the server - are you online?") if retry_count < 4: - print( - "Waiting " - + str(options.retry_mins) - + " minutes before trying again" - ) + print("Waiting " + str(options.retry_mins) + " minutes before trying again") time.sleep(60 * options.retry_mins) retry_count += 1 print("Attempt " + str(retry_count + 1)) diff --git a/ephemetoot/plist.py b/ephemetoot/plist.py index 23d9a83..9be6343 100644 --- a/ephemetoot/plist.py +++ b/ephemetoot/plist.py @@ -1,4 +1,4 @@ -default_file = ''' +default_file = """ @@ -24,4 +24,4 @@ default_file = ''' 00 -''' \ No newline at end of file +""" From 2c9e35fffcb388b5430a29a6d1290845feb9f0a6 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sat, 29 Aug 2020 14:04:24 +1000 Subject: [PATCH 04/20] create __docs__ comments --- ephemetoot/console.py | 3 +++ ephemetoot/ephemetoot.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/ephemetoot/console.py b/ephemetoot/console.py index a174842..3f626e2 100644 --- a/ephemetoot/console.py +++ b/ephemetoot/console.py @@ -117,6 +117,9 @@ else: def main(): + ''' + Call ephemetoot.checkToots() on each user in the config file, with options set via flags from command line. + ''' try: if options.init: diff --git a/ephemetoot/ephemetoot.py b/ephemetoot/ephemetoot.py index fbf6d4b..9e87366 100644 --- a/ephemetoot/ephemetoot.py +++ b/ephemetoot/ephemetoot.py @@ -22,6 +22,10 @@ from ephemetoot import plist def init(): + ''' + Creates a config.yaml file in the current directory, based on user input. + ''' + init_start = "\033[96m" init_end = "\033[0m" init_eg = "\033[2m" @@ -127,6 +131,9 @@ def init(): def version(vnum): + ''' + Prints current and latest version numbers to console. + ''' try: latest = requests.get( "https://api.github.com/repos/hughrun/ephemetoot/releases/latest" @@ -146,6 +153,10 @@ def version(vnum): def schedule(options): + + ''' + Creates and loads a plist file for scheduled running with launchd. If --time flag is used, the scheduled time is set accordingly. Note that this is designed for use on MacOS. + ''' try: if options.schedule == ".": @@ -196,6 +207,10 @@ def schedule(options): def checkToots(config, options, retry_count=0): + ''' + The main function, uses the Mastodon API to check all toots in the user timeline, and delete any that do not meet any of the exclusion criteria from the config file. + ''' + keep_pinned = "keep_pinned" in config and config["keep_pinned"] toots_to_keep = config["toots_to_keep"] if "toots_to_keep" in config else [] visibility_to_keep = ( From 619a4a42f7a7ce7188b0fdfcb79b9a265184aa28 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 30 Aug 2020 11:44:37 +1000 Subject: [PATCH 05/20] add pytest --- poetry.lock | 200 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 4 +- 2 files changed, 201 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5f6c9c3..62c7371 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,24 @@ +[[package]] +category = "dev" +description = "Atomic file writes." +name = "atomicwrites" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.4.0" + +[[package]] +category = "dev" +description = "Classes Without Boilerplate" +name = "attrs" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "20.1.0" + +[package.extras] +dev = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "sphinx-rtd-theme", "pre-commit"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] + [[package]] category = "main" description = "Pure-Python implementation of the blurhash algorithm." @@ -25,6 +46,14 @@ optional = false python-versions = "*" version = "3.0.4" +[[package]] +category = "dev" +description = "Cross-platform colored terminal text." +name = "colorama" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "0.4.3" + [[package]] category = "main" description = "Decorators for Humans" @@ -41,6 +70,29 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "2.10" +[[package]] +category = "dev" +description = "Read metadata from Python packages" +name = "importlib-metadata" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +version = "1.7.0" + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "rst.linker"] +testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] + +[[package]] +category = "dev" +description = "iniconfig: brain-dead simple config-ini parsing" +name = "iniconfig" +optional = false +python-versions = "*" +version = "1.0.1" + [[package]] category = "main" description = "Python wrapper for the Mastodon API" @@ -63,6 +115,80 @@ blurhash = ["blurhash (>=1.1.4)"] test = ["blurhash (>=1.1.4)", "cryptography (>=1.6.0)", "http-ece (>=1.0.5)", "pytest", "pytest-cov", "pytest-mock", "pytest-runner", "pytest-vcr", "requests-mock", "vcrpy"] webpush = ["cryptography (>=1.6.0)", "http-ece (>=1.0.5)"] +[[package]] +category = "dev" +description = "More routines for operating on iterables, beyond itertools" +name = "more-itertools" +optional = false +python-versions = ">=3.5" +version = "8.5.0" + +[[package]] +category = "dev" +description = "Core utilities for Python packages" +name = "packaging" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "20.4" + +[package.dependencies] +pyparsing = ">=2.0.2" +six = "*" + +[[package]] +category = "dev" +description = "plugin and hook calling mechanisms for python" +name = "pluggy" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.13.1" + +[package.dependencies] +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +category = "dev" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +name = "py" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.9.0" + +[[package]] +category = "dev" +description = "Python parsing module" +name = "pyparsing" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +version = "2.4.7" + +[[package]] +category = "dev" +description = "pytest: simple powerful testing with Python" +name = "pytest" +optional = false +python-versions = ">=3.5" +version = "6.0.1" + +[package.dependencies] +attrs = ">=17.4.0" +iniconfig = "*" +more-itertools = ">=4.0.0" +packaging = "*" +pluggy = ">=0.12,<1.0" +py = ">=1.8.2" +toml = "*" +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +colorama = {version = "*", markers = "sys_platform == \"win32\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} + +[package.extras] +checkqa_mypy = ["mypy (0.780)"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + [[package]] category = "main" description = "Extensions to the standard Python datetime module" @@ -124,6 +250,14 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" version = "1.15.0" +[[package]] +category = "dev" +description = "Python Library for Tom's Obvious, Minimal Language" +name = "toml" +optional = false +python-versions = "*" +version = "0.10.1" + [[package]] category = "main" description = "HTTP library with thread-safe connection pooling, file post, and more." @@ -137,12 +271,32 @@ brotli = ["brotlipy (>=0.6.0)"] secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)", "ipaddress"] socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] +[[package]] +category = "dev" +description = "Backport of pathlib-compatible object wrapper for zip files" +name = "zipp" +optional = false +python-versions = ">=3.6" +version = "3.1.0" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["jaraco.itertools", "func-timeout"] + [metadata] -content-hash = "35d07702bbbc789f7e6dc248539c816144f6b4a294d7f5e277ab4aed3c094584" +content-hash = "fefbdaa7cc9c44fec27e8a8a10c673b7d72e00316507560ceef047285bcdfb25" lock-version = "1.1" python-versions = "^3.6" [metadata.files] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] +attrs = [ + {file = "attrs-20.1.0-py2.py3-none-any.whl", hash = "sha256:2867b7b9f8326499ab5b0e2d12801fa5c98842d2cbd22b35112ae04bf85b4dff"}, + {file = "attrs-20.1.0.tar.gz", hash = "sha256:0ef97238856430dcf9228e07f316aefc17e8939fc8507e18c6501b761ef1a42a"}, +] blurhash = [ {file = "blurhash-1.1.4-py2.py3-none-any.whl", hash = "sha256:7611c1bc41383d2349b6129208587b5d61e8792ce953893cb49c38beeb400d1d"}, {file = "blurhash-1.1.4.tar.gz", hash = "sha256:da56b163e5a816e4ad07172f5639287698e09d7f3dc38d18d9726d9c1dbc4cee"}, @@ -155,6 +309,10 @@ chardet = [ {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, ] +colorama = [ + {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, + {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, +] decorator = [ {file = "decorator-4.4.2-py2.py3-none-any.whl", hash = "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760"}, {file = "decorator-4.4.2.tar.gz", hash = "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"}, @@ -163,10 +321,42 @@ idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, ] +importlib-metadata = [ + {file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"}, + {file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"}, +] +iniconfig = [ + {file = "iniconfig-1.0.1-py3-none-any.whl", hash = "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437"}, + {file = "iniconfig-1.0.1.tar.gz", hash = "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69"}, +] "mastodon.py" = [ {file = "Mastodon.py-1.5.1-py2.py3-none-any.whl", hash = "sha256:cc454cac0ed1ae4f105f7399ea53f5b31a1be5075d1882f47162d2e78a9e4064"}, {file = "Mastodon.py-1.5.1.tar.gz", hash = "sha256:2afddbad8b5d7326fcc8a8f8c62bfe956e34627f516b06c6694fc8c8fedc33ee"}, ] +more-itertools = [ + {file = "more-itertools-8.5.0.tar.gz", hash = "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20"}, + {file = "more_itertools-8.5.0-py3-none-any.whl", hash = "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c"}, +] +packaging = [ + {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, + {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, +] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] +py = [ + {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, + {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, +] +pyparsing = [ + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, +] +pytest = [ + {file = "pytest-6.0.1-py3-none-any.whl", hash = "sha256:8b6007800c53fdacd5a5c192203f4e531eb2a1540ad9c752e052ec0f7143dbad"}, + {file = "pytest-6.0.1.tar.gz", hash = "sha256:85228d75db9f45e06e57ef9bf4429267f81ac7c0d742cc9ed63d09886a9fe6f4"}, +] python-dateutil = [ {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, @@ -200,7 +390,15 @@ six = [ {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, ] +toml = [ + {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, + {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, +] urllib3 = [ {file = "urllib3-1.25.10-py2.py3-none-any.whl", hash = "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"}, {file = "urllib3-1.25.10.tar.gz", hash = "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a"}, ] +zipp = [ + {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, + {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, +] diff --git a/pyproject.toml b/pyproject.toml index 2845ffa..3c1bf08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ephemetoot" -version = "3.0.0-alpha.0" +version = "3.0.0-alpha.1" description = "A command line tool to delete your old toots" authors = ["Hugh Rundle "] license = "GPL-3.0-or-later" @@ -24,7 +24,7 @@ requests = "^2.22.0" pyyaml = "^5.0" [tool.poetry.dev-dependencies] -# TODO - tests! +pytest = "^6" [tool.poetry.scripts] ephemetoot = 'ephemetoot.console:main' From f6542f05295b9c4a217d71010c602d7ed2fe9d90 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 30 Aug 2020 14:33:36 +1000 Subject: [PATCH 06/20] refactor print statements --- ephemetoot/ephemetoot.py | 173 ++++++++++++++++----------------------- 1 file changed, 72 insertions(+), 101 deletions(-) diff --git a/ephemetoot/ephemetoot.py b/ephemetoot/ephemetoot.py index 9e87366..a701041 100644 --- a/ephemetoot/ephemetoot.py +++ b/ephemetoot/ephemetoot.py @@ -134,16 +134,17 @@ def version(vnum): ''' Prints current and latest version numbers to console. ''' + try: latest = requests.get( "https://api.github.com/repos/hughrun/ephemetoot/releases/latest" ) res = latest.json() - latest_version = res["name"] + latest_version = res["tag_name"] print("\nephemetoot ==> ๐Ÿฅณ ==> ๐Ÿงผ ==> ๐Ÿ˜‡") print("-------------------------------") - print("Using: \033[92mVersion " + vnum + "\033[0m") - print("Latest: \033[92m" + latest_version + "\033[0m") + print("You are using release: \033[92mv", vnum, "\033[0m", sep="") + print("The latest release is: \033[92m" + latest_version + "\033[0m") print( "To upgrade to the most recent version run \033[92mpip3 install --update ephemetoot\033[0m" ) @@ -204,6 +205,26 @@ def schedule(options): except Exception as e: print("๐Ÿ™ Scheduling failed.") +# TODO: move the json function to here and understand what it does + +def archive_toot(): + pass + # TODO: move all the archiving logic and definitions to here + +def tooted_time(toot): + # TODO: return a string with the toot created time + pass + +def datestamp_now(): + return str( + datetime.now(timezone.utc).strftime( + "%a %d %b %Y %H:%M:%S %z" + ) + ) + +# TODO: move this out of checkToots and pass through all needed arg +# def checkBatch(): +# pass def checkToots(config, options, retry_count=0): @@ -261,88 +282,65 @@ def checkToots(config, options, retry_count=0): try: if keep_pinned and hasattr(toot, "pinned") and toot.pinned: if not (options.hide_skipped or options.quiet): - if options.datestamp: - print( - str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ), - end=" : ", - ) - print("๐Ÿ“Œ skipping pinned toot - " + str(toot.id)) + if options.datestamp: + print(datestamp_now(),end=" : ") + + print("๐Ÿ“Œ skipping pinned toot -", str(toot.id)) + elif toot.id in toots_to_keep: if not (options.hide_skipped or options.quiet): - if options.datestamp: - print( - str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ), - end=" : ", - ) - print("๐Ÿ’พ skipping saved toot - " + str(toot.id)) + if options.datestamp: + print(datestamp_now(),end=" : ") + + print("๐Ÿ’พ skipping saved toot -", str(toot.id)) + elif toot.visibility in visibility_to_keep: if not (options.hide_skipped or options.quiet): + if options.datestamp: - print( - str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ), - end=" : ", - ) + print(datestamp_now(), end=" : ") print( - "๐Ÿ‘€ skipping " - + toot.visibility - + " toot - " - + str(toot.id) + "๐Ÿ‘€ skipping", + toot.visibility, + "toot -", + str(toot.id) ) + elif len(hashtags_to_keep.intersection(toot_tags)) > 0: if not (options.hide_skipped or options.quiet): - if options.datestamp: - print( - str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ), - end=" : ", - ) - print("#๏ธโƒฃ skipping toot with hashtag - " + str(toot.id)) + if options.datestamp: + print(datestamp_now(), end=" : ") + + print( + "#๏ธโƒฃ skipping toot with hashtag -", + str(toot.id) + ) + elif cutoff_date > toot.created_at: if hasattr(toot, "reblog") and toot.reblog: if not options.quiet: if options.datestamp: - print( - str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ), - end=" : ", - ) + print(datestamp_now(), end=" : ") print( - "๐Ÿ‘Ž unboosting toot " - + str(toot.id) - + " boosted " - + toot.created_at.strftime("%d %b %Y") + "๐Ÿ‘Ž unboosting toot", + str(toot.id), + "boosted", + toot.created_at.strftime("%d %b %Y") ) + deleted_count += 1 # unreblog the original toot (their toot), not the toot created by boosting (your toot) if not options.test: if mastodon.ratelimit_remaining == 0: + if not options.quiet: - print( - "Rate limit reached. Waiting for a rate limit reset" - ) + print("Rate limit reached. Waiting for a rate limit reset") + # check for --archive-deleted if ( options.archive_deleted @@ -361,21 +359,14 @@ def checkToots(config, options, retry_count=0): else: if not options.quiet: if options.datestamp: - print( - str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ), - end=" : ", - ) + print(datestamp_now(), end=" : ") print( - "โŒ deleting toot " - + str(toot.id) - + " tooted " - + toot.created_at.strftime("%d %b %Y") + "โŒ deleting toot", + str(toot.id), "tooted", + toot.created_at.strftime("%d %b %Y") ) + deleted_count += 1 time.sleep( 2 @@ -390,15 +381,11 @@ def checkToots(config, options, retry_count=0): diff = mastodon.ratelimit_reset - now print( - "\nRate limit reached at " - + str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ) - + " - next reset due in " - + str(format(diff / 60, ".0f")) - + " minutes.\n" + "\nRate limit reached at", + datestamp_now(), + "- next reset due in", + str(format(diff / 60, ".0f")), + "minutes.\n" ) # check for --archive-deleted if ( @@ -424,11 +411,7 @@ def checkToots(config, options, retry_count=0): print( "\nRate limit reached at " - + str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ) + + datestamp_now() + " - waiting for next reset due in " + str(format(diff / 60, ".0f")) + " minutes.\n" @@ -447,11 +430,7 @@ def checkToots(config, options, retry_count=0): "Attempt " + str(attempts) + " at " - + str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ) + + datestamp_now() ) mastodon.status_delete(toot) time.sleep( @@ -511,11 +490,7 @@ def checkToots(config, options, retry_count=0): if options.datestamp: print( "\n\n" - + str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ), + + datestamp_now(), end=" : ", ) @@ -528,11 +503,7 @@ def checkToots(config, options, retry_count=0): if options.datestamp: print( "\n\n" - + str( - datetime.now(timezone.utc).strftime( - "%a %d %b %Y %H:%M:%S %z" - ) - ), + + datestamp_now(), end=" : ", ) From 8c973a0b3ee6e0aaea2b09e2d3695d379413df9e Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 30 Aug 2020 16:03:27 +1000 Subject: [PATCH 07/20] improve error handling in console.py --- ephemetoot/console.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/ephemetoot/console.py b/ephemetoot/console.py index 3f626e2..3b038cf 100644 --- a/ephemetoot/console.py +++ b/ephemetoot/console.py @@ -111,15 +111,19 @@ options = parser.parse_args() if options.config[0] == "~": config_file = os.path.expanduser(options.config) elif options.config[0] == "/": - config_file = options.config + # make sure user isn't passing in something dodgy + if os.path.exists(options.config): + config_file = options.config + else: + config_file = "" else: config_file = os.path.join(os.getcwd(), options.config) def main(): - ''' - Call ephemetoot.checkToots() on each user in the config file, with options set via flags from command line. - ''' + ''' + Call ephemetoot.checkToots() on each user in the config file, with options set via flags from command line. + ''' try: if options.init: @@ -148,8 +152,14 @@ def main(): func.checkToots(user, options) except FileNotFoundError as err: - print("๐Ÿ•ต๏ธ Missing config file") - print("Run \033[92mephemetoot --init\033[0m to create a new one\n") + + if err.filename == config_file: + print("๐Ÿ•ต๏ธ Missing config file") + print("Run \033[92mephemetoot --init\033[0m to create a new one\n") + + else: + print("\n๐Ÿคทโ€โ™‚๏ธ The archive directory in your config file does not exist") + print("Create the directory or correct your config before trying again\n") if __name__ == "__main__": From 50d04c27db350113335c420e92e0c5d203e74ea8 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sun, 30 Aug 2020 18:57:25 +1000 Subject: [PATCH 08/20] move archiving and date printing functions into global scope --- ephemetoot/ephemetoot.py | 78 +++++++++++++++++----------------------- 1 file changed, 32 insertions(+), 46 deletions(-) diff --git a/ephemetoot/ephemetoot.py b/ephemetoot/ephemetoot.py index a701041..446d744 100644 --- a/ephemetoot/ephemetoot.py +++ b/ephemetoot/ephemetoot.py @@ -205,15 +205,30 @@ def schedule(options): except Exception as e: print("๐Ÿ™ Scheduling failed.") -# TODO: move the json function to here and understand what it does +def archive_toot(config, toot): + # define archive path + if config["archive"][0] == "~": + archive_path = os.path.expanduser(config["archive"]) + elif config["archive"][0] == "/": + archive_path = config["archive"] + else: + archive_path = os.path.join(os.getcwd(), config["archive"]) + if archive_path[-1] != "/": + archive_path += "/" -def archive_toot(): - pass - # TODO: move all the archiving logic and definitions to here + filename = os.path.join(archive_path, str(toot["id"]) + ".json") -def tooted_time(toot): - # TODO: return a string with the toot created time - pass + # write to file + with open(filename, "w") as f: + f.write(json.dumps(toot, indent=4, default=jsondefault)) + f.close() + +def jsondefault(obj): + if isinstance(obj, (date, datetime)): + return obj.isoformat() + +def tooted_date(toot): + return toot.created_at.strftime("%d %b %Y") def datestamp_now(): return str( @@ -250,31 +265,14 @@ def checkToots(config, options, retry_count=0): + config["base_url"] ) - def jsondefault(obj): - if isinstance(obj, (date, datetime)): - return obj.isoformat() - def checkBatch(timeline, deleted_count=0): for toot in timeline: + # TODO: move all this into a new testable function process_toot() if "id" in toot and "archive" in config: - # define archive path - if config["archive"][0] == "~": - archive_path = os.path.expanduser(config["archive"]) - elif config["archive"][0] == "/": - archive_path = config["archive"] - else: - archive_path = os.path.join(os.getcwd(), config["archive"]) - if archive_path[-1] != "/": - archive_path += "/" - - filename = os.path.join(archive_path, str(toot["id"]) + ".json") - if not options.archive_deleted: # write toot to archive - with open(filename, "w") as f: - f.write(json.dumps(toot, indent=4, default=jsondefault)) - f.close() + archive_toot(config, toot) toot_tags = set() for tag in toot.tags: @@ -330,7 +328,7 @@ def checkToots(config, options, retry_count=0): "๐Ÿ‘Ž unboosting toot", str(toot.id), "boosted", - toot.created_at.strftime("%d %b %Y") + tooted_date(toot) ) deleted_count += 1 @@ -348,13 +346,7 @@ def checkToots(config, options, retry_count=0): and "archive" in config ): # write toot to archive - with open(filename, "w") as f: - f.write( - json.dumps( - toot, indent=4, default=jsondefault - ) - ) - f.close() + archive_toot(config, toot) mastodon.status_unreblog(toot.reblog) else: if not options.quiet: @@ -364,7 +356,7 @@ def checkToots(config, options, retry_count=0): print( "โŒ deleting toot", str(toot.id), "tooted", - toot.created_at.strftime("%d %b %Y") + tooted_date(toot) ) deleted_count += 1 @@ -394,13 +386,7 @@ def checkToots(config, options, retry_count=0): and "archive" in config ): # write toot to archive - with open(filename, "w") as f: - f.write( - json.dumps( - toot, indent=4, default=jsondefault - ) - ) - f.close() + archive_toot(config, toot) mastodon.status_delete(toot) @@ -553,12 +539,12 @@ def checkToots(config, options, retry_count=0): except MastodonAPIError as e: if e.args[1] == 401: print( - "\n๐Ÿ™… User and/or access token does not exist or has been deleted (401)" + "\n๐Ÿ™… User and/or access token does not exist or has been deleted (401)\n" ) elif e.args[1] == 404: - print("\n๐Ÿ”ญ Can't find that server (404)") + print("\n๐Ÿ”ญ Can't find that server (404)\n") else: - print("\n๐Ÿ˜• Server has returned an error (5xx)") + print("\n๐Ÿ˜• Server has returned an error (5xx)\n") except MastodonNetworkError: if retry_count == 0: @@ -570,4 +556,4 @@ def checkToots(config, options, retry_count=0): print("Attempt " + str(retry_count + 1)) checkToots(config, options, retry_count) else: - print("Gave up waiting for network") + print("Gave up waiting for network\n") From ffa50dd44831591ec079c2e4baf994085a6d0164 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Mon, 31 Aug 2020 09:53:09 +1000 Subject: [PATCH 09/20] use os.path.join in plist creation This is more correct and also makes testing easier --- ephemetoot/ephemetoot.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ephemetoot/ephemetoot.py b/ephemetoot/ephemetoot.py index 446d744..78ccef6 100644 --- a/ephemetoot/ephemetoot.py +++ b/ephemetoot/ephemetoot.py @@ -178,8 +178,10 @@ def schedule(options): # write out file directly to ~/Library/LaunchAgents f = open( - os.path.expanduser("~/Library/LaunchAgents/") - + "ephemetoot.scheduler.plist", + os.path.join( + os.path.expanduser("~/Library/LaunchAgents"), + "ephemetoot.scheduler.plist" + ), mode="w", ) for line in lines: @@ -203,7 +205,7 @@ def schedule(options): ) print("โฐ Scheduled!") except Exception as e: - print("๐Ÿ™ Scheduling failed.") + print("๐Ÿ™ Scheduling failed.", e) def archive_toot(config, toot): # define archive path @@ -241,6 +243,7 @@ def datestamp_now(): # def checkBatch(): # pass +# TODO: rename to check_toots def checkToots(config, options, retry_count=0): ''' @@ -265,6 +268,7 @@ def checkToots(config, options, retry_count=0): + config["base_url"] ) + # TODO: rename this to check_batch def checkBatch(timeline, deleted_count=0): for toot in timeline: # TODO: move all this into a new testable function process_toot() From 0a01f18e449e0e639face2cafd6de6fdb33934a2 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Mon, 31 Aug 2020 14:28:08 +1000 Subject: [PATCH 10/20] refactor init() This moves the input() statements into four separate functions taking values. The main reason for doing so was to allow for unit testing, however it also makes the init() function a little clearer. Also, potentially, these input functions can now be used by other functions in future. --- ephemetoot/ephemetoot.py | 169 +++++++++++++++++---------------------- 1 file changed, 74 insertions(+), 95 deletions(-) diff --git a/ephemetoot/ephemetoot.py b/ephemetoot/ephemetoot.py index 78ccef6..7fe2e7a 100644 --- a/ephemetoot/ephemetoot.py +++ b/ephemetoot/ephemetoot.py @@ -19,116 +19,95 @@ import requests # local from ephemetoot import plist +def compulsory_input(tags, name, example): + value = "" + while len(value) < 1: + if example: + value = input(tags[0] + name + tags[1] + example + tags[2]) + else: + value = input(tags[0] + name + tags[2]) + + return value + +def digit_input(tags, name, example): + + value = "" + while value.isdigit() == False: + if example: + value = input(tags[0] + name + tags[1] + example + tags[2]) + else: + value = input(tags[0] + name + tags[2]) + + return value + +def yes_no_input(tags, name): + value = "" + while value not in ["y", "n"]: + value = input( + tags[0] + name + tags[1] + "(y or n):" + tags[2] + ) + return_val = "true" if value == "y" else "false" + return return_val + +def optional_input(tags, name, example): + value = input(tags[0] + name + tags[1] + example + tags[2]) + return value + def init(): - ''' Creates a config.yaml file in the current directory, based on user input. ''' + try: - init_start = "\033[96m" - init_end = "\033[0m" - init_eg = "\033[2m" + # text colour markers (beginning, example, end) + tags = ("\033[96m", "\033[2m", "\033[0m") - conf_token = "" - while len(conf_token) < 1: - conf_token = input(init_start + "Access token: " + init_end) + conf_token = compulsory_input(tags, "Access token: ", None) + conf_user = compulsory_input(tags, "Username", "(without the '@' - e.g. alice):") + conf_url = compulsory_input(tags, "Base URL", "(e.g. example.social):") + conf_days = digit_input(tags, "Days to keep", "(default 365):") + conf_pinned = yes_no_input(tags, "Keep pinned toots?") + conf_keep_toots = optional_input(tags, "Toots to keep", "(optional list of IDs separated by commas):") + conf_keep_hashtags = optional_input(tags, "Hashtags to keep", "(optional list separated by commas):") + conf_keep_visibility = optional_input(tags, "Visibility to keep", "(optional list separated by commas):") + conf_archive = optional_input(tags, "Archive path", "(optional filepath for archive):") - conf_user = "" - while len(conf_user) < 1: - conf_user = input( - init_start - + "Username" - + init_eg - + "(without the '@' - e.g. alice):" - + init_end - ) + # write out the config file + with open("config.yaml", "w") as configfile: - conf_url = "" - while len(conf_url) < 1: - conf_url = input( - init_start + "Base URL" + init_eg + "(e.g. example.social):" + init_end - ) + configfile.write("-") + configfile.write("\n access_token: " + conf_token) + configfile.write("\n username: " + conf_user) + configfile.write("\n base_url: " + conf_url) + configfile.write("\n days_to_keep: " + conf_days) + configfile.write("\n keep_pinned: " + conf_pinned) - conf_days = "" - while conf_days.isdigit() == False: - conf_days = input( - init_start + "Days to keep" + init_eg + "(default 365):" + init_end - ) + if len(conf_keep_toots) > 0: + keep_list = conf_keep_toots.split(",") + configfile.write("\n toots_to_keep:") + for toot in keep_list: + configfile.write("\n - " + toot.strip()) - conf_keep_pinned = "" - while conf_keep_pinned not in ["y", "n"]: - conf_keep_pinned = input( - init_start + "Keep pinned toots?" + init_eg + "(y or n):" + init_end - ) + if len(conf_keep_hashtags) > 0: + tag_list = conf_keep_hashtags.split(",") + configfile.write("\n hashtags_to_keep:") + for tag in tag_list: + configfile.write("\n - " + tag.strip()) - conf_pinned = "true" if conf_keep_pinned == "y" else "false" + if len(conf_keep_visibility) > 0: + viz_list = conf_keep_visibility.split(",") + configfile.write("\n visibility_to_keep:") + for mode in viz_list: + configfile.write("\n - " + mode.strip()) - conf_keep_toots = input( - init_start - + "Toots to keep" - + init_eg - + " (optional list of IDs separated by commas):" - + init_end - ) + if len(conf_archive) > 0: + configfile.write("\n archive: " + conf_archive) - conf_keep_hashtags = input( - init_start - + "Hashtags to keep" - + init_eg - + " (optional list separated by commas):" - + init_end - ) - - conf_keep_visibility = input( - init_start - + "Visibility to keep" - + init_eg - + " (optional list separated by commas):" - + init_end - ) - - conf_archive = input( - init_start - + "Archive path" - + init_eg - + " (optional filepath for archive):" - + init_end - ) - - # write out the config file - with open("config.yaml", "w") as configfile: - - configfile.write("-") - configfile.write("\n access_token: " + conf_token) - configfile.write("\n username: " + conf_user) - configfile.write("\n base_url: " + conf_url) - configfile.write("\n days_to_keep: " + conf_days) - configfile.write("\n keep_pinned: " + conf_pinned) - - if len(conf_keep_toots) > 0: - keep_list = conf_keep_toots.split(",") - configfile.write("\n toots_to_keep:") - for toot in keep_list: - configfile.write("\n - " + toot.strip()) - - if len(conf_keep_hashtags) > 0: - tag_list = conf_keep_hashtags.split(",") - configfile.write("\n hashtags_to_keep:") - for tag in tag_list: - configfile.write("\n - " + tag.strip()) - - if len(conf_keep_visibility) > 0: - viz_list = conf_keep_visibility.split(",") - configfile.write("\n visibility_to_keep:") - for mode in viz_list: - configfile.write("\n - " + mode.strip()) - - if len(conf_archive) > 0: - configfile.write("\n archive: " + conf_archive) - - configfile.close() + configfile.close() + except Exception as e: + print(e) def version(vnum): ''' From 43a792e44d18aab81186a94ceda0660ac334e127 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Mon, 31 Aug 2020 15:10:18 +1000 Subject: [PATCH 11/20] move check_batch to top level function --- ephemetoot/console.py | 4 +- ephemetoot/ephemetoot.py | 462 +++++++++++++++++++-------------------- 2 files changed, 228 insertions(+), 238 deletions(-) diff --git a/ephemetoot/console.py b/ephemetoot/console.py index 3b038cf..69fc594 100644 --- a/ephemetoot/console.py +++ b/ephemetoot/console.py @@ -122,7 +122,7 @@ else: def main(): ''' - Call ephemetoot.checkToots() on each user in the config file, with options set via flags from command line. + Call ephemetoot.check_toots() on each user in the config file, with options set via flags from command line. ''' try: @@ -149,7 +149,7 @@ def main(): with open(config_file) as config: for accounts in yaml.safe_load_all(config): for user in accounts: - func.checkToots(user, options) + func.check_toots(user, options) except FileNotFoundError as err: diff --git a/ephemetoot/ephemetoot.py b/ephemetoot/ephemetoot.py index 7fe2e7a..ca99bcb 100644 --- a/ephemetoot/ephemetoot.py +++ b/ephemetoot/ephemetoot.py @@ -218,17 +218,10 @@ def datestamp_now(): ) ) -# TODO: move this out of checkToots and pass through all needed arg -# def checkBatch(): -# pass - -# TODO: rename to check_toots -def checkToots(config, options, retry_count=0): - - ''' - The main function, uses the Mastodon API to check all toots in the user timeline, and delete any that do not meet any of the exclusion criteria from the config file. - ''' - +def check_batch(config, options, mastodon, user_id, timeline, deleted_count=0): + """ + Check a batch of up to 40 toots. This is usually triggered by check_toots, and then recursively calls itself until all toots within the time period specified have been checked. + """ keep_pinned = "keep_pinned" in config and config["keep_pinned"] toots_to_keep = config["toots_to_keep"] if "toots_to_keep" in config else [] visibility_to_keep = ( @@ -238,256 +231,256 @@ def checkToots(config, options, retry_count=0): set(config["hashtags_to_keep"]) if "hashtags_to_keep" in config else set() ) days_to_keep = config["days_to_keep"] if "days_to_keep" in config else 365 + cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep) - try: - print( - "Fetching account details for @" - + config["username"] - + "@" - + config["base_url"] - ) + for toot in timeline: + # TODO: move all this into a new testable function process_toot() + if "id" in toot and "archive" in config: - # TODO: rename this to check_batch - def checkBatch(timeline, deleted_count=0): - for toot in timeline: - # TODO: move all this into a new testable function process_toot() - if "id" in toot and "archive" in config: + if not options.archive_deleted: + # write toot to archive + archive_toot(config, toot) - if not options.archive_deleted: - # write toot to archive - archive_toot(config, toot) + toot_tags = set() + for tag in toot.tags: + toot_tags.add(tag.name) + try: + if keep_pinned and hasattr(toot, "pinned") and toot.pinned: + if not (options.hide_skipped or options.quiet): - toot_tags = set() - for tag in toot.tags: - toot_tags.add(tag.name) - try: - if keep_pinned and hasattr(toot, "pinned") and toot.pinned: - if not (options.hide_skipped or options.quiet): + if options.datestamp: + print(datestamp_now(),end=" : ") - if options.datestamp: - print(datestamp_now(),end=" : ") + print("๐Ÿ“Œ skipping pinned toot -", str(toot.id)) - print("๐Ÿ“Œ skipping pinned toot -", str(toot.id)) + elif toot.id in toots_to_keep: + if not (options.hide_skipped or options.quiet): - elif toot.id in toots_to_keep: - if not (options.hide_skipped or options.quiet): + if options.datestamp: + print(datestamp_now(),end=" : ") - if options.datestamp: - print(datestamp_now(),end=" : ") + print("๐Ÿ’พ skipping saved toot -", str(toot.id)) - print("๐Ÿ’พ skipping saved toot -", str(toot.id)) + elif toot.visibility in visibility_to_keep: + if not (options.hide_skipped or options.quiet): - elif toot.visibility in visibility_to_keep: - if not (options.hide_skipped or options.quiet): - - if options.datestamp: - print(datestamp_now(), end=" : ") - - print( - "๐Ÿ‘€ skipping", - toot.visibility, - "toot -", - str(toot.id) - ) - - elif len(hashtags_to_keep.intersection(toot_tags)) > 0: - if not (options.hide_skipped or options.quiet): - - if options.datestamp: - print(datestamp_now(), end=" : ") - - print( - "#๏ธโƒฃ skipping toot with hashtag -", - str(toot.id) - ) - - elif cutoff_date > toot.created_at: - if hasattr(toot, "reblog") and toot.reblog: - if not options.quiet: - if options.datestamp: - print(datestamp_now(), end=" : ") - - print( - "๐Ÿ‘Ž unboosting toot", - str(toot.id), - "boosted", - tooted_date(toot) - ) - - deleted_count += 1 - # unreblog the original toot (their toot), not the toot created by boosting (your toot) - if not options.test: - if mastodon.ratelimit_remaining == 0: - - if not options.quiet: - print("Rate limit reached. Waiting for a rate limit reset") - - # check for --archive-deleted - if ( - options.archive_deleted - and "id" in toot - and "archive" in config - ): - # write toot to archive - archive_toot(config, toot) - mastodon.status_unreblog(toot.reblog) - else: - if not options.quiet: - if options.datestamp: - print(datestamp_now(), end=" : ") - - print( - "โŒ deleting toot", - str(toot.id), "tooted", - tooted_date(toot) - ) - - deleted_count += 1 - time.sleep( - 2 - ) # wait 2 secs between deletes to be a bit nicer to the server - if not options.test: - if ( - mastodon.ratelimit_remaining == 0 - and not options.quiet - ): - - now = time.time() - diff = mastodon.ratelimit_reset - now - - print( - "\nRate limit reached at", - datestamp_now(), - "- next reset due in", - str(format(diff / 60, ".0f")), - "minutes.\n" - ) - # check for --archive-deleted - if ( - options.archive_deleted - and "id" in toot - and "archive" in config - ): - # write toot to archive - archive_toot(config, toot) - - mastodon.status_delete(toot) - - except MastodonRatelimitError: - - now = time.time() - diff = mastodon.ratelimit_reset - now + if options.datestamp: + print(datestamp_now(), end=" : ") print( - "\nRate limit reached at " - + datestamp_now() - + " - waiting for next reset due in " - + str(format(diff / 60, ".0f")) - + " minutes.\n" + "๐Ÿ‘€ skipping", + toot.visibility, + "toot -", + str(toot.id) ) - time.sleep(diff + 1) # wait for rate limit to reset + elif len(hashtags_to_keep.intersection(toot_tags)) > 0: + if not (options.hide_skipped or options.quiet): - except MastodonError as e: - - def retry_on_error(attempts): - - if attempts < 6: - try: - if not options.quiet: - print( - "Attempt " - + str(attempts) - + " at " - + datestamp_now() - ) - mastodon.status_delete(toot) - time.sleep( - 2 - ) # wait 2 secs between deletes to be a bit nicer to the server - except: - attempts += 1 - time.sleep(60 * options.retry_mins) - retry_on_error(attempts) - else: - raise TimeoutError("Gave up after 5 attempts") + if options.datestamp: + print(datestamp_now(), end=" : ") print( - "๐Ÿ›‘ ERROR deleting toot - " - + str(toot.id) - + " - " - + str(e.args[0]) - + " - " - + str(e.args[3]) + "#๏ธโƒฃ skipping toot with hashtag -", + str(toot.id) ) + + elif cutoff_date > toot.created_at: + if hasattr(toot, "reblog") and toot.reblog: if not options.quiet: + if options.datestamp: + print(datestamp_now(), end=" : ") + print( - "Waiting " - + str(options.retry_mins) - + " minutes before re-trying" + "๐Ÿ‘Ž unboosting toot", + str(toot.id), + "boosted", + tooted_date(toot) ) - time.sleep(60 * options.retry_mins) - retry_on_error(attempts=2) - except KeyboardInterrupt: - print("Operation aborted.") - break - except KeyError as e: - print( - "โš ๏ธ There is an error in your config.yaml file. Please add a value for " - + str(e) - + " and try again." - ) - break - except: - e = sys.exc_info() + deleted_count += 1 + # unreblog the original toot (their toot), not the toot created by boosting (your toot) + if not options.test: + if mastodon.ratelimit_remaining == 0: - print("๐Ÿ›‘ Unknown ERROR deleting toot - " + str(toot.id)) + if not options.quiet: + print("Rate limit reached. Waiting for a rate limit reset") - print("ERROR: " + str(e[0]) + " - " + str(e[1])) - - # the account_statuses call is paginated with a 40-toot limit - # get the id of the last toot to include as 'max_id' in the next API call. - # then keep triggering new rounds of checkToots() until there are no more toots to check - try: - max_id = timeline[-1:][0].id - next_batch = mastodon.account_statuses(user_id, limit=40, max_id=max_id) - if len(next_batch) > 0: - checkBatch(next_batch, deleted_count) + # check for --archive-deleted + if ( + options.archive_deleted + and "id" in toot + and "archive" in config + ): + # write toot to archive + archive_toot(config, toot) + mastodon.status_unreblog(toot.reblog) else: - if options.test: + if not options.quiet: if options.datestamp: - print( - "\n\n" - + datestamp_now(), - end=" : ", - ) + print(datestamp_now(), end=" : ") print( - "Test run completed. This would have removed " - + str(deleted_count) - + " toots." + "โŒ deleting toot", + str(toot.id), "tooted", + tooted_date(toot) ) - else: - if options.datestamp: + + deleted_count += 1 + time.sleep( + 2 + ) # wait 2 secs between deletes to be a bit nicer to the server + if not options.test: + if ( + mastodon.ratelimit_remaining == 0 + and not options.quiet + ): + + now = time.time() + diff = mastodon.ratelimit_reset - now + print( - "\n\n" - + datestamp_now(), - end=" : ", + "\nRate limit reached at", + datestamp_now(), + "- next reset due in", + str(format(diff / 60, ".0f")), + "minutes.\n" ) + # check for --archive-deleted + if ( + options.archive_deleted + and "id" in toot + and "archive" in config + ): + # write toot to archive + archive_toot(config, toot) - print("Removed " + str(deleted_count) + " toots.") + mastodon.status_delete(toot) - if not options.quiet: - print("\n---------------------------------------") - print("๐Ÿฅณ ==> ๐Ÿงผ ==> ๐Ÿ˜‡ User cleanup complete!") - print("---------------------------------------\n") + except MastodonRatelimitError: - except IndexError: - print("No toots found!") + now = time.time() + diff = mastodon.ratelimit_reset - now - except Exception as e: - print("ERROR: " + str(e.args[0])) + print( + "\nRate limit reached at " + + datestamp_now() + + " - waiting for next reset due in " + + str(format(diff / 60, ".0f")) + + " minutes.\n" + ) + + time.sleep(diff + 1) # wait for rate limit to reset + + except MastodonError as e: + + def retry_on_error(attempts): + + if attempts < 6: + try: + if not options.quiet: + print( + "Attempt " + + str(attempts) + + " at " + + datestamp_now() + ) + mastodon.status_delete(toot) + time.sleep( + 2 + ) # wait 2 secs between deletes to be a bit nicer to the server + except: + attempts += 1 + time.sleep(60 * options.retry_mins) + retry_on_error(attempts) + else: + raise TimeoutError("Gave up after 5 attempts") + + print( + "๐Ÿ›‘ ERROR deleting toot - " + + str(toot.id) + + " - " + + str(e.args[0]) + + " - " + + str(e.args[3]) + ) + if not options.quiet: + print( + "Waiting " + + str(options.retry_mins) + + " minutes before re-trying" + ) + time.sleep(60 * options.retry_mins) + retry_on_error(attempts=2) + + except KeyboardInterrupt: + print("Operation aborted.") + break + except KeyError as e: + print( + "โš ๏ธ There is an error in your config.yaml file. Please add a value for " + + str(e) + + " and try again." + ) + break + except: + e = sys.exc_info() + + print("๐Ÿ›‘ Unknown ERROR deleting toot - " + str(toot.id)) + + print("ERROR: " + str(e[0]) + " - " + str(e[1])) + + # the account_statuses call is paginated with a 40-toot limit + # get the id of the last toot to include as 'max_id' in the next API call. + # then keep triggering new rounds of check_toots() until there are no more toots to check + try: + max_id = timeline[-1:][0].id + next_batch = mastodon.account_statuses(user_id, limit=40, max_id=max_id) + if len(next_batch) > 0: + check_batch(config, options, mastodon, user_id, next_batch, deleted_count) + else: + if options.test: + if options.datestamp: + print( + "\n\n" + + datestamp_now(), + end=" : ", + ) + + print( + "Test run completed. This would have removed " + + str(deleted_count) + + " toots." + ) + else: + if options.datestamp: + print( + "\n\n" + + datestamp_now(), + end=" : ", + ) + + print("Removed " + str(deleted_count) + " toots.") + + if not options.quiet: + print("\n---------------------------------------") + print("๐Ÿฅณ ==> ๐Ÿงผ ==> ๐Ÿ˜‡ User cleanup complete!") + print("---------------------------------------\n") + + except IndexError: + print("No toots found!") + + except Exception as e: + print("ERROR: " + str(e.args[0])) + + +def check_toots(config, options, retry_count=0): + + ''' + The main function, uses the Mastodon API to check all toots in the user timeline, and delete any that do not meet any of the exclusion criteria from the config file. + ''' + try: + print("Fetching account details for @", config["username"], "@", config["base_url"], sep="") if options.pace: mastodon = Mastodon( @@ -495,25 +488,22 @@ def checkToots(config, options, retry_count=0): api_base_url="https://" + config["base_url"], ratelimit_method="pace", ) - else: - mastodon = Mastodon( access_token=config["access_token"], api_base_url="https://" + config["base_url"], ratelimit_method="wait", ) - # STARTS HERE - cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep) - user_id = mastodon.account_verify_credentials().id - account = mastodon.account(user_id) - timeline = mastodon.account_statuses(user_id, limit=40) + user_id = mastodon.account_verify_credentials().id # verify user and get ID + account = mastodon.account(user_id) # get the account + timeline = mastodon.account_statuses(user_id, limit=40) # initial batch if not options.quiet: - print("Checking " + str(account.statuses_count) + " toots") + print("Checking", str(account.statuses_count), "toots") - checkBatch(timeline) + # check first batch, check_batch then recursively keeps looping until all toots have been checked + check_batch(config, options, mastodon, user_id, timeline) except KeyError as val: print("\nโš ๏ธ error with in your config.yaml file!") @@ -537,6 +527,6 @@ def checkToots(config, options, retry_count=0): time.sleep(60 * options.retry_mins) retry_count += 1 print("Attempt " + str(retry_count + 1)) - checkToots(config, options, retry_count) + check_toots(config, options, retry_count) else: print("Gave up waiting for network\n") From 977bb06db48c4d40daf2901d63427a763b0f2b72 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Mon, 31 Aug 2020 15:10:58 +1000 Subject: [PATCH 12/20] ignore archive dir used in dev --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d6be2e3..57a2dd4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ config.yaml _assets pypi-readme.md +archive dist \ No newline at end of file From 3012800f5439c509532bb74e2ce7ad68147c62df Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Mon, 31 Aug 2020 16:54:26 +1000 Subject: [PATCH 13/20] create process_toot() function Move all logic for processing each individual toot out of check_batch to a top level function. This is partially to enable easier testing and partially to make the codebase easier to read through and understand. --- ephemetoot/ephemetoot.py | 391 ++++++++++++++++++--------------------- 1 file changed, 178 insertions(+), 213 deletions(-) diff --git a/ephemetoot/ephemetoot.py b/ephemetoot/ephemetoot.py index ca99bcb..7b48f2f 100644 --- a/ephemetoot/ephemetoot.py +++ b/ephemetoot/ephemetoot.py @@ -218,10 +218,8 @@ def datestamp_now(): ) ) -def check_batch(config, options, mastodon, user_id, timeline, deleted_count=0): - """ - Check a batch of up to 40 toots. This is usually triggered by check_toots, and then recursively calls itself until all toots within the time period specified have been checked. - """ +def process_toot(config, options, mastodon, deleted_count, toot): + keep_pinned = "keep_pinned" in config and config["keep_pinned"] toots_to_keep = config["toots_to_keep"] if "toots_to_keep" in config else [] visibility_to_keep = ( @@ -233,207 +231,182 @@ def check_batch(config, options, mastodon, user_id, timeline, deleted_count=0): days_to_keep = config["days_to_keep"] if "days_to_keep" in config else 365 cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep) - for toot in timeline: - # TODO: move all this into a new testable function process_toot() - if "id" in toot and "archive" in config: + if "id" in toot and "archive" in config: - if not options.archive_deleted: - # write toot to archive - archive_toot(config, toot) + if not options.archive_deleted: + # write toot to archive + archive_toot(config, toot) - toot_tags = set() - for tag in toot.tags: - toot_tags.add(tag.name) - try: - if keep_pinned and hasattr(toot, "pinned") and toot.pinned: - if not (options.hide_skipped or options.quiet): - - if options.datestamp: - print(datestamp_now(),end=" : ") - - print("๐Ÿ“Œ skipping pinned toot -", str(toot.id)) - - elif toot.id in toots_to_keep: - if not (options.hide_skipped or options.quiet): - - if options.datestamp: - print(datestamp_now(),end=" : ") - - print("๐Ÿ’พ skipping saved toot -", str(toot.id)) - - elif toot.visibility in visibility_to_keep: - if not (options.hide_skipped or options.quiet): - - if options.datestamp: - print(datestamp_now(), end=" : ") - - print( - "๐Ÿ‘€ skipping", - toot.visibility, - "toot -", - str(toot.id) - ) - - elif len(hashtags_to_keep.intersection(toot_tags)) > 0: - if not (options.hide_skipped or options.quiet): - - if options.datestamp: - print(datestamp_now(), end=" : ") - - print( - "#๏ธโƒฃ skipping toot with hashtag -", - str(toot.id) - ) - - elif cutoff_date > toot.created_at: - if hasattr(toot, "reblog") and toot.reblog: - if not options.quiet: - if options.datestamp: - print(datestamp_now(), end=" : ") - - print( - "๐Ÿ‘Ž unboosting toot", - str(toot.id), - "boosted", - tooted_date(toot) - ) - - deleted_count += 1 - # unreblog the original toot (their toot), not the toot created by boosting (your toot) - if not options.test: - if mastodon.ratelimit_remaining == 0: - - if not options.quiet: - print("Rate limit reached. Waiting for a rate limit reset") - - # check for --archive-deleted - if ( - options.archive_deleted - and "id" in toot - and "archive" in config - ): - # write toot to archive - archive_toot(config, toot) - mastodon.status_unreblog(toot.reblog) - else: - if not options.quiet: - if options.datestamp: - print(datestamp_now(), end=" : ") - - print( - "โŒ deleting toot", - str(toot.id), "tooted", - tooted_date(toot) - ) - - deleted_count += 1 - time.sleep( - 2 - ) # wait 2 secs between deletes to be a bit nicer to the server - if not options.test: - if ( - mastodon.ratelimit_remaining == 0 - and not options.quiet - ): - - now = time.time() - diff = mastodon.ratelimit_reset - now - - print( - "\nRate limit reached at", - datestamp_now(), - "- next reset due in", - str(format(diff / 60, ".0f")), - "minutes.\n" - ) - # check for --archive-deleted - if ( - options.archive_deleted - and "id" in toot - and "archive" in config - ): - # write toot to archive - archive_toot(config, toot) - - mastodon.status_delete(toot) - - except MastodonRatelimitError: - - now = time.time() - diff = mastodon.ratelimit_reset - now - - print( - "\nRate limit reached at " - + datestamp_now() - + " - waiting for next reset due in " - + str(format(diff / 60, ".0f")) - + " minutes.\n" - ) - - time.sleep(diff + 1) # wait for rate limit to reset - - except MastodonError as e: - - def retry_on_error(attempts): - - if attempts < 6: - try: - if not options.quiet: - print( - "Attempt " - + str(attempts) - + " at " - + datestamp_now() - ) - mastodon.status_delete(toot) - time.sleep( - 2 - ) # wait 2 secs between deletes to be a bit nicer to the server - except: - attempts += 1 - time.sleep(60 * options.retry_mins) - retry_on_error(attempts) - else: - raise TimeoutError("Gave up after 5 attempts") - - print( - "๐Ÿ›‘ ERROR deleting toot - " - + str(toot.id) - + " - " - + str(e.args[0]) - + " - " - + str(e.args[3]) - ) - if not options.quiet: - print( - "Waiting " - + str(options.retry_mins) - + " minutes before re-trying" - ) - time.sleep(60 * options.retry_mins) - retry_on_error(attempts=2) - - except KeyboardInterrupt: - print("Operation aborted.") - break - except KeyError as e: - print( - "โš ๏ธ There is an error in your config.yaml file. Please add a value for " - + str(e) - + " and try again." - ) - break - except: - e = sys.exc_info() - - print("๐Ÿ›‘ Unknown ERROR deleting toot - " + str(toot.id)) - - print("ERROR: " + str(e[0]) + " - " + str(e[1])) - - # the account_statuses call is paginated with a 40-toot limit - # get the id of the last toot to include as 'max_id' in the next API call. - # then keep triggering new rounds of check_toots() until there are no more toots to check + toot_tags = set() + for tag in toot.tags: + toot_tags.add(tag.name) try: + if keep_pinned and hasattr(toot, "pinned") and toot.pinned: + if not (options.hide_skipped or options.quiet): + + if options.datestamp: + print(datestamp_now(),end=" : ") + + print("๐Ÿ“Œ skipping pinned toot -", str(toot.id)) + + elif toot.id in toots_to_keep: + if not (options.hide_skipped or options.quiet): + + if options.datestamp: + print(datestamp_now(),end=" : ") + + print("๐Ÿ’พ skipping saved toot -", str(toot.id)) + + elif toot.visibility in visibility_to_keep: + if not (options.hide_skipped or options.quiet): + + if options.datestamp: + print(datestamp_now(), end=" : ") + + print( "๐Ÿ‘€ skipping", toot.visibility, "toot -", str(toot.id) ) + + elif len(hashtags_to_keep.intersection(toot_tags)) > 0: + if not (options.hide_skipped or options.quiet): + + if options.datestamp: + print(datestamp_now(), end=" : ") + + print( "#๏ธโƒฃ skipping toot with hashtag -", str(toot.id) ) + + elif cutoff_date > toot.created_at: + if hasattr(toot, "reblog") and toot.reblog: + if not options.quiet: + if options.datestamp: + print(datestamp_now(), end=" : ") + + print( "๐Ÿ‘Ž unboosting toot", str(toot.id), "boosted", tooted_date(toot) ) + + deleted_count += 1 + # unreblog the original toot (their toot), not the toot created by boosting (your toot) + if not options.test: + if mastodon.ratelimit_remaining == 0: + + if not options.quiet: + print("Rate limit reached. Waiting for a rate limit reset") + + # check for --archive-deleted + if (options.archive_deleted and "id" in toot and "archive" in config): + # write toot to archive + archive_toot(config, toot) + + mastodon.status_unreblog(toot.reblog) + + else: + if not options.quiet: + if options.datestamp: + print(datestamp_now(), end=" : ") + + print( "โŒ deleting toot", str(toot.id), "tooted", tooted_date(toot) ) + + deleted_count += 1 + time.sleep(2) # wait 2 secs between deletes to be a bit nicer to the server + + if not options.test: + + # deal with rate limits + if (mastodon.ratelimit_remaining == 0 and not options.quiet): + + now = time.time() + diff = mastodon.ratelimit_reset - now + + print( + "\nRate limit reached at", + datestamp_now(), + "- next reset due in", + str(format(diff / 60, ".0f")), + "minutes.\n" + ) + + # check for --archive-deleted + if (options.archive_deleted and "id" in toot and "archive" in config): + + archive_toot(config, toot) + + # finally we actually delete the toot + mastodon.status_delete(toot) + + # return the deleted_count back so that it can be tallied within check_batch() + return deleted_count + + except MastodonRatelimitError: + + now = time.time() + diff = mastodon.ratelimit_reset - now + + print( + "\nRate limit reached at " + + datestamp_now() + + " - waiting for next reset due in " + + str(format(diff / 60, ".0f")) + + " minutes.\n" + ) + + time.sleep(diff + 1) # wait for rate limit to reset + + except MastodonError as e: + + def retry_on_error(attempts): + + if attempts < 6: + try: + if not options.quiet: + print("Attempt", str(attempts), "at", datestamp_now() ) + mastodon.status_delete(toot) + time.sleep(2) # wait 2 secs between deletes to be a bit nicer to the server + except: + attempts += 1 + time.sleep(60 * options.retry_mins) + retry_on_error(attempts) + else: + raise TimeoutError("Gave up after 5 attempts") + + print( + "๐Ÿ›‘ ERROR deleting toot - " + + str(toot.id) + + " - " + + str(e.args[0]) + + " - " + + str(e.args[3]) + ) + if not options.quiet: + print("Waiting", str(options.retry_mins), "minutes before re-trying") + time.sleep(60 * options.retry_mins) + retry_on_error(attempts=2) + + except KeyboardInterrupt: + print("Operation aborted.") + + except KeyError as e: + print( + "โš ๏ธ There is an error in your config.yaml file. Please add a value for", + str(e), + "and try again." + ) + + except: + e = sys.exc_info() + print( "๐Ÿ›‘ Unknown ERROR deleting toot -", str(toot.id) ) + print( "ERROR:", str(e[0]),"-", str(e[1]) ) + + +def check_batch(config, options, mastodon, user_id, timeline, deleted_count=0): + """ + Check a batch of up to 40 toots. This is usually triggered by check_toots, and then recursively calls itself until all toots within the time period specified have been checked. + """ + + try: + for toot in timeline: + # process_toot returns the value of the deleted_count so we can keep track here + deleted_count = process_toot(config, options, mastodon, deleted_count, toot) + + # the account_statuses call is paginated with a 40-toot limit + # get the id of the last toot to include as 'max_id' in the next API call. + # then keep triggering new rounds of check_toots() until there are no more toots to check max_id = timeline[-1:][0].id next_batch = mastodon.account_statuses(user_id, limit=40, max_id=max_id) if len(next_batch) > 0: @@ -441,11 +414,7 @@ def check_batch(config, options, mastodon, user_id, timeline, deleted_count=0): else: if options.test: if options.datestamp: - print( - "\n\n" - + datestamp_now(), - end=" : ", - ) + print( "\n\n", datestamp_now(), sep="", end=" : ") print( "Test run completed. This would have removed " @@ -454,11 +423,7 @@ def check_batch(config, options, mastodon, user_id, timeline, deleted_count=0): ) else: if options.datestamp: - print( - "\n\n" - + datestamp_now(), - end=" : ", - ) + print( "\n\n", datestamp_now(), end=" : ") print("Removed " + str(deleted_count) + " toots.") @@ -468,7 +433,7 @@ def check_batch(config, options, mastodon, user_id, timeline, deleted_count=0): print("---------------------------------------\n") except IndexError: - print("No toots found!") + print("No toots found!\n") except Exception as e: print("ERROR: " + str(e.args[0])) From 303f7a4cc0fcf5947338fdd233eba1b973a8a5f6 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sat, 5 Sep 2020 09:20:26 +1000 Subject: [PATCH 14/20] move most print statements to console_print() function Makes code more DRY by moving printing logic into a function. Checks for --quiet and --hide_skipped are now in this function. Checks for --datestamp also in this function. Also makes code easier to test. --- ephemetoot/ephemetoot.py | 107 +++++++++++++++++++++------------------ 1 file changed, 57 insertions(+), 50 deletions(-) diff --git a/ephemetoot/ephemetoot.py b/ephemetoot/ephemetoot.py index 7b48f2f..ca6829d 100644 --- a/ephemetoot/ephemetoot.py +++ b/ephemetoot/ephemetoot.py @@ -218,6 +218,16 @@ def datestamp_now(): ) ) +def console_print(msg, options, skip): + + skip_announcement = True if (options.hide_skipped and skip) else False + if not (skip_announcement or options.quiet): + + if options.datestamp: + msg += datestamp_now() + " : " + + print(msg) + def process_toot(config, options, mastodon, deleted_count, toot): keep_pinned = "keep_pinned" in config and config["keep_pinned"] @@ -242,52 +252,46 @@ def process_toot(config, options, mastodon, deleted_count, toot): toot_tags.add(tag.name) try: if keep_pinned and hasattr(toot, "pinned") and toot.pinned: - if not (options.hide_skipped or options.quiet): - - if options.datestamp: - print(datestamp_now(),end=" : ") - - print("๐Ÿ“Œ skipping pinned toot -", str(toot.id)) + console_print( + "๐Ÿ“Œ skipping pinned toot - " + str(toot.id), + options, + True + ) elif toot.id in toots_to_keep: - if not (options.hide_skipped or options.quiet): - - if options.datestamp: - print(datestamp_now(),end=" : ") - - print("๐Ÿ’พ skipping saved toot -", str(toot.id)) + console_print( + "๐Ÿ’พ skipping saved toot - " + str(toot.id), + options, + True + ) elif toot.visibility in visibility_to_keep: - if not (options.hide_skipped or options.quiet): - - if options.datestamp: - print(datestamp_now(), end=" : ") - - print( "๐Ÿ‘€ skipping", toot.visibility, "toot -", str(toot.id) ) + console_print( + "๐Ÿ‘€ skipping " + toot.visibility + " toot - " + str(toot.id), + options, + True + ) elif len(hashtags_to_keep.intersection(toot_tags)) > 0: - if not (options.hide_skipped or options.quiet): - - if options.datestamp: - print(datestamp_now(), end=" : ") - - print( "#๏ธโƒฃ skipping toot with hashtag -", str(toot.id) ) + console_print( + "#๏ธโƒฃ skipping toot with hashtag - " + str(toot.id), + options, + True + ) elif cutoff_date > toot.created_at: if hasattr(toot, "reblog") and toot.reblog: - if not options.quiet: - if options.datestamp: - print(datestamp_now(), end=" : ") - - print( "๐Ÿ‘Ž unboosting toot", str(toot.id), "boosted", tooted_date(toot) ) + console_print( + "๐Ÿ‘Ž unboosting toot" + str(toot.id) + "boosted" + tooted_date(toot), + options, + False + ) deleted_count += 1 # unreblog the original toot (their toot), not the toot created by boosting (your toot) if not options.test: if mastodon.ratelimit_remaining == 0: - - if not options.quiet: - print("Rate limit reached. Waiting for a rate limit reset") + console_print("Rate limit reached. Waiting for a rate limit reset") # check for --archive-deleted if (options.archive_deleted and "id" in toot and "archive" in config): @@ -297,11 +301,11 @@ def process_toot(config, options, mastodon, deleted_count, toot): mastodon.status_unreblog(toot.reblog) else: - if not options.quiet: - if options.datestamp: - print(datestamp_now(), end=" : ") - - print( "โŒ deleting toot", str(toot.id), "tooted", tooted_date(toot) ) + console_print( + "โŒ deleting toot ", str(toot.id) + " tooted " + tooted_date(toot), + options, + False + ) deleted_count += 1 time.sleep(2) # wait 2 secs between deletes to be a bit nicer to the server @@ -313,7 +317,7 @@ def process_toot(config, options, mastodon, deleted_count, toot): now = time.time() diff = mastodon.ratelimit_reset - now - + # TODO: create a function for rate limit message print( "\nRate limit reached at", datestamp_now(), @@ -350,12 +354,16 @@ def process_toot(config, options, mastodon, deleted_count, toot): except MastodonError as e: + # TODO: this should have a test associated with it def retry_on_error(attempts): if attempts < 6: try: - if not options.quiet: - print("Attempt", str(attempts), "at", datestamp_now() ) + console_print( + "Attempt " + str(attempts) + " at " + datestamp_now(), + options, + False + ) mastodon.status_delete(toot) time.sleep(2) # wait 2 secs between deletes to be a bit nicer to the server except: @@ -373,8 +381,11 @@ def process_toot(config, options, mastodon, deleted_count, toot): + " - " + str(e.args[3]) ) - if not options.quiet: - print("Waiting", str(options.retry_mins), "minutes before re-trying") + console_print( + "Waiting " + str(options.retry_mins) + " minutes before re-trying", + options, + False + ) time.sleep(60 * options.retry_mins) retry_on_error(attempts=2) @@ -417,10 +428,7 @@ def check_batch(config, options, mastodon, user_id, timeline, deleted_count=0): print( "\n\n", datestamp_now(), sep="", end=" : ") print( - "Test run completed. This would have removed " - + str(deleted_count) - + " toots." - ) + "Test run completed. This would have removed", str(deleted_count), "toots.") else: if options.datestamp: print( "\n\n", datestamp_now(), end=" : ") @@ -436,11 +444,9 @@ def check_batch(config, options, mastodon, user_id, timeline, deleted_count=0): print("No toots found!\n") except Exception as e: - print("ERROR: " + str(e.args[0])) - + print("ERROR:", str(e.args[0]), "\n") def check_toots(config, options, retry_count=0): - ''' The main function, uses the Mastodon API to check all toots in the user timeline, and delete any that do not meet any of the exclusion criteria from the config file. ''' @@ -467,7 +473,8 @@ def check_toots(config, options, retry_count=0): if not options.quiet: print("Checking", str(account.statuses_count), "toots") - # check first batch, check_batch then recursively keeps looping until all toots have been checked + # check first batch + # check_batch() then recursively keeps looping until all toots have been checked check_batch(config, options, mastodon, user_id, timeline) except KeyError as val: From 0be13d7f1a0d3702bc0ac4389feeafc94eda24d0 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sat, 5 Sep 2020 11:09:59 +1000 Subject: [PATCH 15/20] put rate limit message in function and fix console_print() - datetime message should print before message, not after - rate limit message moved into function print_rate_limit_message() --- ephemetoot/ephemetoot.py | 48 ++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/ephemetoot/ephemetoot.py b/ephemetoot/ephemetoot.py index ca6829d..633f78a 100644 --- a/ephemetoot/ephemetoot.py +++ b/ephemetoot/ephemetoot.py @@ -224,10 +224,22 @@ def console_print(msg, options, skip): if not (skip_announcement or options.quiet): if options.datestamp: - msg += datestamp_now() + " : " + msg = datestamp_now() + " : " + msg print(msg) +def print_rate_limit_message(mastodon): + now = time.time() + diff = mastodon.ratelimit_reset - now + + print( + "\nRate limit reached at", + datestamp_now(), + "- next reset due in", + str(format(diff / 60, ".0f")), + "minutes.\n" + ) + def process_toot(config, options, mastodon, deleted_count, toot): keep_pinned = "keep_pinned" in config and config["keep_pinned"] @@ -291,7 +303,11 @@ def process_toot(config, options, mastodon, deleted_count, toot): # unreblog the original toot (their toot), not the toot created by boosting (your toot) if not options.test: if mastodon.ratelimit_remaining == 0: - console_print("Rate limit reached. Waiting for a rate limit reset") + console_print( + "Rate limit reached. Waiting for a rate limit reset", + options, + False + ) # check for --archive-deleted if (options.archive_deleted and "id" in toot and "archive" in config): @@ -302,7 +318,7 @@ def process_toot(config, options, mastodon, deleted_count, toot): else: console_print( - "โŒ deleting toot ", str(toot.id) + " tooted " + tooted_date(toot), + "โŒ deleting toot " + str(toot.id) + " tooted " + tooted_date(toot), options, False ) @@ -314,17 +330,7 @@ def process_toot(config, options, mastodon, deleted_count, toot): # deal with rate limits if (mastodon.ratelimit_remaining == 0 and not options.quiet): - - now = time.time() - diff = mastodon.ratelimit_reset - now - # TODO: create a function for rate limit message - print( - "\nRate limit reached at", - datestamp_now(), - "- next reset due in", - str(format(diff / 60, ".0f")), - "minutes.\n" - ) + print_rate_limit_message(mastodon) # check for --archive-deleted if (options.archive_deleted and "id" in toot and "archive" in config): @@ -339,22 +345,12 @@ def process_toot(config, options, mastodon, deleted_count, toot): except MastodonRatelimitError: - now = time.time() - diff = mastodon.ratelimit_reset - now - - print( - "\nRate limit reached at " - + datestamp_now() - + " - waiting for next reset due in " - + str(format(diff / 60, ".0f")) - + " minutes.\n" - ) - + print_rate_limit_message(mastodon) time.sleep(diff + 1) # wait for rate limit to reset except MastodonError as e: - # TODO: this should have a test associated with it + # TODO: this should ideally have a test associated with it def retry_on_error(attempts): if attempts < 6: From 85546870f65c7aab5359dd650572ced6c4ab0fd1 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sat, 5 Sep 2020 12:07:51 +1000 Subject: [PATCH 16/20] move retry_on_error() to top level - moves retry_on_error() to top level function to enable testing - clean up some code formatting --- ephemetoot/ephemetoot.py | 52 +++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/ephemetoot/ephemetoot.py b/ephemetoot/ephemetoot.py index 633f78a..71bd3a2 100644 --- a/ephemetoot/ephemetoot.py +++ b/ephemetoot/ephemetoot.py @@ -229,6 +229,7 @@ def console_print(msg, options, skip): print(msg) def print_rate_limit_message(mastodon): + now = time.time() diff = mastodon.ratelimit_reset - now @@ -240,6 +241,24 @@ def print_rate_limit_message(mastodon): "minutes.\n" ) +# TODO: this should ideally have a test associated with it +def retry_on_error(options, mastodon, toot, attempts): + + if attempts < 6: + try: + console_print( + "Attempt " + str(attempts) + " at " + datestamp_now(), + options, + False + ) + mastodon.status_delete(toot) + except: + attempts += 1 + time.sleep(60 * options.retry_mins) + retry_on_error(options, mastodon, toot, attempts) + else: + raise TimeoutError("Gave up after 5 attempts") + def process_toot(config, options, mastodon, deleted_count, toot): keep_pinned = "keep_pinned" in config and config["keep_pinned"] @@ -262,6 +281,7 @@ def process_toot(config, options, mastodon, deleted_count, toot): toot_tags = set() for tag in toot.tags: toot_tags.add(tag.name) + try: if keep_pinned and hasattr(toot, "pinned") and toot.pinned: console_print( @@ -327,14 +347,12 @@ def process_toot(config, options, mastodon, deleted_count, toot): time.sleep(2) # wait 2 secs between deletes to be a bit nicer to the server if not options.test: - # deal with rate limits if (mastodon.ratelimit_remaining == 0 and not options.quiet): print_rate_limit_message(mastodon) # check for --archive-deleted if (options.archive_deleted and "id" in toot and "archive" in config): - archive_toot(config, toot) # finally we actually delete the toot @@ -350,40 +368,14 @@ def process_toot(config, options, mastodon, deleted_count, toot): except MastodonError as e: - # TODO: this should ideally have a test associated with it - def retry_on_error(attempts): - - if attempts < 6: - try: - console_print( - "Attempt " + str(attempts) + " at " + datestamp_now(), - options, - False - ) - mastodon.status_delete(toot) - time.sleep(2) # wait 2 secs between deletes to be a bit nicer to the server - except: - attempts += 1 - time.sleep(60 * options.retry_mins) - retry_on_error(attempts) - else: - raise TimeoutError("Gave up after 5 attempts") - - print( - "๐Ÿ›‘ ERROR deleting toot - " - + str(toot.id) - + " - " - + str(e.args[0]) - + " - " - + str(e.args[3]) - ) + print( "๐Ÿ›‘ ERROR deleting toot -", str(toot.id), "-", str(e.args[0]), "-", str(e.args[3]) ) console_print( "Waiting " + str(options.retry_mins) + " minutes before re-trying", options, False ) time.sleep(60 * options.retry_mins) - retry_on_error(attempts=2) + retry_on_error(options, mastodon, toot, attempts=2) except KeyboardInterrupt: print("Operation aborted.") From 6ccbd9bdd934888544fa2d9455ea3dc33e53900f Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sat, 5 Sep 2020 17:14:34 +1000 Subject: [PATCH 17/20] standardise toot object references and fix prints - toot references consistently use dot notation - console_print statement with old print() comma use fixed - change the way new lines are created in final message --- ephemetoot/ephemetoot.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/ephemetoot/ephemetoot.py b/ephemetoot/ephemetoot.py index 71bd3a2..a2e5400 100644 --- a/ephemetoot/ephemetoot.py +++ b/ephemetoot/ephemetoot.py @@ -197,7 +197,7 @@ def archive_toot(config, toot): if archive_path[-1] != "/": archive_path += "/" - filename = os.path.join(archive_path, str(toot["id"]) + ".json") + filename = os.path.join(archive_path, str(toot.id) + ".json") # write to file with open(filename, "w") as f: @@ -241,7 +241,6 @@ def print_rate_limit_message(mastodon): "minutes.\n" ) -# TODO: this should ideally have a test associated with it def retry_on_error(options, mastodon, toot, attempts): if attempts < 6: @@ -272,7 +271,7 @@ def process_toot(config, options, mastodon, deleted_count, toot): days_to_keep = config["days_to_keep"] if "days_to_keep" in config else 365 cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep) - if "id" in toot and "archive" in config: + if toot.id and "archive" in config: if not options.archive_deleted: # write toot to archive @@ -314,7 +313,7 @@ def process_toot(config, options, mastodon, deleted_count, toot): elif cutoff_date > toot.created_at: if hasattr(toot, "reblog") and toot.reblog: console_print( - "๐Ÿ‘Ž unboosting toot" + str(toot.id) + "boosted" + tooted_date(toot), + "๐Ÿ‘Ž unboosting toot " + str(toot.id) + " boosted " + tooted_date(toot), options, False ) @@ -413,18 +412,18 @@ def check_batch(config, options, mastodon, user_id, timeline, deleted_count=0): else: if options.test: if options.datestamp: - print( "\n\n", datestamp_now(), sep="", end=" : ") + print( "\n", datestamp_now(), sep="", end=" : ") print( - "Test run completed. This would have removed", str(deleted_count), "toots.") + "Test run completed. This would have removed", str(deleted_count), "toots.\n") else: if options.datestamp: - print( "\n\n", datestamp_now(), end=" : ") + print( "\n", datestamp_now(), end=" : ") - print("Removed " + str(deleted_count) + " toots.") + print("Removed " + str(deleted_count) + " toots.\n") if not options.quiet: - print("\n---------------------------------------") + print("---------------------------------------") print("๐Ÿฅณ ==> ๐Ÿงผ ==> ๐Ÿ˜‡ User cleanup complete!") print("---------------------------------------\n") From aea5c1d0ea77eb72cdc64e450749e72a6ea8249e Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sat, 5 Sep 2020 17:23:04 +1000 Subject: [PATCH 18/20] update docs for v3 Adds more information specific to upgrading from earlier version to v3.x --- docs/index.md | 6 +++++- docs/upgrade.md | 12 ++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/index.md b/docs/index.md index 9e43256..0c25e47 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,11 @@ **ephemetoot** is a Python command line tool for deleting old toots. -These docs apply to Version 3. +These docs apply to `ephemetoot` version 3. + +Note that throughout these docs the `pip` command is referred to as `pip3`. This is to help new Python users on systems running both Python 2 and Python 3, which is currently still common and a frequent source of confusion. On some systems, `pip` will be the appropriate command, as it points to Python 3 environments. + +If you are upgrading from an `ephemetoot` version prior to v3.0.0 please see the [upgrading](./upgrade.md) instructions and note that you need to manually uninstall the old version first. * [Installation](./install.md) * [Options](./options.md) diff --git a/docs/upgrade.md b/docs/upgrade.md index a95853b..b2a3049 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -2,15 +2,15 @@ ## Upgrading -### Note for users upgrading from Version 2 to Version 3 +### Note for users upgrading to Version 3 -To upgrade from Version 2.x to Version 3.x you will need to remove your existing install. +To upgrade from an earlier version to Version 3.x you will need to remove your existing install. 1. save a copy of your `config.yaml` file somewhere safe -2. run `pip uninstall ephemetoot` -3. run `pip install ephemetoot` +2. run `pip3 uninstall ephemetoot` +3. run `pip3 install ephemetoot` 4. check your config file is in the current directory -5. do a test run with `ephemetoot --test` +5. check everythign is working with `ephemetoot --test` or `ephemetoot --version` ### Upgrading with pypi To upgrade to a new version, the easiest way is to use pip to download the latest version from pypi (remembering that for your machine you may need to substitute `pip` for `pip3`): @@ -34,7 +34,7 @@ To upgrade without using git or pypi: * put your config file somewhere safe * download and unzip the zip file into your `ephemetoot` directory over the top of your existing installation * move your config file back in to the ephemetoot directory -* run `pip install .` from within the directory +* run `pip3 install .` from within the directory ## Uninstalling From f430ef1049219c9114b186a3b66eadfd806ce52d Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sat, 5 Sep 2020 17:24:10 +1000 Subject: [PATCH 19/20] add tests adds complete test suite using pytest --- tests/test_ephemetoot.py | 446 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 446 insertions(+) create mode 100644 tests/test_ephemetoot.py diff --git a/tests/test_ephemetoot.py b/tests/test_ephemetoot.py new file mode 100644 index 0000000..4162b23 --- /dev/null +++ b/tests/test_ephemetoot.py @@ -0,0 +1,446 @@ +import datetime +from datetime import timezone +from dateutil.tz import tzutc +import json +import os +import subprocess +import sys + +import pytest +import requests + +from ephemetoot import ephemetoot + + +######################## +# MOCKS # +######################## + +toot_dict = { + 'id': 104136090490756999, + 'created_at': datetime.datetime(2020, 5, 9, 2, 17, 18, 598000, tzinfo=tzutc()), + 'in_reply_to_id': None, + 'in_reply_to_account_id': None, + 'sensitive': False, + 'spoiler_text': '', + 'visibility': 'public', + 'language': 'en', + 'uri': 'https://example.social/users/testbot/statuses/104136090490756503', + 'url': 'https://example.social/@testbot/104136090490756503', + 'replies_count': 0, + 'reblogs_count': 0, + 'favourites_count': 0, + 'favourited': False, + 'reblogged': False, + 'muted': False, + 'bookmarked': False, + 'pinned': True, + 'content': '

hello I am testing

', + 'reblog': None, + 'application': None, + 'account': { + 'id': 16186, + 'username': 'testbot', + 'acct': 'testbot', + 'display_name': 'ephemtoot Testing Bot', + 'locked': True, + 'bot': True, + 'discoverable': False, + 'group': False, + 'created_at': datetime.datetime(2018, 11, 16, 23, 15, 15, 718000, tzinfo=tzutc()), + 'note': '

Liable to explode at any time, handle with care.

', + 'url': 'https://example.social/@testbot', + 'avatar': 'https://example.social/system/accounts/avatars/000/016/186/original/66d11c4191332e7a.png?1542410869', + 'avatar_static': 'https://example.social/system/accounts/avatars/000/016/186/original/66d11c4191332e7a.png?1542410869', + 'header': 'https://example.social/headers/original/header.png', + 'header_static': 'https://example.social/headers/original/header.png', + 'followers_count': 100, + 'following_count': 10, + 'statuses_count': 99, + 'last_status_at': datetime.datetime(2020, 8, 17, 0, 0), + 'emojis': [], + 'fields': [ + { + 'name': 'Fully', + 'value': 'Automated', + 'verified_at': None + }, + { + 'name': 'Luxury', + 'value': 'Communism', + 'verified_at': None + } + ] + }, + 'media_attachments': [], + 'mentions': [], + 'tags': [], + 'emojis': [], + 'card': None, + 'poll': None +} + +# Turn dict into object needed by mastodon.py +# Use this in tests after making any changes +# you need to your dict object +# NOTE: ensure values in the dict object are what you need: +# it can be mutated by any test before your test runs + +def dict2obj(d): + # checking whether object d is a + # instance of class list + if isinstance(d, list): + d = [dict2obj(x) for x in d] + + # if d is not a instance of dict then + # directly object is returned + if not isinstance(d, dict): + return d + + # declaring a class + class C: + pass + + # constructor of the class passed to obj + obj = C() + + for k in d: + obj.__dict__[k] = dict2obj(d[k]) + + return obj + +# here is our toot object - use this in tests +toot = dict2obj(toot_dict) + +# config file after being parsed by yaml.safe_load +config_file = { + 'access_token': 'abcd_1234', + 'username': 'alice', + 'base_url': 'test.social', + 'hashtags_to_keep': ['ephemetoot'], + 'days_to_keep': 14, + 'keep_pinned': True, + 'toots_to_keep': [103996285277439262, 103976473612749097, 103877521458738491], + 'visibility_to_keep': [None], + 'archive': 'archive' +} + +# mock GitHub API call for the version number +class MockGitHub: + @staticmethod + def json(): + return {"tag_name": "vLATEST_VERSION"} + +# mock Mastodon +class Mocktodon: + def __init__(self): + return None + + def status_delete(self, t=toot): + return None + + def status_unreblog(self, t=toot): + return None + + def ratelimit_remaining(self): + return 100 + + def account_statuses(self, user_id=None, limit=None, max_id=None): + # create 10 statuses + # the first 2 will be checked in the first batch (in production it would be 40) + user_toots = [] + def make_toot(i=1): + if i < 11: + keys = ("id", "created_at", "reblog", "tags", "visibility") + vals = ( + i, + datetime.datetime(2018, 11, i, 23, 15, 15, 718000, tzinfo=tzutc()), + False, + [], + "public" + ) + user_toot = dict(zip(keys, vals)) + user_toots.append(user_toot) + total = i + 1 + make_toot(total) + + user_toots.sort(reverse=True) + make_toot(1) # make the batch of toots + # ignore user_id + # filter for toots with id smaller than max_id + this_batch = [] + # use dict_to_obj to make a toot for each toot in the obj then a list from that + this_batch = [ dict2obj(t) for t in user_toots if t["id"] > max_id][:limit] + return this_batch + +# mock argparse objects (options) +class Namespace: + def __init__(self, archive_deleted=False, datestamp=False, hide_skipped=False, retry_mins=1, schedule=False, test=False, time=False, quiet=False): + self.archive_deleted = archive_deleted + self.datestamp = datestamp + self.schedule = schedule + self.time = time + self.test = test + self.hide_skipped = hide_skipped + self.quiet = quiet + self.retry_mins = retry_mins + +@pytest.fixture +def mock_github_response(monkeypatch): + + def mock_get(*args, **kwargs): + return MockGitHub() + + monkeypatch.setattr(requests, "get", mock_get) + +######################## +# TESTS # +######################## + +def test_archive_toot(tmpdir): + p = tmpdir.mkdir("archive") + config_file['archive'] = str(p) # make archive directory a temp test dir + + ephemetoot.archive_toot(config_file, toot) + + file_exists = os.path.exists(p + "/104136090490756999.json") + assert file_exists + +def test_check_batch(capfd, monkeypatch): + config = config_file + options = Namespace(archive_deleted=False) + mastodon = Mocktodon() + user_id = "test_user_id" + # limit to 2 so check_batch calls itself for the last 8 toots + timeline = mastodon.account_statuses(user_id=user_id, limit=2, max_id=0) + # monkeypatch process_toot to add 1 to deleted_count and return + # this simulates what would happen if the toot was being deleted + monkeypatch.setattr( + "ephemetoot.ephemetoot.process_toot", + lambda config, options, mastodon, deleted_count, toot: deleted_count + 1 + ) + # run check_batch + ephemetoot.check_batch(config, options, mastodon, user_id, timeline, 0) + # deleted_count should be 10 + output = capfd.readouterr().out.split("\n") + assert output[0] == "Removed 10 toots." + +def test_console_print(capfd): + ephemetoot.console_print("test123", Namespace(test=False, hide_skipped=False, quiet=False), False) + assert capfd.readouterr().out == "test123\n" + +def test_console_print_quiet(): + result = ephemetoot.console_print("test123", Namespace(test=False, hide_skipped=False, quiet=True), False) + assert result == None + +def test_console_print_skip(): + result = ephemetoot.console_print("test123", Namespace(test=False, hide_skipped=True, quiet=False), True) + assert result == None + +def test_datestamp_now(): + datestamp = ephemetoot.datestamp_now() + date_object = datetime.datetime.strptime(datestamp, "%a %d %b %Y %H:%M:%S %z") + # use timetuple() to exclude differences in milliseconds + assert datetime.datetime.now(timezone.utc).timetuple() == date_object.timetuple() + +def test_init(monkeypatch, tmpdir): + + # monkeypatch current directory + current_dir = tmpdir.mkdir("current_dir") # temporary directory for testing + monkeypatch.chdir(current_dir) + + # monkeypatch input ...outputs + monkeypatch.setattr("ephemetoot.ephemetoot.compulsory_input", lambda a, b, c: "compulsory") + monkeypatch.setattr("ephemetoot.ephemetoot.digit_input", lambda a, b, c: "14") + monkeypatch.setattr("ephemetoot.ephemetoot.yes_no_input", lambda a, b: "false") + monkeypatch.setattr("ephemetoot.ephemetoot.optional_input", lambda a, b, c: "optional") + + # run init + ephemetoot.init() + assert os.path.exists( os.path.join(current_dir, "config.yaml")) + +def test_jsondefault(): + d = ephemetoot.jsondefault(toot.created_at) + assert d == "2020-05-09T02:17:18.598000+00:00" + +def test_process_toot(capfd, tmpdir, monkeypatch): + # config uses config_listed at top of this tests file + p = tmpdir.mkdir("archive") # use temporary test directory + config_file["archive"] = str(p) + config_file["keep_pinned"] = False + config_file["toots_to_keep"] = [] + config_file["visibility_to_keep"] = [] + options = Namespace(archive_deleted=False) + mastodon = Mocktodon() + toot_dict["pinned"] = False + toot_dict["visibility"] = "public" + toot_dict["reblog"] = False + toot = dict2obj(toot_dict) + ephemetoot.process_toot(config_file, options, mastodon, 0, toot) + assert capfd.readouterr().out == "โŒ deleting toot 104136090490756999 tooted 09 May 2020\n" + +def test_process_toot_pinned(capfd, tmpdir): + # config uses config_listed at top of this tests file + p = tmpdir.mkdir("archive") # use temporary test directory + config_file["archive"] = str(p) + config_file["keep_pinned"] = True + options = Namespace(archive_deleted=False) + mastodon = Mocktodon() + toot_dict["pinned"] = True + toot = dict2obj(toot_dict) + ephemetoot.process_toot(config_file, options, mastodon, 0, toot) + assert capfd.readouterr().out == "๐Ÿ“Œ skipping pinned toot - 104136090490756999\n" +def test_process_toot_saved(capfd, tmpdir): + # config uses config_listed at top of this tests file + p = tmpdir.mkdir("archive") # use temporary test directory + config_file["archive"] = str(p) + config_file["keep_pinned"] = False + config_file["toots_to_keep"].append(104136090490756999) + options = Namespace(archive_deleted=False) + mastodon = Mocktodon() + toot_dict["pinned"] = False + toot = dict2obj(toot_dict) + ephemetoot.process_toot(config_file, options, mastodon, 0, toot) + assert capfd.readouterr().out == "๐Ÿ’พ skipping saved toot - 104136090490756999\n" + +def test_process_toot_visibility(capfd, tmpdir): + # config uses config_listed at top of this tests file + p = tmpdir.mkdir("archive") # use temporary test directory + config_file["archive"] = str(p) + config_file["keep_pinned"] = False # is true above so make false + config_file["toots_to_keep"].remove(104136090490756999) # don't keep this toot + config_file["visibility_to_keep"].append("testing") + options = Namespace(archive_deleted=False) + mastodon = Mocktodon() + toot_dict["pinned"] = False # is true above so make false + toot_dict["visibility"] = "testing" + toot = dict2obj(toot_dict) + ephemetoot.process_toot(config_file, options, mastodon, 0, toot) + assert capfd.readouterr().out == "๐Ÿ‘€ skipping testing toot - 104136090490756999\n" + +def test_process_toot_hashtag(capfd, tmpdir, monkeypatch): + # config uses config_listed at top of this tests file + p = tmpdir.mkdir("archive") # use temporary test directory + config_file["archive"] = str(p) + config_file["keep_pinned"] = False + config_file["toots_to_keep"] = [] + config_file["visibility_to_keep"] = [] + options = Namespace(archive_deleted=False) + mastodon = Mocktodon() + toot_dict["pinned"] = False + toot_dict["visibility"] = "public" + toot_dict["reblog"] = True + toot = dict2obj(toot_dict) + + ephemetoot.process_toot(config_file, options, mastodon, 0, toot) + assert capfd.readouterr().out == "๐Ÿ‘Ž unboosting toot 104136090490756999 boosted 09 May 2020\n" + +def test_retry_on_error(): + # Namespace object constructed from top of tests (representing options) + # toot comes from variable at top of test + mastodon = Mocktodon() + toot = dict2obj(toot_dict) + retry = ephemetoot.retry_on_error(Namespace(retry_mins=True), mastodon, toot, 5 ) + assert retry == None # should not return an error + +def test_retry_on_error_max_tries(): + # Namespace object constructed from top of tests (representing options) + # toot and mastodon come from objects at top of test + with pytest.raises(TimeoutError): + mastodon = Mocktodon() + toot = dict2obj(toot_dict) + retry = ephemetoot.retry_on_error(Namespace(retry_mins=True), mastodon, toot, 7 ) + +def test_schedule(monkeypatch, tmpdir): + + home = tmpdir.mkdir("current_dir") # temporary directory for testing + launch = tmpdir.mkdir("TestAgents") # temporary directory for testing + + # monkeypatch directories and suppress the plist loading process + # NOTE: it may be possible to test the plist loading process + # but I can't work out how to do it universally / consistently + + def mock_current_dir(): + return str(home) + + def mock_home_dir_expansion(arg): + return str(launch) + + def suppress_subprocess(args, stdout=None, stderr=None, shell=None): + return None + + monkeypatch.setattr(os, "getcwd", mock_current_dir) + monkeypatch.setattr(os.path, "expanduser", mock_home_dir_expansion) + monkeypatch.setattr(subprocess, "run", suppress_subprocess) + + # now we run the function we're testing + ephemetoot.schedule(Namespace(schedule=".", time=None)) + + # assert the plist file was created + plist_file = os.path.join(launch, "ephemetoot.scheduler.plist") + assert os.path.lexists(plist_file) + + # check that correct values were modified in the file + f = open(plist_file, "r") + plist = f.readlines() + assert plist[7] == " " + str(home) + "\n" + assert plist[7] == " " + str(home) + "\n" + assert plist[10] == " " + sys.argv[0] + "\n" + assert plist[12] == " " + str(home) + "/config.yaml\n" + assert plist[15] == " " + str(home) + "/ephemetoot.log\n" + assert plist[17] == " " + str(home) + "/ephemetoot.error.log\n" + +def test_schedule_with_time(monkeypatch, tmpdir): + + home = tmpdir.mkdir("current_dir") # temporary directory for testing + launch = tmpdir.mkdir("TestAgents") # temporary directory for testing + + # monkeypatch directories and suppress the plist loading process + # NOTE: it may be possible to test the plist loading process + # but I can't work out how to do it universally / consistently + + def mock_current_dir(): + return str(home) + + def mock_home_dir_expansion(arg): + return str(launch) + + def suppress_subprocess(args, stdout=None, stderr=None, shell=None): + return None + + monkeypatch.setattr(os, "getcwd", mock_current_dir) + monkeypatch.setattr(os.path, "expanduser", mock_home_dir_expansion) + monkeypatch.setattr(subprocess, "run", suppress_subprocess) + + # now we run the function we're testing + ephemetoot.schedule(Namespace(schedule=".", time=["10", "30"])) + + # assert the plist file was created + plist_file = os.path.join(launch, "ephemetoot.scheduler.plist") + assert os.path.lexists(plist_file) + + # assert that correct values were modified in the file + f = open(plist_file, "r") + plist = f.readlines() + + assert plist[21] == " 10\n" + assert plist[23] == " 30\n" + +def test_tooted_date(): + string = ephemetoot.tooted_date(toot) + created = datetime.datetime(2020, 5, 9, 2, 17, 18, 598000, tzinfo=timezone.utc) + test_string = created.strftime("%d %b %Y") + + assert string == test_string + +def test_version(mock_github_response, capfd): + ephemetoot.version("TEST_VERSION") + output = capfd.readouterr().out + msg = """ +ephemetoot ==> ๐Ÿฅณ ==> ๐Ÿงผ ==> ๐Ÿ˜‡ +------------------------------- +You are using release: \033[92mvTEST_VERSION\033[0m +The latest release is: \033[92mvLATEST_VERSION\033[0m +To upgrade to the most recent version run \033[92mpip3 install --update ephemetoot\033[0m\n""" + + assert output == msg \ No newline at end of file From 3d78af93b4ce571aa5c6d155f6dc58d13d912a71 Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Sat, 5 Sep 2020 17:41:02 +1000 Subject: [PATCH 20/20] update version to 3 beta 0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3c1bf08..a1f6acc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ephemetoot" -version = "3.0.0-alpha.1" +version = "3.0.0-beta.0" description = "A command line tool to delete your old toots" authors = ["Hugh Rundle "] license = "GPL-3.0-or-later"