diff --git a/.gitignore b/.gitignore index 881a66b..fdf85ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ __pycache__ database.json database.bak +logs/*.log* +backups/*.bak* .env -dbhost/ \ No newline at end of file +*.jpg \ No newline at end of file diff --git a/backups/.gitkeep b/backups/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/crosspost.py b/crosspost.py index 34e11fa..8c23dec 100644 --- a/crosspost.py +++ b/crosspost.py @@ -1,553 +1,21 @@ -from atproto import Client -import tweepy -from mastodon import Mastodon -from datetime import datetime, timedelta -from auth import * -from paths import * -import settings -import json, os, urllib.request, random, string, shutil, re +from settings.auth import * +from settings.paths import * +from local.functions import write_log, cleanup, post_cache_read, post_cache_write, get_post_time_limit +from local.db import db_read, db_backup, save_db +from input.bluesky import get_posts +from output.post import post -date_in_format = '%Y-%m-%dT%H:%M:%S' - -# Setting up connections to bluesky, twitter and mastodon - -bsky = Client() -bsky.login(bsky_handle, bsky_password) -# After changes in twitters API we need to use tweepy.Client to make posts as it uses version 2.0 of the API. -# However, uploading images is still not included in 2.0, so for that we need to use tweepy.API, which uses -# the previous version. -if settings.Twitter: - twitter = tweepy.Client(consumer_key=TWITTER_APP_KEY, - consumer_secret=TWITTER_APP_SECRET, - access_token=TWITTER_ACCESS_TOKEN, - access_token_secret=TWITTER_ACCESS_TOKEN_SECRET) - - tweepy_auth = tweepy.OAuth1UserHandler(TWITTER_APP_KEY, TWITTER_APP_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_TOKEN_SECRET) - twitter_images = tweepy.API(tweepy_auth) - -if settings.Mastodon: - mastodon = Mastodon( - access_token = MASTODON_TOKEN, - api_base_url = MASTODON_INSTANCE - ) - -# Getting posts from bluesky - -def getPosts(): - writeLog("Gathering posts") - posts = {} - # Getting feed of user - profile_feed = bsky.app.bsky.feed.get_author_feed({'actor': bsky_handle}) - for feed_view in profile_feed.feed: - if feed_view.post.author.handle != bsky_handle: - continue - # Post type "post" means it is not a quote post. - postType = "post" - # If post has an embed of type record it is a quote post, and should not be crossposted - cid = feed_view.post.cid - text = feed_view.post.record.text - # Sometimes bluesky shortens URLs and in that case we need to restore them before crossposting - if feed_view.post.record.facets: - text = restoreUrls(feed_view.post.record) - langs = feed_view.post.record.langs - timestamp = datetime.strptime(feed_view.post.indexed_at.split(".")[0], date_in_format) + timedelta(hours = 2) - # Setting replyToUser to the same as user handle and only changing it if the tweet is an actual reply. - # This way we can just check if the variable is the same as the user handle later and send through - # both tweets that are not replies, and posts that are part of a thread. - replyToUser = bsky_handle - replyTo = "" - # Checking if post is a quote post. Posts with references to feeds look like quote posts but aren't, and so will fail on missing attribute. - # Since quote posts can give values in two different ways it's a bit of a hassle to double check if it is an actual quote post, - # so instead I just try to run the function and if it fails I skip the post - # If there is some reason you would want to crosspost a post referencing a bluesky-feed that I'm not seeing, I might update this in the future. - if feed_view.post.embed and hasattr(feed_view.post.embed, "record"): - try: - replyToUser, replyTo = getQuotePost(feed_view.post.embed.record) - postType = "quote" - except: - writeLog("Post is of a type the crossposter can't parse.") - continue - # Checking if post is regular reply - elif feed_view.post.record.reply: - postType = "reply" - replyTo = feed_view.post.record.reply.parent.cid - # Poster will try to fetch reply to-username the "ordinary" way, - # and if it fails, it will try getting the entire thread and - # finding it that way - try: - replyToUser = feed_view.reply.parent.author.handle - except: - replyToUser = getReplyToUser(feed_view.post.record.reply.parent) - # If unable to fetch user that was replied to, code will skip this post. - if not replyToUser: - writeLog("Unable to find the user that this post replies to or quotes") - continue - # Checking if post is by user (i.e. not a repost), withing timelimit and either not a reply or a reply in a thread. - if timestamp > datetime.now() - timedelta(hours = settings.postTimeLimit) and replyToUser == bsky_handle: - # Fetching images if there are any in the post - imageData = "" - images = [] - if feed_view.post.embed and hasattr(feed_view.post.embed, "images"): - imageData = feed_view.post.embed.images - elif feed_view.post.embed and hasattr(feed_view.post.embed, "media") and postType == "quote": - imageData = feed_view.post.embed.media.images - # Sometimes posts have included links that are not included in the actual text of the post. This adds adds that back. - if feed_view.post.embed and hasattr(feed_view.post.embed, "external") and hasattr(feed_view.post.embed.external, "uri"): - if feed_view.post.embed.external.uri not in text: - text += '\n'+feed_view.post.embed.external.uri - if imageData: - for image in imageData: - images.append({"url": image.fullsize, "alt": image.alt}) - postInfo = { - "text": text, - "replyTo": replyTo, - "images": images, - "type": postType, - "langs": langs - } - # Saving post to posts dictionary - posts[cid] = postInfo; - return posts - -# Function for getting username of person replied to. It can mostly be retrieved from the reply section of the tweet that has been fetched, -# but in cases where the original post in a thread has been deleted it causes some weirdness. Hopefully this resolves it. -def getReplyToUser(reply): - uri = reply.uri - username = "" - try: - response = bsky.app.bsky.feed.get_post_thread(params={"uri": uri}) - username = response.thread.post.author.handle - except: - writeLog("Unable to retrieve replyTo-user.") - return username - -# Function for getting included images. If no images are included, an empty list will be returned, -# and the posting functions will know not to include any images. -def getImages(images): - localImages = [] - for image in images: - # Getting alt text for image. If there is none this will be an empty string. - alt = image["alt"] - # Giving the image just a random filename - filename = ''.join(random.choice(string.ascii_lowercase) for i in range(10)) + ".jpg" - filename = imagePath + filename - # Downloading fullsize version of image - urllib.request.urlretrieve(image["url"], filename) - # Saving image info in a dictionary and adding it to the list. - imageInfo = { - "filename": filename, - "alt": alt - } - localImages.append(imageInfo) - return localImages - -# Function for restoring shortened URLS -def restoreUrls(record): - text = record.text - encodedText = text.encode("UTF-8") - for facet in record.facets: - if facet.features[0].py_type != "app.bsky.richtext.facet#link": - continue - url = facet.features[0].uri - # The index section designates where a URL starts end ends. Using this we can pick out the exact - # string representing the URL in the post, and replace it with the actual URL. - start = facet.index.byte_start - end = facet.index.byte_end - section = encodedText[start:end] - shortened = section.decode("UTF-8") - text = text.replace(shortened, url) - return text - -def getQuotePost(post): - if isinstance(post, dict): - user = post["record"]["author"]["handle"] - cid = post["record"]["cid"] - elif hasattr(post, "author"): - user = post.author.handle - cid = post.cid - else: - user = post.record.author.handle - cid = post.record.cid - return user, cid - -# Deprecated function -def imageFail(post): - if (post.embed and (hasattr(post.record.embed, "image") or hasattr(post.record.embed, "media")) - and not hasattr(post.embed, "images")): - return True - else: - return False - -def post(posts): - # The updates status is set to false until anything has been altered in the databse. If nothing has been posted in a run, we skip resaving the database. - updates = False - # Running through the posts dictionary reversed, to get oldest posts first. - for cid in reversed(list(posts.keys())): - # Checking if the post is already in the database, and in that case getting the IDs for the post - # on twitter and mastodon. If one or both of these IDs are empty, post will be sent. - tweetId = "" - tootId = "" - tFail = 0 - mFail = 0 - if cid in database: - tweetId = database[cid]["ids"]["twitterId"] - tootId = database[cid]["ids"]["mastodonId"] - tFail = database[cid]["failed"]["twitter"] - mFail = database[cid]["failed"]["mastodon"] - if mFail >= settings.maxRetries: - writeLog("Error limit reached, not posting to Mastodon") - if not tootId: - updates = True - tootId = "FailedToPost" - if tFail >= settings.maxRetries: - writeLog("Error limit reached, not posting to Twitter") - if not tweetId: - updates = True - tweetId = "FailedToPost" - text = posts[cid]["text"] - replyTo = posts[cid]["replyTo"] - images = posts[cid]["images"] - postType = posts[cid]["type"] - langs = posts[cid]["langs"] - tweetReply = "" - tootReply = "" - # If it is a reply, we get the IDs of the posts we want to reply to from the database. - # If post is not found in database, we can't continue the thread on mastodon and twitter, - # and so we skip it. - if replyTo in database: - tweetReply = database[replyTo]["ids"]["twitterId"] - tootReply = database[replyTo]["ids"]["mastodonId"] - elif replyTo and replyTo not in database: - writeLog("Post was a reply to a post that is not in the database.") - continue - # If either tweet or toot has not previously been posted, we download images (given the post includes images). - if images and (not tweetId or not tootId): - images = getImages(images) - # Trying to post to twitter and mastodon. If posting fails the post ID for each service is set to an - # empty string, letting the code know it should try again next time the code is run. - if not tweetId and tweetReply != "skipped" and tweetReply != "FailedToPost": - updates = True - try: - tweetId = tweet(text, tweetReply, images, postType, langToggle(langs, "twitter")) - except Exception as error: - writeLog(error) - tFail += 1 - tweetId = "" - else: - writeLog("Not posting to Twitter") - # Mastodon does not have a quote retweet function, so those will just be sent as replies. - if not tootId and tootReply != "skipped" and tootReply != "FailedToPost": - updates = True - try: - tootId = toot(text, tootReply, images, langToggle(langs, "mastodon")) - except Exception as error: - writeLog(error) - mFail += 1 - tootId = "" - else: - writeLog("Not posting to Mastodon") - # Saving post to database - jsonWrite(cid, tweetId, tootId, {"twitter": tFail, "mastodon": mFail}) - return updates - -# This function uses the language selection as a way to select which posts should be crossposted. -def langToggle(langs, service): - if service == "twitter": - langToggle = settings.twitterLang - elif service == "mastodon": - langToggle = settings.mastodonLang - else: - writeLog("Something has gone very wrong") - exit() - if not langToggle: - return True - if langs and langToggle in langs: - return (not settings.postDefault) - else: - return settings.postDefault - -# Function for posting tweets -def tweet(post, replyTo, images, postType, doPost): - if not settings.Twitter or not doPost: - return "skipped"; - mediaIds = [] - # If post includes images, images are uploaded so that they can be included in the tweet - if images: - mediaIds = [] - for image in images: - filename = image["filename"] - alt = image["alt"] - if len(alt) > 1000: - alt = alt[:996] + "..." - res = twitter_images.media_upload(filename) - id = res.media_id - # If alt text was added to the image on bluesky, it's also added to the image on twitter. - if alt: - writeLog("Uploading image " + filename + " with alt: " + alt + " to twitter") - twitter_images.create_media_metadata(id, alt) - mediaIds.append(id) - # Checking if the post is longer than 280 characters, and if so sending to the - # splitPost-function. - partTwo = "" - if postLength(post) > 280: - post, partTwo = splitPost(post) - # If the function does not return a post, splitting failed and we will skip this post. - if not post: - return "skipped" - # I wanted to make this part a little neater, but didn't get it to work and gave up. So here we are. - # If post is both reply and has images it is posted as both a reply and with images (duh), if it's - # a quote with images it's posted as that. If just either of the three it is posted as just that, - # and if neither it is just posted as a text post. - if replyTo and mediaIds and postType == "quote": - a = twitter.create_tweet(text=post, quote_tweet_id=replyTo, media_ids=mediaIds) - elif replyTo and mediaIds and postType == "reply": - a = twitter.create_tweet(text=post, in_reply_to_tweet_id=replyTo, media_ids=mediaIds) - elif postType == "quote": - a = twitter.create_tweet(text=post, quote_tweet_id=replyTo) - elif replyTo: - a = twitter.create_tweet(text=post, in_reply_to_tweet_id=replyTo) - elif mediaIds: - a = twitter.create_tweet(text=post, media_ids=mediaIds) - else: - a = twitter.create_tweet(text=post) - writeLog("Posted to twitter") - id = a[0]["id"] - if partTwo: - a = twitter.create_tweet(text=partTwo, in_reply_to_tweet_id=id) - id = a[0]["id"] - return id - -# More or less the exact same function as for tweeting, but for tooting. -def toot(post, replyTo, images, doPost): - if not settings.Mastodon or not doPost: - return "skipped"; - mediaIds = [] - # If post includes images, images are uploaded so that they can be included in the toot - if images: - for image in images: - filename = image["filename"] - alt = image["alt"] - # If alt text was added to the image on bluesky, it's also added to the image on mastodon, - # otherwise it will be uploaded without alt text. - if alt: - writeLog("Uploading image " + filename + " with alt: " + alt + " to mastodon") - res = mastodon.media_post(filename, description=alt) - else: - writeLog("Uploading image " + filename) - res = mastodon.media_post(filename) - mediaIds.append(res.id) - # Visibility is set to whatever is set in the settings file. If that is hybrid, it sets the visibility either to public or unlisted depending on - # if it is a reply in a thread or not. - visibility = settings.mastodonVisibility - if visibility == "hybrid" and replyTo: - visibility = "unlisted" - elif visibility == "hybrid": - visibility = "public" - # I wanted to make this part a little neater, but didn't get it to work and gave up. So here we are. - # If post is both reply and has images it is posted as both a reply and with images (duh). - # If just either of the two it is posted with just that, and if neither it is just posted as a text post. - if replyTo and mediaIds: - a = mastodon.status_post(post, in_reply_to_id=replyTo, media_ids=mediaIds, visibility=visibility) - elif replyTo: - a = mastodon.status_post(post, in_reply_to_id=replyTo, visibility=visibility) - elif mediaIds: - a = mastodon.status_post(post, media_ids=mediaIds, visibility=visibility) - else: - a = mastodon.status_post(post, visibility=visibility) - writeLog("Posted to mastodon") - id = a["id"] - return id - -# Function for correctly counting post length -def postLength(post): - # Twitter shortens urls to 23 characters - shortUrlLength = 23 - length = len(post) - # Finding all urls and calculating how much shorter the post will be after shortening - regex = r"(?i)\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'\".,<>?«»“”‘’]))" - urls = re.findall(regex, post) - for url in urls: - urlLength = len(url[0]) - if urlLength > shortUrlLength: - length = length - (urlLength - shortUrlLength) - return length - -# Function for splitting up posts that are too long for twitter. -def splitPost(text): - writeLog("Splitting post that is too long for twitter.") - first = text - # We first try to split the post into sentences, and send as many as can fit in the first one, - # and the rest in the second. - sentences = text.split(". ") - i = 1 - while len(first) > 280 and i < len(sentences): - first = ".".join(sentences[:(len(sentences) - i)]) + "." - second = ".".join(sentences[(len(sentences) - i):]) - i += 1 - # If splitting by sentance does not result in a short enough post, we try splitting by words instead. - if len(first) > 280: - first = text - words = text.split(" ") - i = 1 - while len(first) > 280 and i < len(words): - first = " ".join(words[:(len(words) - i)]) - second = " ".join(words[(len(words) - i):]) - i += 1 - # If splitting has ended up with either a first or second part that is too long, we return empty - # strings and the post is not sent to twitter. - if len(first) > 280 or len(second) > 280: - writeLog("Was not able to split post.") - first = "" - second = "" - return first, second - -# Function for writing new lines to the database -def jsonWrite(skeet, tweet, toot, failed): - ids = { - "twitterId": tweet, - "mastodonId": toot - } - data = { - "ids": ids, - "failed": failed - } - # When running, the code saves the database to memory, so instead of just saving the post to the database file, - # we also save it to the open database. This also overwrites the version of the post in memory in case - # an ID that was missing because of a previous failure. - database[skeet] = data - row = { - "skeet": skeet, - "ids": ids, - "failed": failed - } - jsonString = json.dumps(row) - # If the database file exists we want to append to it, otherwise we create it anew. - if os.path.exists(databasePath): - append_write = 'a' - else: - append_write = 'w' - # Skipping adding posts to db file if they are already in it. - if not isInDB(jsonString): - writeLog("Adding to database: " + jsonString) - file = open(databasePath, append_write) - file.write(jsonString + "\n") - file.close() - -# Function for reading database file and saving values in a dictionary -def jsonRead(): - database = {} - if not os.path.exists(databasePath): - return database - with open(databasePath, 'r') as file: - for line in file: - try: - jsonLine = json.loads(line) - except: - continue - skeet = jsonLine["skeet"] - ids = jsonLine["ids"] - failed = {"twitter": 0, "mastodon": 0} - if "failed" in jsonLine: - failed = jsonLine["failed"] - lineData = { - "ids": ids, - "failed": failed - } - database[skeet] = lineData - return database; - -# Function for checking if a line is already in the database-file -def isInDB(line): - if not os.path.exists(databasePath): - return False - with open(databasePath, 'r') as file: - content = file.read() - if line in content: - return True - else: - return False - -# Function for writing to the log file -def writeLog(message): - now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") - date = datetime.now().strftime("%y%m%d") - message = str(now) + ": " + str(message) + "\n" - print(message) - if not settings.Logging: - return; - log = logPath + date + ".log" - if os.path.exists(log): - append_write = 'a' - else: - append_write = 'w' - dst = open(log, append_write) - dst.write(message) - dst.close() - -# Cleaning up downloaded images -def cleanup(): - writeLog("Deleting local images") - for filename in os.listdir(imagePath): - file_path = os.path.join(imagePath, filename) - try: - if os.path.isfile(file_path) or os.path.islink(file_path): - os.unlink(file_path) - elif os.path.isdir(file_path): - shutil.rmtree(file_path) - except Exception as e: - writeLog('Failed to delete %s. Reason: %s' % (file_path, e)) - -# Since we are working with a version of the database in memory, at the end of the run -# we completely overwrite the database on file with the one in memory. -# This does kind of make it uneccessary to write each new post to the file while running, -# but in case the program fails halfway through it gives us kind of a backup. -def saveDB(): - writeLog("Saving new database") - append_write = "w" - for skeet in database: - row = { - "skeet": skeet, - "ids": database[skeet]["ids"], - "failed": database[skeet]["failed"] - } - jsonString = json.dumps(row) - file = open(databasePath, append_write) - file.write(jsonString + "\n") - file.close() - append_write = "a" - -# Function for counting lines in a file -def countLines(file): - with open(file, 'r') as file: - for count, line in enumerate(file): - pass - return count - -# Every twelve hours a backup of the database is saved, in case something happens to the live database. -# If the live database contains fewer lines than the backup it means something has probably gone wrong, -# and before the live database is saved as a backup, the current backup is saved as a new file, so that -# it can be recovered later. -def dbBackup(): - if not os.path.isfile(databasePath) or (os.path.isfile(backupPath) - and datetime.fromtimestamp(os.stat(backupPath).st_mtime) > datetime.now() - timedelta(hours = 24)): - return - if os.path.isfile(backupPath): - if countLines(backupPath) < countLines(databasePath): - os.remove(backupPath) - else: - date = datetime.now().strftime("%y%m%d") - os.rename(backupPath, backupPath + "_" + date) - writeLog("Current backup file contains more entries than current live database, backup saved") - shutil.copyfile(databasePath, backupPath) - writeLog("Backup of database taken") - # Here the whole thing is run -database = jsonRead() -posts = getPosts() -updates = post(posts) -if updates: - saveDB() - cleanup() -dbBackup() -if not posts: - writeLog("No new posts found.") +if __name__ == "__main__": + database = db_read() + post_cache = post_cache_read() + timelimit = get_post_time_limit(post_cache) + posts = get_posts(timelimit) + updates, database, post_cache = post(posts, database, post_cache) + post_cache_write(post_cache) + if updates: + save_db(database) + cleanup() + db_backup() + if not posts: + write_log("No new posts found.") \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 10f72f5..090bc01 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,7 @@ services: environment: BSKY_HANDLE: BSKY_PASSWORD: + MASTODON_HANDLE: MASTODON_INSTANCE: MASTODON_TOKEN: TWITTER_APP_KEY: @@ -13,13 +14,16 @@ services: TWITTER_ACCESS_TOKEN_SECRET: TWITTER_CROSSPOSTING: MASTODON_CROSSPOSTING: + LOG_LEVEL: MASTODON_VISIBILITY: - LOGGING: + MENTIONS: POST_DEFAULT: MASTODON_LANG: TWITTER_LANG: + QUOTE_POSTS: MAX_RETRIES: - RUN_INTERVAL: POST_TIME_LIMIT: + MAX_PER_HOUR: + OVERFLOW_POST: volumes: - ./dbhost:/db diff --git a/env.example b/env.example index 8556d3d..4751e6f 100644 --- a/env.example +++ b/env.example @@ -1,18 +1,22 @@ -BSKY_HANDLE= -BSKY_PASSWORD= -MASTODON_INSTANCE= -MASTODON_TOKEN= -TWITTER_APP_KEY= -TWITTER_APP_SECRET= -TWITTER_ACCESS_TOKEN= -TWITTER_ACCESS_TOKEN_SECRET= -TWITTER_CROSSPOSTING= -MASTODON_CROSSPOSTING= -MASTODON_VISIBILITY= -LOGGING= -POST_DEFAULT= -MASTODON_LANG= -TWITTER_LANG= -MAX_RETRIES= -RUN_INTERVAL= -POST_TIME_LIMIT= + BSKY_HANDLE= + BSKY_PASSWORD= + MASTODON_HANDLE= + MASTODON_INSTANCE= + MASTODON_TOKEN= + TWITTER_APP_KEY= + TWITTER_APP_SECRET= + TWITTER_ACCESS_TOKEN= + TWITTER_ACCESS_TOKEN_SECRET= + TWITTER_CROSSPOSTING= + MASTODON_CROSSPOSTING= + LOG_LEVEL= + MASTODON_VISIBILITY= + MENTIONS= + POST_DEFAULT= + MASTODON_LANG= + TWITTER_LANG= + QUOTE_POSTS= + MAX_RETRIES= + POST_TIME_LIMIT= + MAX_PER_HOUR= + OVERFLOW_POST= diff --git a/input/bluesky.py b/input/bluesky.py new file mode 100644 index 0000000..d623ec6 --- /dev/null +++ b/input/bluesky.py @@ -0,0 +1,235 @@ +from atproto import Client +from settings.auth import BSKY_HANDLE, BSKY_PASSWORD +from settings.paths import * +from settings import settings +from local.functions import write_log, lang_toggle, get_post_time_limit +import urllib.request, random, string, arrow + +date_in_format = 'YYYY-MM-DDTHH:mm:ss' + +# Setting up connections to bluesky, twitter and mastodon + +bsky = Client() +bsky.login(BSKY_HANDLE, BSKY_PASSWORD) + +# Getting posts from bluesky + +def get_posts(timelimit = arrow.utcnow().shift(hours = -1)): + write_log("Gathering posts") + posts = {} + # Getting feed of user + profile_feed = bsky.app.bsky.feed.get_author_feed({'actor': BSKY_HANDLE}) + visibility = settings.visibility + for feed_view in profile_feed.feed: + # If the post was not written by the account that posted it, it is a repost and we skip it. + if feed_view.post.author.handle != BSKY_HANDLE: + continue + repost = False + created_at = arrow.get(feed_view.post.record.created_at.split(".")[0], date_in_format) + if hasattr(feed_view.reason, "indexed_at"): + repost = True + created_at = arrow.get(feed_view.reason.indexed_at.split(".")[0], date_in_format) + # The language settings on posts are used to determine if a post should be crossposted + # to a specific service. Here we check the settings against the language of the post to + # see what service it should post to. We also check if posting for a service is enabled + # at all in the settings. If it shouldn't post to either, we skip it. + langs = feed_view.post.record.langs + mastodon_post = (lang_toggle(langs, "mastodon") and settings.Mastodon) + twitter_post = (lang_toggle(langs, "twitter") and settings.Twitter) + if not mastodon_post and not twitter_post: + continue + # If post has an embed of type record it is a quote post, and should not be crossposted + cid = feed_view.post.cid + text = feed_view.post.record.text + # Facets contains things like urls and mentions, which we need to deal with. + # send_mention is used to keep track of if the mention-settings says for the post to be posted or not. + # Default is True, because if nobody is mentioned it should be posted. + send_mention = True + if feed_view.post.record.facets: + # Sometimes bluesky shortens URLs and in that case we need to restore them before crossposting + text = restore_urls(feed_view.post.record) + # If a user is mentioned the parse_mentioned_username function will deal with it according + # to how the variable "mentions" is set in settings. If it is set to "ignore", nothing is + # done. + if settings.mentions != "ignore": + text, send_mention = parse_mentioned_username(feed_view.post.record, text) + # If "mentions" is set to "skip" a post with a mention should not be crossposted, and parse_mentioned_username will + # return send_mention as False. + if not send_mention: + continue + # Setting reply_to_user to the same as user handle and only changing it if the tweet is an actual reply. + # This way we can just check if the variable is the same as the user handle later and send through + # both tweets that are not replies, and posts that are part of a thread. + reply_to_user = BSKY_HANDLE + reply_to_post = "" + quoted_post = "" + quote_url = "" + # Checking who is allowed to reply to the post + allowed_reply = get_allowed_reply(feed_view.post) + # Checking if post is a quote post. Posts with references to feeds look like quote posts but aren't, and so will fail on missing attribute. + # Since quote posts can give values in two different ways it's a bit of a hassle to double check if it is an actual quote post, + # so instead I just try to run the function and if it fails I skip the post + # If there is some reason you would want to crosspost a post referencing a bluesky-feed that I'm not seeing, I might update this in the future. + if feed_view.post.embed and hasattr(feed_view.post.embed, "record"): + try: + quoted_user, quoted_post, quote_url, open = get_quote_post(feed_view.post.embed.record) + except: + write_log("Post " + cid + " is of a type the crossposter can't parse.", "error") + continue + # If post is a quote post of a post from another user, and quote-posting is disabled in settings + # or the post is not open to users not logged in, the post will be skipped + if quoted_user != BSKY_HANDLE and (not settings.quote_posts or not open): + continue + # If the post is a quote of ourselves, the url to the post is removed (if it was included), + # as we instead want to reference the version of the post from twitter or mastodon. + # If no such post exists, we can add back the link to the bluesky-post later + elif quoted_user == BSKY_HANDLE: + text = text.replace(quote_url, "") + # Checking if post is regular reply + if feed_view.post.record.reply: + reply_to_post = feed_view.post.record.reply.parent.cid + # Poster will try to fetch reply to-username the "ordinary" way, + # and if it fails, it will try getting the entire thread and + # finding it that way + try: + reply_to_user = feed_view.reply.parent.author.handle + except: + reply_to_user = get_reply_to_user(feed_view.post.record.reply.parent) + # If unable to fetch user that was replied to, code will skip this post. If the post was not a + # reply at all, the reply_to_user will still be set to the user account. + if not reply_to_user: + write_log("Unable to find the user that post " + cid + " replies to or quotes", "error") + continue + # Checking if post is withing timelimit and not a reply to someone elses post. + if created_at > timelimit and reply_to_user == BSKY_HANDLE: + # Fetching images if there are any in the post + image_data = "" + images = [] + if feed_view.post.embed and hasattr(feed_view.post.embed, "images"): + image_data = feed_view.post.embed.images + elif feed_view.post.embed and hasattr(feed_view.post.embed, "media") and hasattr(feed_view.post.embed, "record"): + image_data = feed_view.post.embed.media.images + # Sometimes posts have included links that are not included in the actual text of the post. This adds adds that back. + if feed_view.post.embed and hasattr(feed_view.post.embed, "external") and hasattr(feed_view.post.embed.external, "uri"): + if feed_view.post.embed.external.uri not in text: + text += '\n'+feed_view.post.embed.external.uri + if image_data: + for image in image_data: + images.append({"url": image.fullsize, "alt": image.alt}) + if visibility == "hybrid" and reply_to_post: + visibility = "unlisted" + elif visibility == "hybrid": + visibility = "public" + post_info = { + "text": text, + "reply_to_post": reply_to_post, + "quoted_post": quoted_post, + "quote_url": quote_url, + "images": images, + "visibility": visibility, + "twitter": twitter_post, + "mastodon": mastodon_post, + "allowed_reply": allowed_reply, + "repost": repost, + "timestamp": created_at + } + # Saving post to posts dictionary + posts[cid] = post_info; + return posts + +# Function for getting username of person replied to. It can mostly be retrieved from the reply section of the tweet that has been fetched, +# but in cases where the original post in a thread has been deleted it causes some weirdness. Hopefully this resolves it. +def get_reply_to_user(reply): + uri = reply.uri + username = "" + try: + response = bsky.app.bsky.feed.get_post_thread(params={"uri": uri}) + username = response.thread.post.author.handle + except: + write_log("Unable to retrieve reply_to-user of post.", "error") + return username + + +def get_allowed_reply(post): + reply_restriction = post.threadgate + if reply_restriction is None: + return "All" + if len(reply_restriction.record.allow) == 0: + return "None" + if reply_restriction.record.allow[0].py_type == "app.bsky.feed.threadgate#followingRule": + return "Following" + if reply_restriction.record.allow[0].py_type == "app.bsky.feed.threadgate#mentionRule": + return "Mentioned" + return "Unknown" + +# Function for restoring shortened URLS +def restore_urls(record): + text = record.text + encoded_text = text.encode("UTF-8") + for facet in record.facets: + if facet.features[0].py_type != "app.bsky.richtext.facet#link": + continue + url = facet.features[0].uri + # The index section designates where a URL starts end ends. Using this we can pick out the exact + # string representing the URL in the post, and replace it with the actual URL. + start = facet.index.byte_start + end = facet.index.byte_end + section = encoded_text[start:end] + shortened = section.decode("UTF-8") + text = text.replace(shortened, url) + return text + + +def parse_mentioned_username(record, text): + # send_mention keeps track if the post should be sent at all. + send_mention = True + encoded_text = text.encode("UTF-8") + for facet in record.facets: + if facet.features[0].py_type != "app.bsky.richtext.facet#mention": + continue + # The index section designates where a username starts end ends. Using this we can pick out the exact + # string representing the user in the post, and replace it with the corrected value + start = facet.index.byte_start + end = facet.index.byte_end + username = encoded_text[start:end] + username = username.decode("UTF-8") + # If the mentions setting is set to skip, None will be returned, if it's set to strip the + # text will be returned with the @ of the username removed, if it's set to URL the name will + # be replaced with a link to the profile. + if settings.mentions == "skip": + send_mention = False + elif settings.mentions == "strip": + text = text.replace(username, username.replace("@", "")) + elif settings.mentions == "url": + base_url = "https://bsky.app/profile/" + did = facet.features[0].did + url = base_url + did + text = text.replace(username, url) + return text, send_mention + +# Quoted posts can be stored in several different ways for some reason. With this +# function we check which one is used and fetches information accordingly. +def get_quote_post(post): + open = True + if isinstance(post, dict): + user = post["record"]["author"]["handle"] + cid = post["record"]["cid"] + uri = post["record"]["uri"] + labels = post["record"]["author"]["labels"] + elif hasattr(post, "author"): + user = post.author.handle + cid = post.cid + uri = post.uri + labels = post.author.labels + else: + user = post.record.author.handle + cid = post.record.cid + uri = post.record.uri + labels = post.record.author.labels + # the val label is used by bluesky to check if a post should be viewable by people + # who are not logged in. When crossposting with a link to a bsky post, we first + # want to make sure that the post in question is publicly available. + if labels and labels[0].val == "!no-unauthenticated": + open = False + url = "https://bsky.app/profile/" + user + "/post/" + uri.split("/")[-1] + return user, cid, url, open diff --git a/local/db.py b/local/db.py new file mode 100644 index 0000000..622a00c --- /dev/null +++ b/local/db.py @@ -0,0 +1,131 @@ +from settings.paths import * +from local.functions import write_log +import json, os, shutil, arrow + +# Function for writing new lines to the database +def db_write(skeet, tweet, toot, failed, database): + ids = { + "twitter_id": tweet, + "mastodon_id": toot + } + data = { + "ids": ids, + "failed": failed + } + # When running, the code saves the database to memory, so instead of just saving the post to the database file, + # we also save it to the open database. This also overwrites the version of the post in memory in case + # an ID that was missing because of a previous failure. + database[skeet] = data + row = { + "skeet": skeet, + "ids": ids, + "failed": failed + } + json_string = json.dumps(row) + # If the database file exists we want to append to it, otherwise we create it anew. + if os.path.exists(database_path): + append_write = 'a' + else: + append_write = 'w' + # Skipping adding posts to db file if they are already in it. + if not is_in_db(json_string): + write_log("Adding to database: " + json_string) + file = open(database_path, append_write) + file.write(json_string + "\n") + file.close() + return database + +# Function for reading database file and saving values in a dictionary +def db_read(): + database = {} + if not os.path.exists(database_path): + return database + with open(database_path, 'r') as file: + for line in file: + try: + json_line = json.loads(line) + except: + continue + skeet = json_line["skeet"] + ids = json_line["ids"] + ids = db_convert(ids) + failed = {"twitter": 0, "mastodon": 0} + if "failed" in json_line: + failed = json_line["failed"] + line_data = { + "ids": ids, + "failed": failed + } + database[skeet] = line_data + return database; + +# After changing from camelCase to snake_case, old database entries will have to be converted. +def db_convert(ids_in): + ids_out = {} + try: + ids_out["twitter_id"] = ids_in["twitter_id"] + except: + ids_out["twitter_id"] = ids_in["twitterId"] + try: + ids_out["mastodon_id"] = ids_in["mastodon_id"] + except: + ids_out["mastodon_id"] = ids_in["mastodonId"] + return ids_out + + +# Function for checking if a line is already in the database-file +def is_in_db(line): + if not os.path.exists(database_path): + return False + with open(database_path, 'r') as file: + content = file.read() + if line in content: + return True + else: + return False + +# Since we are working with a version of the database in memory, at the end of the run +# we completely overwrite the database on file with the one in memory. +# This does kind of make it uneccessary to write each new post to the file while running, +# but in case the program fails halfway through it gives us kind of a backup. +def save_db(database): + write_log("Saving new database") + append_write = "w" + for skeet in database: + row = { + "skeet": skeet, + "ids": database[skeet]["ids"], + "failed": database[skeet]["failed"] + } + jsonString = json.dumps(row) + file = open(database_path, append_write) + file.write(jsonString + "\n") + file.close() + append_write = "a" + +# Every twelve hours a backup of the database is saved, in case something happens to the live database. +# If the live database contains fewer lines than the backup it means something has probably gone wrong, +# and before the live database is saved as a backup, the current backup is saved as a new file, so that +# it can be recovered later. +def db_backup(): + if not os.path.isfile(database_path) or (os.path.isfile(backup_path) + and arrow.Arrow.fromtimestamp(os.stat(backup_path).st_mtime) > arrow.utcnow().shift(hours = -24)): + return + if os.path.isfile(backup_path): + if count_lines(backup_path) < count_lines(database_path): + os.remove(backup_path) + else: + date = arrow.utcnow().format("YYMMDD") + os.rename(backup_path, backup_path + "_" + date) + write_log("Current backup file contains more entries than current live database, backup saved", "error") + shutil.copyfile(database_path, backup_path) + write_log("Backup of database taken") + + +# Function for counting lines in a file +def count_lines(file): + count = 0; + with open(file, 'r') as file: + for count, line in enumerate(file): + pass + return count \ No newline at end of file diff --git a/local/functions.py b/local/functions.py new file mode 100644 index 0000000..8bb2661 --- /dev/null +++ b/local/functions.py @@ -0,0 +1,114 @@ +from settings.auth import * +from settings.paths import * +from local.functions import * +import settings.settings as settings +import os, shutil, re, arrow + +# This function uses the language selection as a way to select which posts should be crossposted. +def lang_toggle(langs, service): + if service == "twitter": + lang_toggle = settings.twitter_lang + elif service == "mastodon": + lang_toggle = settings.mastodon_lang + else: + write_log("Something has gone very wrong.", "error") + exit() + if not lang_toggle: + return True + if langs and lang_toggle in langs: + return (not settings.post_default) + else: + return settings.post_default + +# Function for correctly counting post length +def post_length(post): + # Twitter shortens urls to 23 characters + short_url_length = 23 + length = len(post) + # Finding all urls and calculating how much shorter the post will be after shortening + regex = r"(?i)\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'\".,<>?«»“”‘’]))" + urls = re.findall(regex, post) + for url in urls: + url_length = len(url[0]) + if url_length > short_url_length: + length = length - (url_length - short_url_length) + return length + + + +# Function for writing to the log file +def write_log(message, type = "message"): + if settings.log_level == "none" or (settings.log_level == "error" and type == "message"): + return; + now = arrow.utcnow().format("DD/MM/YYYY HH:mm:ss") + date = arrow.utcnow().format("YYMMDD") + message = str(now) + " (" + type.upper() + "): " + str(message) + "\n" + print(message) + log = log_path + date + ".log" + if os.path.exists(log): + append_write = 'a' + else: + append_write = 'w' + dst = open(log, append_write) + dst.write(message) + dst.close() + +# Cleaning up downloaded images +def cleanup(): + write_log("Deleting local images") + for filename in os.listdir(image_path): + file_path = os.path.join(image_path, filename) + try: + if os.path.isfile(file_path) or os.path.islink(file_path): + os.unlink(file_path) + elif os.path.isdir(file_path): + shutil.rmtree(file_path) + except Exception as e: + write_log('Failed to delete %s. Reason: %s' % (file_path, e), "error") + +# Following two functions deals with the post per hour limit + +# Function for reading post log and checking number of posts sent in last hour +def post_cache_read(): + write_log("Reading cache of recent posts.") + cache = {} + timelimit = arrow.utcnow().shift(hours = -1) + if not os.path.exists(post_cache_path): + write_log(post_cache_path + " not found.") + return cache + with open(post_cache_path, 'r') as file: + for line in file: + try: + post_id = line.split(";")[0] + timestamp = int(line.split(".")[1]) + timestamp = arrow.Arrow.fromtimestamp(timestamp) + except Exception as error: + write_log(error, "error") + continue + if timestamp > timelimit: + cache[post_id] = timestamp + return cache; + +def post_cache_write(cache): + write_log("Saving post cache.") + append_write = "w" + for post_id in cache: + timestamp = str(cache[post_id].timestamp()) + file = open(post_cache_path, append_write) + file.write(post_id + ";" + timestamp + "\n") + file.close() + append_write = "a" + +# The timelimit specifies the cutoff time for which posts are crossposted. This is usually based on the +# post_time_limit in settings, but if overflow_posts is set to "skip", meaning any posts that could +# not be posted due to the hourly post max limit is to be skipped, then the timelimit is instead set to +# when the last post was sent. +def get_post_time_limit(cache): + timelimit = arrow.utcnow().shift(hours = -settings.post_time_limit) + if settings.overflow_posts != "skip": + return timelimit + for post_id in cache: + if timelimit < cache[post_id]: + timelimit = cache[post_id] + return timelimit + diff --git a/output/mastodon.py b/output/mastodon.py new file mode 100644 index 0000000..485a52f --- /dev/null +++ b/output/mastodon.py @@ -0,0 +1,46 @@ +from mastodon import Mastodon +from settings import settings +from settings.auth import * +from local.functions import write_log + +if settings.Mastodon: + mastodon = Mastodon( + access_token = MASTODON_TOKEN, + api_base_url = MASTODON_INSTANCE + ) + +# More or less the exact same function as for tweeting, but for tooting. +def toot(post, reply_to_post, quoted_post, images, visibility = "unlisted"): + # Since mastodon does not have a quote repost function, quote posts are turned into replies. If the post is both + # a reply and a quote post, the quote is replaced with a url to the post quoted. + if reply_to_post is None and quoted_post: + reply_to_post = quoted_post + elif reply_to_post is not None and quoted_post: + post_url = MASTODON_INSTANCE + "@" + MASTODON_USER + "/" + str(quoted_post) + post += "\n" + post_url + media_ids = [] + # If post includes images, images are uploaded so that they can be included in the toot + if images: + for image in images: + filename = image["filename"] + alt = image["alt"] + # If alt text was added to the image on bluesky, it's also added to the image on mastodon, + # otherwise it will be uploaded without alt text. + if alt: + write_log("Uploading image " + filename + " with alt: " + alt + " to mastodon") + res = mastodon.media_post(filename, description=alt) + else: + write_log("Uploading image " + filename) + res = mastodon.media_post(filename) + media_ids.append(res.id) + # I wanted to make this part a little neater, but didn't get it to work and gave up. So here we are. + # If post is both reply and has images it is posted as both a reply and with images (duh). + # If just either of the two it is posted with just that, and if neither it is just posted as a text post. + a = mastodon.status_post(post, in_reply_to_id=reply_to_post, media_ids=media_ids, visibility=visibility) + write_log("Posted to mastodon") + id = a["id"] + return id + +def retoot(toot_id): + mastodon.status_reblog(toot_id) + write_log("Boosted toot " + str(toot_id)) \ No newline at end of file diff --git a/output/post.py b/output/post.py new file mode 100644 index 0000000..f028a5f --- /dev/null +++ b/output/post.py @@ -0,0 +1,179 @@ +import random, string, urllib, arrow +from settings import settings +from settings.paths import * +from local.functions import write_log +from local.db import db_write +from output.twitter import tweet, retweet +from output.mastodon import toot, retoot + + +def post(posts, database, post_cache): + # The updates status is set to false until anything has been altered in the databse. If nothing has been posted in a run, we skip resaving the database. + updates = False + # Running through the posts dictionary reversed, to get oldest posts first. + for cid in reversed(list(posts.keys())): + post = posts[cid] + # Checking if a maximum amount of posts per hour is set, and if so if it has been reached. + if settings.max_per_hour != 0 and len(post_cache) >= settings.max_per_hour: + write_log("Max posts per hour reached.") + break + # If a post is posted, we want to add a timestamp to the post_cache. Since there are several + # reasons why a post might not be posted, we start out with this set to false for each post, + # and change it to true if a post is actually sent. + posted = False + # Checking if the post is already in the database, and in that case getting the IDs for the post + # on twitter and mastodon. If one or both of these IDs are empty, post will be sent. + # Also checking the existing fail count against the max_retries set in settings, to avoid + # retrying a failure so much that the poster gets ratelimited + tweet_id = "" + toot_id = "" + t_fail = 0 + m_fail = 0 + if cid in database: + tweet_id = database[cid]["ids"]["twitter_id"] + toot_id = database[cid]["ids"]["mastodon_id"] + t_fail = database[cid]["failed"]["twitter"] + m_fail = database[cid]["failed"]["mastodon"] + if m_fail >= settings.max_retries: + write_log("Error limit reached, not posting to Mastodon", "error") + if not toot_id: + updates = True + toot_id = "FailedToPost" + if t_fail >= settings.max_retries: + write_log("Error limit reached, not posting to Twitter", "error") + if not tweet_id: + updates = True + tweet_id = "FailedToPost" + text = post["text"] + reply_to_post = post["reply_to_post"] + quoted_post = post["quoted_post"] + quote_url = post["quote_url"] + images = post["images"] + visibility = post["visibility"] + allowed_reply = post["allowed_reply"] + tweet_reply = "" + toot_reply = "" + tweet_quote = "" + toot_quote = "" + # If the post has already been sent to both twitter and mastodon and is not a repost, no + # further action is needed. + if tweet_id and toot_id and not post["repost"]: + continue + # If a retweet is found within the last hour, we check the cache to see if it has already been retweeted + repost_timelimit = arrow.utcnow().shift(hours = -1) + if cid in post_cache: + repost_timelimit = post_cache[cid] + # If it is a reply, we get the IDs of the posts we want to reply to from the database. + # If post is not found in database, we can't continue the thread on mastodon and twitter, + # and so we skip it. + if reply_to_post in database: + tweet_reply = database[reply_to_post]["ids"]["twitter_id"] + toot_reply = database[reply_to_post]["ids"]["mastodon_id"] + elif reply_to_post and reply_to_post not in database: + write_log("Post " + cid + " was a reply to a post that is not in the database.", "error") + continue + # If post is a quote post we get the IDs of the posts we want to quote from the database. + # If the posts are not found in the database we check if the quote_post setting is true or false in settings. + # If true we add the URL of the bluesky post to the text of the post, if false we skip the post. + if quoted_post in database: + tweet_quote = database[quoted_post]["ids"]["twitter_id"] + toot_quote = database[quoted_post]["ids"]["mastodon_id"] + elif quoted_post and quoted_post not in database: + if settings.quote_posts and quote_url not in text: + text += "\n" + quote_url + elif not settings.quote_posts: + write_log("Post " + cid + " was a quote of a post that is not in the database.", "error") + continue + # In case the tweet or toot reply/quote variables are empty, we set them to None, to make sure they are in the correct format for + # the api requests. This is not necessary for the toot_quote variable, as it is not sent as a parameter in itself anyway. + if not tweet_reply: + tweet_reply = None + if not toot_reply: + toot_reply = None + if not tweet_quote: + tweet_quote = None + # If either tweet or toot has not previously been posted, we download images (given the post includes images). + if images and (not tweet_id or not toot_id): + images = get_images(images) + # If mastodon is set to false, the post is not sent to mastodon. + if not post["twitter"]: + toot_id = "skipped" + write_log("Not posting to Twitter because posting was set to false.") + elif tweet_id and not post["repost"]: + write_log("Post " + cid + " already sent to twitter.") + # if the post already exists and is a repost, we check if it has already been reposted, and if not, repost it. + elif tweet_id and post["repost"] and post["timestamp"] > repost_timelimit: + try: + # This is where retweets would go if they weren't locked behind a paywall. + pass + # retweet(tweet_id) + # posted = True + except Exception as error: + write_log(error, "error") + # Trying to post to twitter and mastodon. If posting fails the post ID for each service is set to an + # empty string, letting the code know it should try again next time the code is run. + elif not tweet_id and tweet_reply != "skipped" and tweet_reply != "FailedToPost": + updates = True + try: + tweet_id = tweet(text, tweet_reply, tweet_quote, images, allowed_reply) + posted = True + except Exception as error: + write_log(error, "error") + t_fail += 1 + tweet_id = "" + # If a tweet failes as a duplicate post, we don't want to try sending it again. + if "duplicate content" in str(error): + t_fail = settings.max_retries + tweet_id = "duplicate" + else: + write_log("Not posting " + cid + " to Twitter") + # If mastodon is set to false, the post is not sent to mastodon. + if not post["mastodon"]: + toot_id = "skipped" + write_log("Not posting to Mastodon because posting was set to false.") + elif toot_id and not post["repost"]: + write_log("Post " + cid + " already sent to mastodon.") + # if the post already exists and is a repost, we check if it has already been reposted, and if not, repost it. + elif toot_id and post["repost"] and post["timestamp"] > repost_timelimit: + try: + retoot(toot_id) + posted = True + except Exception as error: + write_log(error, "error") + # Mastodon does not have a quote retweet function, so those will just be sent as replies. + elif not toot_id and toot_reply != "skipped" and toot_reply != "FailedToPost": + updates = True + try: + toot_id = toot(text, toot_reply, toot_quote, images, visibility) + posted = True + except Exception as error: + write_log(error, "error") + m_fail += 1 + toot_id = "" + else: + write_log("Not posting " + cid + " to Mastodon") + # Saving post to database + database = db_write(cid, tweet_id, toot_id, {"twitter": t_fail, "mastodon": m_fail}, database) + if posted: + post_cache[cid] = arrow.utcnow() + return updates, database, post_cache + +# Function for getting included images. If no images are included, an empty list will be returned, +# and the posting functions will know not to include any images. +def get_images(images): + local_images = [] + for image in images: + # Getting alt text for image. If there is none this will be an empty string. + alt = image["alt"] + # Giving the image just a random filename + filename = ''.join(random.choice(string.ascii_lowercase) for i in range(10)) + ".jpg" + filename = image_path + filename + # Downloading fullsize version of image + urllib.request.urlretrieve(image["url"], filename) + # Saving image info in a dictionary and adding it to the list. + image_info = { + "filename": filename, + "alt": alt + } + local_images.append(image_info) + return local_images \ No newline at end of file diff --git a/output/twitter.py b/output/twitter.py new file mode 100644 index 0000000..38206d5 --- /dev/null +++ b/output/twitter.py @@ -0,0 +1,91 @@ +import tweepy +from settings import settings +from settings.auth import * +from local.functions import write_log + +if settings.Twitter: + twitter_client = tweepy.Client(consumer_key=TWITTER_APP_KEY, + consumer_secret=TWITTER_APP_SECRET, + access_token=TWITTER_ACCESS_TOKEN, + access_token_secret=TWITTER_ACCESS_TOKEN_SECRET) + + tweepy_auth = tweepy.OAuth1UserHandler(TWITTER_APP_KEY, TWITTER_APP_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_TOKEN_SECRET) + twitter_api = tweepy.API(tweepy_auth) + +# Function for posting tweets +def tweet(post, reply_to_post, quoted_post, images, allowed_reply): + media_ids = None + reply_settings = set_reply_settings(allowed_reply) + # If post includes images, images are uploaded so that they can be included in the tweet + if images: + media_ids = [] + for image in images: + filename = image["filename"] + alt = image["alt"] + if len(alt) > 1000: + alt = alt[:996] + "..." + res = twitter_api.media_upload(filename) + id = res.media_id + # If alt text was added to the image on bluesky, it's also added to the image on twitter. + if alt: + write_log("Uploading image " + filename + " with alt: " + alt + " to twitter") + twitter_api.create_media_metadata(id, alt) + media_ids.append(id) + # Checking if the post is longer than 280 characters, and if so sending to the + # splitPost-function. + partTwo = "" + if len(post) > 280: + post, partTwo = split_post(post) + # If the function does not return a post, splitting failed and we will skip this post. + if not post: + return "skipped" + a = twitter_client.create_tweet(text=post, reply_settings=reply_settings, quote_tweet_id=quoted_post, in_reply_to_tweet_id=reply_to_post, media_ids=media_ids) + write_log("Posted to twitter") + id = a[0]["id"] + if partTwo: + a = twitter_client.create_tweet(text=partTwo, in_reply_to_tweet_id=id) + id = a[0]["id"] + return id + +def retweet(tweet_id): + a = twitter_client.retweet(tweet_id) + write_log("retweeted tweet " + str(tweet_id)) + + +# Function for splitting up posts that are too long for twitter. +def split_post(text): + write_log("Splitting post that is too long for twitter.") + first = text + # We first try to split the post into sentences, and send as many as can fit in the first one, + # and the rest in the second. + sentences = text.split(". ") + i = 1 + while len(first) > 280 and i < len(sentences): + first = ".".join(sentences[:(len(sentences) - i)]) + "." + second = ".".join(sentences[(len(sentences) - i):]) + i += 1 + # If splitting by sentance does not result in a short enough post, we try splitting by words instead. + if len(first) > 280: + first = text + words = text.split(" ") + i = 1 + while len(first) > 280 and i < len(words): + first = " ".join(words[:(len(words) - i)]) + second = " ".join(words[(len(words) - i):]) + i += 1 + # If splitting has ended up with either a first or second part that is too long, we return empty + # strings and the post is not sent to twitter. + if len(first) > 280 or len(second) > 280: + write_log("Was not able to split post.", "error") + first = "" + second = "" + return first, second + + +def set_reply_settings(allowed): + reply_settings = None + if allowed == "None" or allowed == "Mentioned": + reply_settings = "mentionedUsers" + elif allowed == "Following": + reply_settings = "following" + return reply_settings diff --git a/poster/bin/python b/poster/bin/python new file mode 120000 index 0000000..b8a0adb --- /dev/null +++ b/poster/bin/python @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/poster/bin/python3 b/poster/bin/python3 new file mode 120000 index 0000000..898ccd7 --- /dev/null +++ b/poster/bin/python3 @@ -0,0 +1 @@ +/bin/python3 \ No newline at end of file diff --git a/poster/lib/python3.8/site-packages/easy_install.py b/poster/lib/python3.8/site-packages/easy_install.py new file mode 100644 index 0000000..d87e984 --- /dev/null +++ b/poster/lib/python3.8/site-packages/easy_install.py @@ -0,0 +1,5 @@ +"""Run the EasyInstall command""" + +if __name__ == '__main__': + from setuptools.command.easy_install import main + main() diff --git a/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/AUTHORS.txt b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/AUTHORS.txt new file mode 100644 index 0000000..72c87d7 --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/AUTHORS.txt @@ -0,0 +1,562 @@ +A_Rog +Aakanksha Agrawal <11389424+rasponic@users.noreply.github.com> +Abhinav Sagar <40603139+abhinavsagar@users.noreply.github.com> +ABHYUDAY PRATAP SINGH +abs51295 +AceGentile +Adam Chainz +Adam Tse +Adam Tse +Adam Wentz +admin +Adrien Morison +ahayrapetyan +Ahilya +AinsworthK +Akash Srivastava +Alan Yee +Albert Tugushev +Albert-Guan +albertg +Aleks Bunin +Alethea Flowers +Alex Gaynor +Alex Grönholm +Alex Loosley +Alex Morega +Alex Stachowiak +Alexander Shtyrov +Alexandre Conrad +Alexey Popravka +Alexey Popravka +Alli +Ami Fischman +Ananya Maiti +Anatoly Techtonik +Anders Kaseorg +Andreas Lutro +Andrei Geacar +Andrew Gaul +Andrey Bulgakov +Andrés Delfino <34587441+andresdelfino@users.noreply.github.com> +Andrés Delfino +Andy Freeland +Andy Freeland +Andy Kluger +Ani Hayrapetyan +Aniruddha Basak +Anish Tambe +Anrs Hu +Anthony Sottile +Antoine Musso +Anton Ovchinnikov +Anton Patrushev +Antonio Alvarado Hernandez +Antony Lee +Antti Kaihola +Anubhav Patel +Anuj Godase +AQNOUCH Mohammed +AraHaan +Arindam Choudhury +Armin Ronacher +Artem +Ashley Manton +Ashwin Ramaswami +atse +Atsushi Odagiri +Avner Cohen +Baptiste Mispelon +Barney Gale +barneygale +Bartek Ogryczak +Bastian Venthur +Ben Darnell +Ben Hoyt +Ben Rosser +Bence Nagy +Benjamin Peterson +Benjamin VanEvery +Benoit Pierre +Berker Peksag +Bernardo B. Marques +Bernhard M. Wiedemann +Bertil Hatt +Bogdan Opanchuk +BorisZZZ +Brad Erickson +Bradley Ayers +Brandon L. Reiss +Brandt Bucher +Brett Randall +Brian Cristante <33549821+brcrista@users.noreply.github.com> +Brian Cristante +Brian Rosner +BrownTruck +Bruno Oliveira +Bruno Renié +Bstrdsmkr +Buck Golemon +burrows +Bussonnier Matthias +c22 +Caleb Martinez +Calvin Smith +Carl Meyer +Carlos Liam +Carol Willing +Carter Thayer +Cass +Chandrasekhar Atina +Chih-Hsuan Yen +Chih-Hsuan Yen +Chris Brinker +Chris Hunt +Chris Jerdonek +Chris McDonough +Chris Wolfe +Christian Heimes +Christian Oudard +Christopher Hunt +Christopher Snyder +Clark Boylan +Clay McClure +Cody +Cody Soyland +Colin Watson +Connor Osborn +Cooper Lees +Cooper Ry Lees +Cory Benfield +Cory Wright +Craig Kerstiens +Cristian Sorinel +Curtis Doty +cytolentino +Damian Quiroga +Dan Black +Dan Savilonis +Dan Sully +daniel +Daniel Collins +Daniel Hahler +Daniel Holth +Daniel Jost +Daniel Shaulov +Daniele Esposti +Daniele Procida +Danny Hermes +Dav Clark +Dave Abrahams +Dave Jones +David Aguilar +David Black +David Bordeynik +David Bordeynik +David Caro +David Evans +David Linke +David Pursehouse +David Tucker +David Wales +Davidovich +derwolfe +Desetude +Diego Caraballo +DiegoCaraballo +Dmitry Gladkov +Domen Kožar +Donald Stufft +Dongweiming +Douglas Thor +DrFeathers +Dustin Ingram +Dwayne Bailey +Ed Morley <501702+edmorley@users.noreply.github.com> +Ed Morley +Eitan Adler +ekristina +elainechan +Eli Schwartz +Eli Schwartz +Emil Burzo +Emil Styrke +Endoh Takanao +enoch +Erdinc Mutlu +Eric Gillingham +Eric Hanchrow +Eric Hopper +Erik M. Bray +Erik Rose +Ernest W Durbin III +Ernest W. Durbin III +Erwin Janssen +Eugene Vereshchagin +everdimension +Felix Yan +fiber-space +Filip Kokosiński +Florian Briand +Florian Rathgeber +Francesco +Francesco Montesano +Frost Ming +Gabriel Curio +Gabriel de Perthuis +Garry Polley +gdanielson +Geoffrey Lehée +Geoffrey Sneddon +George Song +Georgi Valkov +Giftlin Rajaiah +gizmoguy1 +gkdoc <40815324+gkdoc@users.noreply.github.com> +Gopinath M <31352222+mgopi1990@users.noreply.github.com> +GOTO Hayato <3532528+gh640@users.noreply.github.com> +gpiks +Guilherme Espada +Guy Rozendorn +gzpan123 +Hanjun Kim +Hari Charan +Harsh Vardhan +Herbert Pfennig +Hsiaoming Yang +Hugo +Hugo Lopes Tavares +Hugo van Kemenade +hugovk +Hynek Schlawack +Ian Bicking +Ian Cordasco +Ian Lee +Ian Stapleton Cordasco +Ian Wienand +Ian Wienand +Igor Kuzmitshov +Igor Sobreira +Ilya Baryshev +INADA Naoki +Ionel Cristian Mărieș +Ionel Maries Cristian +Ivan Pozdeev +Jacob Kim +jakirkham +Jakub Stasiak +Jakub Vysoky +Jakub Wilk +James Cleveland +James Cleveland +James Firth +James Polley +Jan Pokorný +Jannis Leidel +jarondl +Jason R. Coombs +Jay Graves +Jean-Christophe Fillion-Robin +Jeff Barber +Jeff Dairiki +Jelmer Vernooij +jenix21 +Jeremy Stanley +Jeremy Zafran +Jiashuo Li +Jim Garrison +Jivan Amara +John Paton +John-Scott Atlakson +johnthagen +johnthagen +Jon Banafato +Jon Dufresne +Jon Parise +Jonas Nockert +Jonathan Herbert +Joost Molenaar +Jorge Niedbalski +Joseph Long +Josh Bronson +Josh Hansen +Josh Schneier +Juanjo Bazán +Julian Berman +Julian Gethmann +Julien Demoor +jwg4 +Jyrki Pulliainen +Kai Chen +Kamal Bin Mustafa +kaustav haldar +keanemind +Keith Maxwell +Kelsey Hightower +Kenneth Belitzky +Kenneth Reitz +Kenneth Reitz +Kevin Burke +Kevin Carter +Kevin Frommelt +Kevin R Patterson +Kexuan Sun +Kit Randel +kpinc +Krishna Oza +Kumar McMillan +Kyle Persohn +lakshmanaram +Laszlo Kiss-Kollar +Laurent Bristiel +Laurie Opperman +Leon Sasson +Lev Givon +Lincoln de Sousa +Lipis +Loren Carvalho +Lucas Cimon +Ludovic Gasc +Luke Macken +Luo Jiebin +luojiebin +luz.paz +László Kiss Kollár +László Kiss Kollár +Marc Abramowitz +Marc Tamlyn +Marcus Smith +Mariatta +Mark Kohler +Mark Williams +Mark Williams +Markus Hametner +Masaki +Masklinn +Matej Stuchlik +Mathew Jennings +Mathieu Bridon +Matt Good +Matt Maker +Matt Robenolt +matthew +Matthew Einhorn +Matthew Gilliard +Matthew Iversen +Matthew Trumbell +Matthew Willson +Matthias Bussonnier +mattip +Maxim Kurnikov +Maxime Rouyrre +mayeut +mbaluna <44498973+mbaluna@users.noreply.github.com> +mdebi <17590103+mdebi@users.noreply.github.com> +memoselyk +Michael +Michael Aquilina +Michael E. Karpeles +Michael Klich +Michael Williamson +michaelpacer +Mickaël Schoentgen +Miguel Araujo Perez +Mihir Singh +Mike +Mike Hendricks +Min RK +MinRK +Miro Hrončok +Monica Baluna +montefra +Monty Taylor +Nate Coraor +Nathaniel J. Smith +Nehal J Wani +Neil Botelho +Nick Coghlan +Nick Stenning +Nick Timkovich +Nicolas Bock +Nikhil Benesch +Nitesh Sharma +Nowell Strite +NtaleGrey +nvdv +Ofekmeister +ofrinevo +Oliver Jeeves +Oliver Tonnhofer +Olivier Girardot +Olivier Grisel +Ollie Rutherfurd +OMOTO Kenji +Omry Yadan +Oren Held +Oscar Benjamin +Oz N Tiram +Pachwenko <32424503+Pachwenko@users.noreply.github.com> +Patrick Dubroy +Patrick Jenkins +Patrick Lawson +patricktokeeffe +Patrik Kopkan +Paul Kehrer +Paul Moore +Paul Nasrat +Paul Oswald +Paul van der Linden +Paulus Schoutsen +Pavithra Eswaramoorthy <33131404+QueenCoffee@users.noreply.github.com> +Pawel Jasinski +Pekka Klärck +Peter Lisák +Peter Waller +petr-tik +Phaneendra Chiruvella +Phil Freo +Phil Pennock +Phil Whelan +Philip Jägenstedt +Philip Molloy +Philippe Ombredanne +Pi Delport +Pierre-Yves Rofes +pip +Prabakaran Kumaresshan +Prabhjyotsing Surjit Singh Sodhi +Prabhu Marappan +Pradyun Gedam +Pratik Mallya +Preet Thakkar +Preston Holmes +Przemek Wrzos +Pulkit Goyal <7895pulkit@gmail.com> +Qiangning Hong +Quentin Pradet +R. David Murray +Rafael Caricio +Ralf Schmitt +Razzi Abuissa +rdb +Remi Rampin +Remi Rampin +Rene Dudfield +Riccardo Magliocchetti +Richard Jones +RobberPhex +Robert Collins +Robert McGibbon +Robert T. McGibbon +robin elisha robinson +Roey Berman +Rohan Jain +Rohan Jain +Rohan Jain +Roman Bogorodskiy +Romuald Brunet +Ronny Pfannschmidt +Rory McCann +Ross Brattain +Roy Wellington Ⅳ +Roy Wellington Ⅳ +Ryan Wooden +ryneeverett +Sachi King +Salvatore Rinchiera +Savio Jomton +schlamar +Scott Kitterman +Sean +seanj +Sebastian Jordan +Sebastian Schaetz +Segev Finer +SeongSoo Cho +Sergey Vasilyev +Seth Woodworth +Shlomi Fish +Shovan Maity +Simeon Visser +Simon Cross +Simon Pichugin +sinoroc +Sorin Sbarnea +Stavros Korokithakis +Stefan Scherfke +Stephan Erb +stepshal +Steve (Gadget) Barnes +Steve Barnes +Steve Dower +Steve Kowalik +Steven Myint +stonebig +Stéphane Bidoul (ACSONE) +Stéphane Bidoul +Stéphane Klein +Sumana Harihareswara +Sviatoslav Sydorenko +Sviatoslav Sydorenko +Swat009 +Takayuki SHIMIZUKAWA +tbeswick +Thijs Triemstra +Thomas Fenzl +Thomas Grainger +Thomas Guettler +Thomas Johansson +Thomas Kluyver +Thomas Smith +Tim D. Smith +Tim Gates +Tim Harder +Tim Heap +tim smith +tinruufu +Tom Forbes +Tom Freudenheim +Tom V +Tomas Orsava +Tomer Chachamu +Tony Beswick +Tony Zhaocheng Tan +TonyBeswick +toonarmycaptain +Toshio Kuratomi +Travis Swicegood +Tzu-ping Chung +Valentin Haenel +Victor Stinner +victorvpaulo +Viktor Szépe +Ville Skyttä +Vinay Sajip +Vincent Philippon +Vinicyus Macedo <7549205+vinicyusmacedo@users.noreply.github.com> +Vitaly Babiy +Vladimir Rutsky +W. Trevor King +Wil Tan +Wilfred Hughes +William ML Leslie +William T Olson +Wilson Mo +wim glenn +Wolfgang Maier +Xavier Fernandez +Xavier Fernandez +xoviat +xtreak +YAMAMOTO Takashi +Yen Chi Hsuan +Yeray Diaz Diaz +Yoval P +Yu Jian +Yuan Jing Vincent Yan +Zearin +Zearin +Zhiping Deng +Zvezdan Petkovic +Łukasz Langa +Семён Марьясин diff --git a/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/LICENSE.txt b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/LICENSE.txt new file mode 100644 index 0000000..737fec5 --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/LICENSE.txt @@ -0,0 +1,20 @@ +Copyright (c) 2008-2019 The pip developers (see AUTHORS.txt file) + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/METADATA b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/METADATA new file mode 100644 index 0000000..4adf953 --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/METADATA @@ -0,0 +1,82 @@ +Metadata-Version: 2.1 +Name: setuptools +Version: 44.0.0 +Summary: Easily download, build, install, upgrade, and uninstall Python packages +Home-page: https://github.com/pypa/setuptools +Author: Python Packaging Authority +Author-email: distutils-sig@python.org +License: UNKNOWN +Project-URL: Documentation, https://setuptools.readthedocs.io/ +Keywords: CPAN PyPI distutils eggs package management +Platform: UNKNOWN +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 2 +Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Classifier: Topic :: System :: Archiving :: Packaging +Classifier: Topic :: System :: Systems Administration +Classifier: Topic :: Utilities +Requires-Python: !=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7 +Description-Content-Type: text/x-rst; charset=UTF-8 + +.. image:: https://img.shields.io/pypi/v/setuptools.svg + :target: https://pypi.org/project/setuptools + +.. image:: https://img.shields.io/readthedocs/setuptools/latest.svg + :target: https://setuptools.readthedocs.io + +.. image:: https://img.shields.io/travis/pypa/setuptools/master.svg?label=Linux%20CI&logo=travis&logoColor=white + :target: https://travis-ci.org/pypa/setuptools + +.. image:: https://img.shields.io/appveyor/ci/pypa/setuptools/master.svg?label=Windows%20CI&logo=appveyor&logoColor=white + :target: https://ci.appveyor.com/project/pypa/setuptools/branch/master + +.. image:: https://img.shields.io/codecov/c/github/pypa/setuptools/master.svg?logo=codecov&logoColor=white + :target: https://codecov.io/gh/pypa/setuptools + +.. image:: https://tidelift.com/badges/github/pypa/setuptools?style=flat + :target: https://tidelift.com/subscription/pkg/pypi-setuptools?utm_source=pypi-setuptools&utm_medium=readme + +.. image:: https://img.shields.io/pypi/pyversions/setuptools.svg + +See the `Installation Instructions +`_ in the Python Packaging +User's Guide for instructions on installing, upgrading, and uninstalling +Setuptools. + +Questions and comments should be directed to the `distutils-sig +mailing list `_. +Bug reports and especially tested patches may be +submitted directly to the `bug tracker +`_. + +To report a security vulnerability, please use the +`Tidelift security contact `_. +Tidelift will coordinate the fix and disclosure. + + +For Enterprise +============== + +Available as part of the Tidelift Subscription. + +Setuptools and the maintainers of thousands of other packages are working with Tidelift to deliver one enterprise subscription that covers all of the open source you use. + +`Learn more `_. + +Code of Conduct +=============== + +Everyone interacting in the setuptools project's codebases, issue trackers, +chat rooms, and mailing lists is expected to follow the +`PyPA Code of Conduct `_. + + diff --git a/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/RECORD b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/RECORD new file mode 100644 index 0000000..dbfb023 --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/RECORD @@ -0,0 +1,89 @@ +easy_install.py,sha256=MDC9vt5AxDsXX5qcKlBz2TnW6Tpuv_AobnfhCJ9X3PM,126 +setuptools/__init__.py,sha256=WBpCcn2lvdckotabeae1TTYonPOcgCIF3raD2zRWzBc,7283 +setuptools/_deprecation_warning.py,sha256=jU9-dtfv6cKmtQJOXN8nP1mm7gONw5kKEtiPtbwnZyI,218 +setuptools/_imp.py,sha256=jloslOkxrTKbobgemfP94YII0nhqiJzE1bRmCTZ1a5I,2223 +setuptools/archive_util.py,sha256=kw8Ib_lKjCcnPKNbS7h8HztRVK0d5RacU3r_KRdVnmM,6592 +setuptools/build_meta.py,sha256=-9Nmj9YdbW4zX3TssPJZhsENrTa4fw3k86Jm1cdKMik,9597 +setuptools/cli-32.exe,sha256=dfEuovMNnA2HLa3jRfMPVi5tk4R7alCbpTvuxtCyw0Y,65536 +setuptools/cli-64.exe,sha256=KLABu5pyrnokJCv6skjXZ6GsXeyYHGcqOUT3oHI3Xpo,74752 +setuptools/cli.exe,sha256=dfEuovMNnA2HLa3jRfMPVi5tk4R7alCbpTvuxtCyw0Y,65536 +setuptools/config.py,sha256=6SB2OY3qcooOJmG_rsK_s0pKBsorBlDpfMJUyzjQIGk,20575 +setuptools/dep_util.py,sha256=fgixvC1R7sH3r13ktyf7N0FALoqEXL1cBarmNpSEoWg,935 +setuptools/depends.py,sha256=qt2RWllArRvhnm8lxsyRpcthEZYp4GHQgREl1q0LkFw,5517 +setuptools/dist.py,sha256=xtXaNsOsE32MwwQqErzgXJF7jsTQz9GYFRrwnPFQ0J0,49865 +setuptools/errors.py,sha256=MVOcv381HNSajDgEUWzOQ4J6B5BHCBMSjHfaWcEwA1o,524 +setuptools/extension.py,sha256=uc6nHI-MxwmNCNPbUiBnybSyqhpJqjbhvOQ-emdvt_E,1729 +setuptools/glob.py,sha256=o75cHrOxYsvn854thSxE0x9k8JrKDuhP_rRXlVB00Q4,5084 +setuptools/gui-32.exe,sha256=XBr0bHMA6Hpz2s9s9Bzjl-PwXfa9nH4ie0rFn4V2kWA,65536 +setuptools/gui-64.exe,sha256=aYKMhX1IJLn4ULHgWX0sE0yREUt6B3TEHf_jOw6yNyE,75264 +setuptools/gui.exe,sha256=XBr0bHMA6Hpz2s9s9Bzjl-PwXfa9nH4ie0rFn4V2kWA,65536 +setuptools/installer.py,sha256=TCFRonRo01I79zo-ucf3Ymhj8TenPlmhMijN916aaJs,5337 +setuptools/launch.py,sha256=sd7ejwhBocCDx_wG9rIs0OaZ8HtmmFU8ZC6IR_S0Lvg,787 +setuptools/lib2to3_ex.py,sha256=t5e12hbR2pi9V4ezWDTB4JM-AISUnGOkmcnYHek3xjg,2013 +setuptools/monkey.py,sha256=FGc9fffh7gAxMLFmJs2DW_OYWpBjkdbNS2n14UAK4NA,5264 +setuptools/msvc.py,sha256=8baJ6aYgCA4TRdWQQi185qB9dnU8FaP4wgpbmd7VODs,46751 +setuptools/namespaces.py,sha256=F0Nrbv8KCT2OrO7rwa03om4N4GZKAlnce-rr-cgDQa8,3199 +setuptools/package_index.py,sha256=rqhmbFUEf4WxndnKbtWmj_x8WCuZSuoCgA0K1syyCY8,40616 +setuptools/py27compat.py,sha256=tvmer0Tn-wk_JummCkoM22UIjpjL-AQ8uUiOaqTs8sI,1496 +setuptools/py31compat.py,sha256=h2rtZghOfwoGYd8sQ0-auaKiF3TcL3qX0bX3VessqcE,838 +setuptools/py33compat.py,sha256=SMF9Z8wnGicTOkU1uRNwZ_kz5Z_bj29PUBbqdqeeNsc,1330 +setuptools/py34compat.py,sha256=KYOd6ybRxjBW8NJmYD8t_UyyVmysppFXqHpFLdslGXU,245 +setuptools/sandbox.py,sha256=9UbwfEL5QY436oMI1LtFWohhoZ-UzwHvGyZjUH_qhkw,14276 +setuptools/script (dev).tmpl,sha256=RUzQzCQUaXtwdLtYHWYbIQmOaES5Brqq1FvUA_tu-5I,218 +setuptools/script.tmpl,sha256=WGTt5piezO27c-Dbx6l5Q4T3Ff20A5z7872hv3aAhYY,138 +setuptools/site-patch.py,sha256=OumkIHMuoSenRSW1382kKWI1VAwxNE86E5W8iDd34FY,2302 +setuptools/ssl_support.py,sha256=nLjPUBBw7RTTx6O4RJZ5eAMGgjJG8beiDbkFXDZpLuM,8493 +setuptools/unicode_utils.py,sha256=NOiZ_5hD72A6w-4wVj8awHFM3n51Kmw1Ic_vx15XFqw,996 +setuptools/version.py,sha256=og_cuZQb0QI6ukKZFfZWPlr1HgJBPPn2vO2m_bI9ZTE,144 +setuptools/wheel.py,sha256=zct-SEj5_LoHg6XELt2cVRdulsUENenCdS1ekM7TlZA,8455 +setuptools/windows_support.py,sha256=5GrfqSP2-dLGJoZTq2g6dCKkyQxxa2n5IQiXlJCoYEE,714 +setuptools/_vendor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +setuptools/_vendor/ordered_set.py,sha256=dbaCcs27dyN9gnMWGF5nA_BrVn6Q-NrjKYJpV9_fgBs,15130 +setuptools/_vendor/pyparsing.py,sha256=tmrp-lu-qO1i75ZzIN5A12nKRRD1Cm4Vpk-5LR9rims,232055 +setuptools/_vendor/six.py,sha256=A6hdJZVjI3t_geebZ9BzUvwRrIXo0lfwzQlM2LcKyas,30098 +setuptools/_vendor/packaging/__about__.py,sha256=CpuMSyh1V7adw8QMjWKkY3LtdqRUkRX4MgJ6nF4stM0,744 +setuptools/_vendor/packaging/__init__.py,sha256=6enbp5XgRfjBjsI9-bn00HjHf5TH21PDMOKkJW8xw-w,562 +setuptools/_vendor/packaging/_compat.py,sha256=Ugdm-qcneSchW25JrtMIKgUxfEEBcCAz6WrEeXeqz9o,865 +setuptools/_vendor/packaging/_structures.py,sha256=pVd90XcXRGwpZRB_qdFuVEibhCHpX_bL5zYr9-N0mc8,1416 +setuptools/_vendor/packaging/markers.py,sha256=-meFl9Fr9V8rF5Rduzgett5EHK9wBYRUqssAV2pj0lw,8268 +setuptools/_vendor/packaging/requirements.py,sha256=3dwIJekt8RRGCUbgxX8reeAbgmZYjb0wcCRtmH63kxI,4742 +setuptools/_vendor/packaging/specifiers.py,sha256=0ZzQpcUnvrQ6LjR-mQRLzMr8G6hdRv-mY0VSf_amFtI,27778 +setuptools/_vendor/packaging/tags.py,sha256=EPLXhO6GTD7_oiWEO1U0l0PkfR8R_xivpMDHXnsTlts,12933 +setuptools/_vendor/packaging/utils.py,sha256=VaTC0Ei7zO2xl9ARiWmz2YFLFt89PuuhLbAlXMyAGms,1520 +setuptools/_vendor/packaging/version.py,sha256=Npdwnb8OHedj_2L86yiUqscujb7w_i5gmSK1PhOAFzg,11978 +setuptools/command/__init__.py,sha256=QCAuA9whnq8Bnoc0bBaS6Lw_KAUO0DiHYZQXEMNn5hg,568 +setuptools/command/alias.py,sha256=KjpE0sz_SDIHv3fpZcIQK-sCkJz-SrC6Gmug6b9Nkc8,2426 +setuptools/command/bdist_egg.py,sha256=nnfV8Ah8IRC_Ifv5Loa9FdxL66MVbyDXwy-foP810zM,18185 +setuptools/command/bdist_rpm.py,sha256=B7l0TnzCGb-0nLlm6rS00jWLkojASwVmdhW2w5Qz_Ak,1508 +setuptools/command/bdist_wininst.py,sha256=_6dz3lpB1tY200LxKPLM7qgwTCceOMgaWFF-jW2-pm0,637 +setuptools/command/build_clib.py,sha256=bQ9aBr-5ZSO-9fGsGsDLz0mnnFteHUZnftVLkhvHDq0,4484 +setuptools/command/build_ext.py,sha256=Ib42YUGksBswm2mL5xmQPF6NeTA6HcqrvAtEgFCv32A,13019 +setuptools/command/build_py.py,sha256=yWyYaaS9F3o9JbIczn064A5g1C5_UiKRDxGaTqYbtLE,9596 +setuptools/command/develop.py,sha256=MQlnGS6uP19erK2JCNOyQYoYyquk3PADrqrrinqqLtA,8184 +setuptools/command/dist_info.py,sha256=5t6kOfrdgALT-P3ogss6PF9k-Leyesueycuk3dUyZnI,960 +setuptools/command/easy_install.py,sha256=0lY8Agxe-7IgMtxgxFuOY1NrDlBzOUlpCKsvayXlTYY,89903 +setuptools/command/egg_info.py,sha256=0e_TXrMfpa8nGTO7GmJcmpPCMWzliZi6zt9aMchlumc,25578 +setuptools/command/install.py,sha256=8doMxeQEDoK4Eco0mO2WlXXzzp9QnsGJQ7Z7yWkZPG8,4705 +setuptools/command/install_egg_info.py,sha256=4zq_Ad3jE-EffParuyDEnvxU6efB-Xhrzdr8aB6Ln_8,3195 +setuptools/command/install_lib.py,sha256=9zdc-H5h6RPxjySRhOwi30E_WfcVva7gpfhZ5ata60w,5023 +setuptools/command/install_scripts.py,sha256=UD0rEZ6861mTYhIdzcsqKnUl8PozocXWl9VBQ1VTWnc,2439 +setuptools/command/launcher manifest.xml,sha256=xlLbjWrB01tKC0-hlVkOKkiSPbzMml2eOPtJ_ucCnbE,628 +setuptools/command/py36compat.py,sha256=SzjZcOxF7zdFUT47Zv2n7AM3H8koDys_0OpS-n9gIfc,4986 +setuptools/command/register.py,sha256=kk3DxXCb5lXTvqnhfwx2g6q7iwbUmgTyXUCaBooBOUk,468 +setuptools/command/rotate.py,sha256=co5C1EkI7P0GGT6Tqz-T2SIj2LBJTZXYELpmao6d4KQ,2164 +setuptools/command/saveopts.py,sha256=za7QCBcQimKKriWcoCcbhxPjUz30gSB74zuTL47xpP4,658 +setuptools/command/sdist.py,sha256=IL1LepD2h8qGKOFJ3rrQVbjNH_Q6ViD40l0QADr4MEU,8088 +setuptools/command/setopt.py,sha256=NTWDyx-gjDF-txf4dO577s7LOzHVoKR0Mq33rFxaRr8,5085 +setuptools/command/test.py,sha256=u2kXngIIdSYqtvwFlHiN6Iye1IB4TU6uadB2uiV1szw,9602 +setuptools/command/upload.py,sha256=XT3YFVfYPAmA5qhGg0euluU98ftxRUW-PzKcODMLxUs,462 +setuptools/command/upload_docs.py,sha256=oXiGplM_cUKLwE4CWWw98RzCufAu8tBhMC97GegFcms,7311 +setuptools/extern/__init__.py,sha256=4q9gtShB1XFP6CisltsyPqtcfTO6ZM9Lu1QBl3l-qmo,2514 +setuptools-44.0.0.dist-info/AUTHORS.txt,sha256=RtqU9KfonVGhI48DAA4-yTOBUhBtQTjFhaDzHoyh7uU,21518 +setuptools-44.0.0.dist-info/LICENSE.txt,sha256=W6Ifuwlk-TatfRU2LR7W1JMcyMj5_y1NkRkOEJvnRDE,1090 +setuptools-44.0.0.dist-info/METADATA,sha256=L93fcafgVw4xoJUNG0lehyy0prVj-jU_JFxRh0ZUtos,3523 +setuptools-44.0.0.dist-info/WHEEL,sha256=kGT74LWyRUZrL4VgLh6_g12IeVl_9u9ZVhadrgXZUEY,110 +setuptools-44.0.0.dist-info/dependency_links.txt,sha256=HlkCFkoK5TbZ5EMLbLKYhLcY_E31kBWD8TqW2EgmatQ,239 +setuptools-44.0.0.dist-info/entry_points.txt,sha256=ZmIqlp-SBdsBS2cuetmU2NdSOs4DG0kxctUR9UJ8Xk0,3150 +setuptools-44.0.0.dist-info/top_level.txt,sha256=2HUXVVwA4Pff1xgTFr3GsTXXKaPaO6vlG6oNJ_4u4Tg,38 +setuptools-44.0.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1 +setuptools-44.0.0.dist-info/RECORD,, diff --git a/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/WHEEL b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/WHEEL new file mode 100644 index 0000000..ef99c6c --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/WHEEL @@ -0,0 +1,6 @@ +Wheel-Version: 1.0 +Generator: bdist_wheel (0.34.2) +Root-Is-Purelib: true +Tag: py2-none-any +Tag: py3-none-any + diff --git a/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/dependency_links.txt b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/dependency_links.txt new file mode 100644 index 0000000..e87d021 --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/dependency_links.txt @@ -0,0 +1,2 @@ +https://files.pythonhosted.org/packages/source/c/certifi/certifi-2016.9.26.tar.gz#md5=baa81e951a29958563689d868ef1064d +https://files.pythonhosted.org/packages/source/w/wincertstore/wincertstore-0.2.zip#md5=ae728f2f007185648d0c7a8679b361e2 diff --git a/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/entry_points.txt b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/entry_points.txt new file mode 100644 index 0000000..0fed3f1 --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/entry_points.txt @@ -0,0 +1,68 @@ +[console_scripts] +easy_install = setuptools.command.easy_install:main + +[distutils.commands] +alias = setuptools.command.alias:alias +bdist_egg = setuptools.command.bdist_egg:bdist_egg +bdist_rpm = setuptools.command.bdist_rpm:bdist_rpm +bdist_wininst = setuptools.command.bdist_wininst:bdist_wininst +build_clib = setuptools.command.build_clib:build_clib +build_ext = setuptools.command.build_ext:build_ext +build_py = setuptools.command.build_py:build_py +develop = setuptools.command.develop:develop +dist_info = setuptools.command.dist_info:dist_info +easy_install = setuptools.command.easy_install:easy_install +egg_info = setuptools.command.egg_info:egg_info +install = setuptools.command.install:install +install_egg_info = setuptools.command.install_egg_info:install_egg_info +install_lib = setuptools.command.install_lib:install_lib +install_scripts = setuptools.command.install_scripts:install_scripts +rotate = setuptools.command.rotate:rotate +saveopts = setuptools.command.saveopts:saveopts +sdist = setuptools.command.sdist:sdist +setopt = setuptools.command.setopt:setopt +test = setuptools.command.test:test +upload_docs = setuptools.command.upload_docs:upload_docs + +[distutils.setup_keywords] +convert_2to3_doctests = setuptools.dist:assert_string_list +dependency_links = setuptools.dist:assert_string_list +eager_resources = setuptools.dist:assert_string_list +entry_points = setuptools.dist:check_entry_points +exclude_package_data = setuptools.dist:check_package_data +extras_require = setuptools.dist:check_extras +include_package_data = setuptools.dist:assert_bool +install_requires = setuptools.dist:check_requirements +namespace_packages = setuptools.dist:check_nsp +package_data = setuptools.dist:check_package_data +packages = setuptools.dist:check_packages +python_requires = setuptools.dist:check_specifier +setup_requires = setuptools.dist:check_requirements +test_loader = setuptools.dist:check_importable +test_runner = setuptools.dist:check_importable +test_suite = setuptools.dist:check_test_suite +tests_require = setuptools.dist:check_requirements +use_2to3 = setuptools.dist:assert_bool +use_2to3_exclude_fixers = setuptools.dist:assert_string_list +use_2to3_fixers = setuptools.dist:assert_string_list +zip_safe = setuptools.dist:assert_bool + +[egg_info.writers] +PKG-INFO = setuptools.command.egg_info:write_pkg_info +dependency_links.txt = setuptools.command.egg_info:overwrite_arg +depends.txt = setuptools.command.egg_info:warn_depends_obsolete +eager_resources.txt = setuptools.command.egg_info:overwrite_arg +entry_points.txt = setuptools.command.egg_info:write_entries +namespace_packages.txt = setuptools.command.egg_info:overwrite_arg +requires.txt = setuptools.command.egg_info:write_requirements +top_level.txt = setuptools.command.egg_info:write_toplevel_names + +[setuptools.finalize_distribution_options] +2to3_doctests = setuptools.dist:Distribution._finalize_2to3_doctests +features = setuptools.dist:Distribution._finalize_feature_opts +keywords = setuptools.dist:Distribution._finalize_setup_keywords +parent_finalize = setuptools.dist:_Distribution.finalize_options + +[setuptools.installation] +eggsecutable = setuptools.command.easy_install:bootstrap + diff --git a/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/top_level.txt b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/top_level.txt new file mode 100644 index 0000000..4577c6a --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/top_level.txt @@ -0,0 +1,3 @@ +easy_install +pkg_resources +setuptools diff --git a/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/zip-safe b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/zip-safe new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/zip-safe @@ -0,0 +1 @@ + diff --git a/poster/lib/python3.8/site-packages/setuptools/_deprecation_warning.py b/poster/lib/python3.8/site-packages/setuptools/_deprecation_warning.py new file mode 100644 index 0000000..086b64d --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools/_deprecation_warning.py @@ -0,0 +1,7 @@ +class SetuptoolsDeprecationWarning(Warning): + """ + Base class for warning deprecations in ``setuptools`` + + This class is not derived from ``DeprecationWarning``, and as such is + visible by default. + """ diff --git a/poster/lib/python3.8/site-packages/setuptools/_imp.py b/poster/lib/python3.8/site-packages/setuptools/_imp.py new file mode 100644 index 0000000..a3cce9b --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools/_imp.py @@ -0,0 +1,73 @@ +""" +Re-implementation of find_module and get_frozen_object +from the deprecated imp module. +""" + +import os +import importlib.util +import importlib.machinery + +from .py34compat import module_from_spec + + +PY_SOURCE = 1 +PY_COMPILED = 2 +C_EXTENSION = 3 +C_BUILTIN = 6 +PY_FROZEN = 7 + + +def find_module(module, paths=None): + """Just like 'imp.find_module()', but with package support""" + spec = importlib.util.find_spec(module, paths) + if spec is None: + raise ImportError("Can't find %s" % module) + if not spec.has_location and hasattr(spec, 'submodule_search_locations'): + spec = importlib.util.spec_from_loader('__init__.py', spec.loader) + + kind = -1 + file = None + static = isinstance(spec.loader, type) + if spec.origin == 'frozen' or static and issubclass( + spec.loader, importlib.machinery.FrozenImporter): + kind = PY_FROZEN + path = None # imp compabilty + suffix = mode = '' # imp compability + elif spec.origin == 'built-in' or static and issubclass( + spec.loader, importlib.machinery.BuiltinImporter): + kind = C_BUILTIN + path = None # imp compabilty + suffix = mode = '' # imp compability + elif spec.has_location: + path = spec.origin + suffix = os.path.splitext(path)[1] + mode = 'r' if suffix in importlib.machinery.SOURCE_SUFFIXES else 'rb' + + if suffix in importlib.machinery.SOURCE_SUFFIXES: + kind = PY_SOURCE + elif suffix in importlib.machinery.BYTECODE_SUFFIXES: + kind = PY_COMPILED + elif suffix in importlib.machinery.EXTENSION_SUFFIXES: + kind = C_EXTENSION + + if kind in {PY_SOURCE, PY_COMPILED}: + file = open(path, mode) + else: + path = None + suffix = mode = '' + + return file, path, (suffix, mode, kind) + + +def get_frozen_object(module, paths=None): + spec = importlib.util.find_spec(module, paths) + if not spec: + raise ImportError("Can't find %s" % module) + return spec.loader.get_code(module) + + +def get_module(module, paths, info): + spec = importlib.util.find_spec(module, paths) + if not spec: + raise ImportError("Can't find %s" % module) + return module_from_spec(spec) diff --git a/poster/lib/python3.8/site-packages/setuptools/cli-64.exe b/poster/lib/python3.8/site-packages/setuptools/cli-64.exe new file mode 100644 index 0000000..675e6bf Binary files /dev/null and b/poster/lib/python3.8/site-packages/setuptools/cli-64.exe differ diff --git a/poster/lib/python3.8/site-packages/setuptools/cli.exe b/poster/lib/python3.8/site-packages/setuptools/cli.exe new file mode 100644 index 0000000..b1487b7 Binary files /dev/null and b/poster/lib/python3.8/site-packages/setuptools/cli.exe differ diff --git a/poster/lib/python3.8/site-packages/setuptools/config.py b/poster/lib/python3.8/site-packages/setuptools/config.py new file mode 100644 index 0000000..9b9a0c4 --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools/config.py @@ -0,0 +1,659 @@ +from __future__ import absolute_import, unicode_literals +import io +import os +import sys + +import warnings +import functools +from collections import defaultdict +from functools import partial +from functools import wraps +from importlib import import_module + +from distutils.errors import DistutilsOptionError, DistutilsFileError +from setuptools.extern.packaging.version import LegacyVersion, parse +from setuptools.extern.packaging.specifiers import SpecifierSet +from setuptools.extern.six import string_types, PY3 + + +__metaclass__ = type + + +def read_configuration( + filepath, find_others=False, ignore_option_errors=False): + """Read given configuration file and returns options from it as a dict. + + :param str|unicode filepath: Path to configuration file + to get options from. + + :param bool find_others: Whether to search for other configuration files + which could be on in various places. + + :param bool ignore_option_errors: Whether to silently ignore + options, values of which could not be resolved (e.g. due to exceptions + in directives such as file:, attr:, etc.). + If False exceptions are propagated as expected. + + :rtype: dict + """ + from setuptools.dist import Distribution, _Distribution + + filepath = os.path.abspath(filepath) + + if not os.path.isfile(filepath): + raise DistutilsFileError( + 'Configuration file %s does not exist.' % filepath) + + current_directory = os.getcwd() + os.chdir(os.path.dirname(filepath)) + + try: + dist = Distribution() + + filenames = dist.find_config_files() if find_others else [] + if filepath not in filenames: + filenames.append(filepath) + + _Distribution.parse_config_files(dist, filenames=filenames) + + handlers = parse_configuration( + dist, dist.command_options, + ignore_option_errors=ignore_option_errors) + + finally: + os.chdir(current_directory) + + return configuration_to_dict(handlers) + + +def _get_option(target_obj, key): + """ + Given a target object and option key, get that option from + the target object, either through a get_{key} method or + from an attribute directly. + """ + getter_name = 'get_{key}'.format(**locals()) + by_attribute = functools.partial(getattr, target_obj, key) + getter = getattr(target_obj, getter_name, by_attribute) + return getter() + + +def configuration_to_dict(handlers): + """Returns configuration data gathered by given handlers as a dict. + + :param list[ConfigHandler] handlers: Handlers list, + usually from parse_configuration() + + :rtype: dict + """ + config_dict = defaultdict(dict) + + for handler in handlers: + for option in handler.set_options: + value = _get_option(handler.target_obj, option) + config_dict[handler.section_prefix][option] = value + + return config_dict + + +def parse_configuration( + distribution, command_options, ignore_option_errors=False): + """Performs additional parsing of configuration options + for a distribution. + + Returns a list of used option handlers. + + :param Distribution distribution: + :param dict command_options: + :param bool ignore_option_errors: Whether to silently ignore + options, values of which could not be resolved (e.g. due to exceptions + in directives such as file:, attr:, etc.). + If False exceptions are propagated as expected. + :rtype: list + """ + options = ConfigOptionsHandler( + distribution, command_options, ignore_option_errors) + options.parse() + + meta = ConfigMetadataHandler( + distribution.metadata, command_options, ignore_option_errors, + distribution.package_dir) + meta.parse() + + return meta, options + + +class ConfigHandler: + """Handles metadata supplied in configuration files.""" + + section_prefix = None + """Prefix for config sections handled by this handler. + Must be provided by class heirs. + + """ + + aliases = {} + """Options aliases. + For compatibility with various packages. E.g.: d2to1 and pbr. + Note: `-` in keys is replaced with `_` by config parser. + + """ + + def __init__(self, target_obj, options, ignore_option_errors=False): + sections = {} + + section_prefix = self.section_prefix + for section_name, section_options in options.items(): + if not section_name.startswith(section_prefix): + continue + + section_name = section_name.replace(section_prefix, '').strip('.') + sections[section_name] = section_options + + self.ignore_option_errors = ignore_option_errors + self.target_obj = target_obj + self.sections = sections + self.set_options = [] + + @property + def parsers(self): + """Metadata item name to parser function mapping.""" + raise NotImplementedError( + '%s must provide .parsers property' % self.__class__.__name__) + + def __setitem__(self, option_name, value): + unknown = tuple() + target_obj = self.target_obj + + # Translate alias into real name. + option_name = self.aliases.get(option_name, option_name) + + current_value = getattr(target_obj, option_name, unknown) + + if current_value is unknown: + raise KeyError(option_name) + + if current_value: + # Already inhabited. Skipping. + return + + skip_option = False + parser = self.parsers.get(option_name) + if parser: + try: + value = parser(value) + + except Exception: + skip_option = True + if not self.ignore_option_errors: + raise + + if skip_option: + return + + setter = getattr(target_obj, 'set_%s' % option_name, None) + if setter is None: + setattr(target_obj, option_name, value) + else: + setter(value) + + self.set_options.append(option_name) + + @classmethod + def _parse_list(cls, value, separator=','): + """Represents value as a list. + + Value is split either by separator (defaults to comma) or by lines. + + :param value: + :param separator: List items separator character. + :rtype: list + """ + if isinstance(value, list): # _get_parser_compound case + return value + + if '\n' in value: + value = value.splitlines() + else: + value = value.split(separator) + + return [chunk.strip() for chunk in value if chunk.strip()] + + @classmethod + def _parse_dict(cls, value): + """Represents value as a dict. + + :param value: + :rtype: dict + """ + separator = '=' + result = {} + for line in cls._parse_list(value): + key, sep, val = line.partition(separator) + if sep != separator: + raise DistutilsOptionError( + 'Unable to parse option value to dict: %s' % value) + result[key.strip()] = val.strip() + + return result + + @classmethod + def _parse_bool(cls, value): + """Represents value as boolean. + + :param value: + :rtype: bool + """ + value = value.lower() + return value in ('1', 'true', 'yes') + + @classmethod + def _exclude_files_parser(cls, key): + """Returns a parser function to make sure field inputs + are not files. + + Parses a value after getting the key so error messages are + more informative. + + :param key: + :rtype: callable + """ + def parser(value): + exclude_directive = 'file:' + if value.startswith(exclude_directive): + raise ValueError( + 'Only strings are accepted for the {0} field, ' + 'files are not accepted'.format(key)) + return value + return parser + + @classmethod + def _parse_file(cls, value): + """Represents value as a string, allowing including text + from nearest files using `file:` directive. + + Directive is sandboxed and won't reach anything outside + directory with setup.py. + + Examples: + file: README.rst, CHANGELOG.md, src/file.txt + + :param str value: + :rtype: str + """ + include_directive = 'file:' + + if not isinstance(value, string_types): + return value + + if not value.startswith(include_directive): + return value + + spec = value[len(include_directive):] + filepaths = (os.path.abspath(path.strip()) for path in spec.split(',')) + return '\n'.join( + cls._read_file(path) + for path in filepaths + if (cls._assert_local(path) or True) + and os.path.isfile(path) + ) + + @staticmethod + def _assert_local(filepath): + if not filepath.startswith(os.getcwd()): + raise DistutilsOptionError( + '`file:` directive can not access %s' % filepath) + + @staticmethod + def _read_file(filepath): + with io.open(filepath, encoding='utf-8') as f: + return f.read() + + @classmethod + def _parse_attr(cls, value, package_dir=None): + """Represents value as a module attribute. + + Examples: + attr: package.attr + attr: package.module.attr + + :param str value: + :rtype: str + """ + attr_directive = 'attr:' + if not value.startswith(attr_directive): + return value + + attrs_path = value.replace(attr_directive, '').strip().split('.') + attr_name = attrs_path.pop() + + module_name = '.'.join(attrs_path) + module_name = module_name or '__init__' + + parent_path = os.getcwd() + if package_dir: + if attrs_path[0] in package_dir: + # A custom path was specified for the module we want to import + custom_path = package_dir[attrs_path[0]] + parts = custom_path.rsplit('/', 1) + if len(parts) > 1: + parent_path = os.path.join(os.getcwd(), parts[0]) + module_name = parts[1] + else: + module_name = custom_path + elif '' in package_dir: + # A custom parent directory was specified for all root modules + parent_path = os.path.join(os.getcwd(), package_dir['']) + sys.path.insert(0, parent_path) + try: + module = import_module(module_name) + value = getattr(module, attr_name) + + finally: + sys.path = sys.path[1:] + + return value + + @classmethod + def _get_parser_compound(cls, *parse_methods): + """Returns parser function to represents value as a list. + + Parses a value applying given methods one after another. + + :param parse_methods: + :rtype: callable + """ + def parse(value): + parsed = value + + for method in parse_methods: + parsed = method(parsed) + + return parsed + + return parse + + @classmethod + def _parse_section_to_dict(cls, section_options, values_parser=None): + """Parses section options into a dictionary. + + Optionally applies a given parser to values. + + :param dict section_options: + :param callable values_parser: + :rtype: dict + """ + value = {} + values_parser = values_parser or (lambda val: val) + for key, (_, val) in section_options.items(): + value[key] = values_parser(val) + return value + + def parse_section(self, section_options): + """Parses configuration file section. + + :param dict section_options: + """ + for (name, (_, value)) in section_options.items(): + try: + self[name] = value + + except KeyError: + pass # Keep silent for a new option may appear anytime. + + def parse(self): + """Parses configuration file items from one + or more related sections. + + """ + for section_name, section_options in self.sections.items(): + + method_postfix = '' + if section_name: # [section.option] variant + method_postfix = '_%s' % section_name + + section_parser_method = getattr( + self, + # Dots in section names are translated into dunderscores. + ('parse_section%s' % method_postfix).replace('.', '__'), + None) + + if section_parser_method is None: + raise DistutilsOptionError( + 'Unsupported distribution option section: [%s.%s]' % ( + self.section_prefix, section_name)) + + section_parser_method(section_options) + + def _deprecated_config_handler(self, func, msg, warning_class): + """ this function will wrap around parameters that are deprecated + + :param msg: deprecation message + :param warning_class: class of warning exception to be raised + :param func: function to be wrapped around + """ + @wraps(func) + def config_handler(*args, **kwargs): + warnings.warn(msg, warning_class) + return func(*args, **kwargs) + + return config_handler + + +class ConfigMetadataHandler(ConfigHandler): + + section_prefix = 'metadata' + + aliases = { + 'home_page': 'url', + 'summary': 'description', + 'classifier': 'classifiers', + 'platform': 'platforms', + } + + strict_mode = False + """We need to keep it loose, to be partially compatible with + `pbr` and `d2to1` packages which also uses `metadata` section. + + """ + + def __init__(self, target_obj, options, ignore_option_errors=False, + package_dir=None): + super(ConfigMetadataHandler, self).__init__(target_obj, options, + ignore_option_errors) + self.package_dir = package_dir + + @property + def parsers(self): + """Metadata item name to parser function mapping.""" + parse_list = self._parse_list + parse_file = self._parse_file + parse_dict = self._parse_dict + exclude_files_parser = self._exclude_files_parser + + return { + 'platforms': parse_list, + 'keywords': parse_list, + 'provides': parse_list, + 'requires': self._deprecated_config_handler( + parse_list, + "The requires parameter is deprecated, please use " + "install_requires for runtime dependencies.", + DeprecationWarning), + 'obsoletes': parse_list, + 'classifiers': self._get_parser_compound(parse_file, parse_list), + 'license': exclude_files_parser('license'), + 'license_files': parse_list, + 'description': parse_file, + 'long_description': parse_file, + 'version': self._parse_version, + 'project_urls': parse_dict, + } + + def _parse_version(self, value): + """Parses `version` option value. + + :param value: + :rtype: str + + """ + version = self._parse_file(value) + + if version != value: + version = version.strip() + # Be strict about versions loaded from file because it's easy to + # accidentally include newlines and other unintended content + if isinstance(parse(version), LegacyVersion): + tmpl = ( + 'Version loaded from {value} does not ' + 'comply with PEP 440: {version}' + ) + raise DistutilsOptionError(tmpl.format(**locals())) + + return version + + version = self._parse_attr(value, self.package_dir) + + if callable(version): + version = version() + + if not isinstance(version, string_types): + if hasattr(version, '__iter__'): + version = '.'.join(map(str, version)) + else: + version = '%s' % version + + return version + + +class ConfigOptionsHandler(ConfigHandler): + + section_prefix = 'options' + + @property + def parsers(self): + """Metadata item name to parser function mapping.""" + parse_list = self._parse_list + parse_list_semicolon = partial(self._parse_list, separator=';') + parse_bool = self._parse_bool + parse_dict = self._parse_dict + + return { + 'zip_safe': parse_bool, + 'use_2to3': parse_bool, + 'include_package_data': parse_bool, + 'package_dir': parse_dict, + 'use_2to3_fixers': parse_list, + 'use_2to3_exclude_fixers': parse_list, + 'convert_2to3_doctests': parse_list, + 'scripts': parse_list, + 'eager_resources': parse_list, + 'dependency_links': parse_list, + 'namespace_packages': parse_list, + 'install_requires': parse_list_semicolon, + 'setup_requires': parse_list_semicolon, + 'tests_require': parse_list_semicolon, + 'packages': self._parse_packages, + 'entry_points': self._parse_file, + 'py_modules': parse_list, + 'python_requires': SpecifierSet, + } + + def _parse_packages(self, value): + """Parses `packages` option value. + + :param value: + :rtype: list + """ + find_directives = ['find:', 'find_namespace:'] + trimmed_value = value.strip() + + if trimmed_value not in find_directives: + return self._parse_list(value) + + findns = trimmed_value == find_directives[1] + if findns and not PY3: + raise DistutilsOptionError( + 'find_namespace: directive is unsupported on Python < 3.3') + + # Read function arguments from a dedicated section. + find_kwargs = self.parse_section_packages__find( + self.sections.get('packages.find', {})) + + if findns: + from setuptools import find_namespace_packages as find_packages + else: + from setuptools import find_packages + + return find_packages(**find_kwargs) + + def parse_section_packages__find(self, section_options): + """Parses `packages.find` configuration file section. + + To be used in conjunction with _parse_packages(). + + :param dict section_options: + """ + section_data = self._parse_section_to_dict( + section_options, self._parse_list) + + valid_keys = ['where', 'include', 'exclude'] + + find_kwargs = dict( + [(k, v) for k, v in section_data.items() if k in valid_keys and v]) + + where = find_kwargs.get('where') + if where is not None: + find_kwargs['where'] = where[0] # cast list to single val + + return find_kwargs + + def parse_section_entry_points(self, section_options): + """Parses `entry_points` configuration file section. + + :param dict section_options: + """ + parsed = self._parse_section_to_dict(section_options, self._parse_list) + self['entry_points'] = parsed + + def _parse_package_data(self, section_options): + parsed = self._parse_section_to_dict(section_options, self._parse_list) + + root = parsed.get('*') + if root: + parsed[''] = root + del parsed['*'] + + return parsed + + def parse_section_package_data(self, section_options): + """Parses `package_data` configuration file section. + + :param dict section_options: + """ + self['package_data'] = self._parse_package_data(section_options) + + def parse_section_exclude_package_data(self, section_options): + """Parses `exclude_package_data` configuration file section. + + :param dict section_options: + """ + self['exclude_package_data'] = self._parse_package_data( + section_options) + + def parse_section_extras_require(self, section_options): + """Parses `extras_require` configuration file section. + + :param dict section_options: + """ + parse_list = partial(self._parse_list, separator=';') + self['extras_require'] = self._parse_section_to_dict( + section_options, parse_list) + + def parse_section_data_files(self, section_options): + """Parses `data_files` configuration file section. + + :param dict section_options: + """ + parsed = self._parse_section_to_dict(section_options, self._parse_list) + self['data_files'] = [(k, v) for k, v in parsed.items()] diff --git a/poster/lib/python3.8/site-packages/setuptools/dep_util.py b/poster/lib/python3.8/site-packages/setuptools/dep_util.py new file mode 100644 index 0000000..2931c13 --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools/dep_util.py @@ -0,0 +1,23 @@ +from distutils.dep_util import newer_group + +# yes, this is was almost entirely copy-pasted from +# 'newer_pairwise()', this is just another convenience +# function. +def newer_pairwise_group(sources_groups, targets): + """Walk both arguments in parallel, testing if each source group is newer + than its corresponding target. Returns a pair of lists (sources_groups, + targets) where sources is newer than target, according to the semantics + of 'newer_group()'. + """ + if len(sources_groups) != len(targets): + raise ValueError("'sources_group' and 'targets' must be the same length") + + # build a pair of lists (sources_groups, targets) where source is newer + n_sources = [] + n_targets = [] + for i in range(len(sources_groups)): + if newer_group(sources_groups[i], targets[i]): + n_sources.append(sources_groups[i]) + n_targets.append(targets[i]) + + return n_sources, n_targets diff --git a/poster/lib/python3.8/site-packages/setuptools/depends.py b/poster/lib/python3.8/site-packages/setuptools/depends.py new file mode 100644 index 0000000..a37675c --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools/depends.py @@ -0,0 +1,176 @@ +import sys +import marshal +import contextlib +from distutils.version import StrictVersion + +from .py33compat import Bytecode + +from .py27compat import find_module, PY_COMPILED, PY_FROZEN, PY_SOURCE +from . import py27compat + + +__all__ = [ + 'Require', 'find_module', 'get_module_constant', 'extract_constant' +] + + +class Require: + """A prerequisite to building or installing a distribution""" + + def __init__( + self, name, requested_version, module, homepage='', + attribute=None, format=None): + + if format is None and requested_version is not None: + format = StrictVersion + + if format is not None: + requested_version = format(requested_version) + if attribute is None: + attribute = '__version__' + + self.__dict__.update(locals()) + del self.self + + def full_name(self): + """Return full package/distribution name, w/version""" + if self.requested_version is not None: + return '%s-%s' % (self.name, self.requested_version) + return self.name + + def version_ok(self, version): + """Is 'version' sufficiently up-to-date?""" + return self.attribute is None or self.format is None or \ + str(version) != "unknown" and version >= self.requested_version + + def get_version(self, paths=None, default="unknown"): + """Get version number of installed module, 'None', or 'default' + + Search 'paths' for module. If not found, return 'None'. If found, + return the extracted version attribute, or 'default' if no version + attribute was specified, or the value cannot be determined without + importing the module. The version is formatted according to the + requirement's version format (if any), unless it is 'None' or the + supplied 'default'. + """ + + if self.attribute is None: + try: + f, p, i = find_module(self.module, paths) + if f: + f.close() + return default + except ImportError: + return None + + v = get_module_constant(self.module, self.attribute, default, paths) + + if v is not None and v is not default and self.format is not None: + return self.format(v) + + return v + + def is_present(self, paths=None): + """Return true if dependency is present on 'paths'""" + return self.get_version(paths) is not None + + def is_current(self, paths=None): + """Return true if dependency is present and up-to-date on 'paths'""" + version = self.get_version(paths) + if version is None: + return False + return self.version_ok(version) + + +def maybe_close(f): + @contextlib.contextmanager + def empty(): + yield + return + if not f: + return empty() + + return contextlib.closing(f) + + +def get_module_constant(module, symbol, default=-1, paths=None): + """Find 'module' by searching 'paths', and extract 'symbol' + + Return 'None' if 'module' does not exist on 'paths', or it does not define + 'symbol'. If the module defines 'symbol' as a constant, return the + constant. Otherwise, return 'default'.""" + + try: + f, path, (suffix, mode, kind) = info = find_module(module, paths) + except ImportError: + # Module doesn't exist + return None + + with maybe_close(f): + if kind == PY_COMPILED: + f.read(8) # skip magic & date + code = marshal.load(f) + elif kind == PY_FROZEN: + code = py27compat.get_frozen_object(module, paths) + elif kind == PY_SOURCE: + code = compile(f.read(), path, 'exec') + else: + # Not something we can parse; we'll have to import it. :( + imported = py27compat.get_module(module, paths, info) + return getattr(imported, symbol, None) + + return extract_constant(code, symbol, default) + + +def extract_constant(code, symbol, default=-1): + """Extract the constant value of 'symbol' from 'code' + + If the name 'symbol' is bound to a constant value by the Python code + object 'code', return that value. If 'symbol' is bound to an expression, + return 'default'. Otherwise, return 'None'. + + Return value is based on the first assignment to 'symbol'. 'symbol' must + be a global, or at least a non-"fast" local in the code block. That is, + only 'STORE_NAME' and 'STORE_GLOBAL' opcodes are checked, and 'symbol' + must be present in 'code.co_names'. + """ + if symbol not in code.co_names: + # name's not there, can't possibly be an assignment + return None + + name_idx = list(code.co_names).index(symbol) + + STORE_NAME = 90 + STORE_GLOBAL = 97 + LOAD_CONST = 100 + + const = default + + for byte_code in Bytecode(code): + op = byte_code.opcode + arg = byte_code.arg + + if op == LOAD_CONST: + const = code.co_consts[arg] + elif arg == name_idx and (op == STORE_NAME or op == STORE_GLOBAL): + return const + else: + const = default + + +def _update_globals(): + """ + Patch the globals to remove the objects not available on some platforms. + + XXX it'd be better to test assertions about bytecode instead. + """ + + if not sys.platform.startswith('java') and sys.platform != 'cli': + return + incompatible = 'extract_constant', 'get_module_constant' + for name in incompatible: + del globals()[name] + __all__.remove(name) + + +_update_globals() diff --git a/poster/lib/python3.8/site-packages/setuptools/errors.py b/poster/lib/python3.8/site-packages/setuptools/errors.py new file mode 100644 index 0000000..2701747 --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools/errors.py @@ -0,0 +1,16 @@ +"""setuptools.errors + +Provides exceptions used by setuptools modules. +""" + +from distutils.errors import DistutilsError + + +class RemovedCommandError(DistutilsError, RuntimeError): + """Error used for commands that have been removed in setuptools. + + Since ``setuptools`` is built on ``distutils``, simply removing a command + from ``setuptools`` will make the behavior fall back to ``distutils``; this + error is raised if a command exists in ``distutils`` but has been actively + removed in ``setuptools``. + """ diff --git a/poster/lib/python3.8/site-packages/setuptools/glob.py b/poster/lib/python3.8/site-packages/setuptools/glob.py new file mode 100644 index 0000000..9d7cbc5 --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools/glob.py @@ -0,0 +1,174 @@ +""" +Filename globbing utility. Mostly a copy of `glob` from Python 3.5. + +Changes include: + * `yield from` and PEP3102 `*` removed. + * Hidden files are not ignored. +""" + +import os +import re +import fnmatch + +__all__ = ["glob", "iglob", "escape"] + + +def glob(pathname, recursive=False): + """Return a list of paths matching a pathname pattern. + + The pattern may contain simple shell-style wildcards a la + fnmatch. However, unlike fnmatch, filenames starting with a + dot are special cases that are not matched by '*' and '?' + patterns. + + If recursive is true, the pattern '**' will match any files and + zero or more directories and subdirectories. + """ + return list(iglob(pathname, recursive=recursive)) + + +def iglob(pathname, recursive=False): + """Return an iterator which yields the paths matching a pathname pattern. + + The pattern may contain simple shell-style wildcards a la + fnmatch. However, unlike fnmatch, filenames starting with a + dot are special cases that are not matched by '*' and '?' + patterns. + + If recursive is true, the pattern '**' will match any files and + zero or more directories and subdirectories. + """ + it = _iglob(pathname, recursive) + if recursive and _isrecursive(pathname): + s = next(it) # skip empty string + assert not s + return it + + +def _iglob(pathname, recursive): + dirname, basename = os.path.split(pathname) + if not has_magic(pathname): + if basename: + if os.path.lexists(pathname): + yield pathname + else: + # Patterns ending with a slash should match only directories + if os.path.isdir(dirname): + yield pathname + return + if not dirname: + if recursive and _isrecursive(basename): + for x in glob2(dirname, basename): + yield x + else: + for x in glob1(dirname, basename): + yield x + return + # `os.path.split()` returns the argument itself as a dirname if it is a + # drive or UNC path. Prevent an infinite recursion if a drive or UNC path + # contains magic characters (i.e. r'\\?\C:'). + if dirname != pathname and has_magic(dirname): + dirs = _iglob(dirname, recursive) + else: + dirs = [dirname] + if has_magic(basename): + if recursive and _isrecursive(basename): + glob_in_dir = glob2 + else: + glob_in_dir = glob1 + else: + glob_in_dir = glob0 + for dirname in dirs: + for name in glob_in_dir(dirname, basename): + yield os.path.join(dirname, name) + + +# These 2 helper functions non-recursively glob inside a literal directory. +# They return a list of basenames. `glob1` accepts a pattern while `glob0` +# takes a literal basename (so it only has to check for its existence). + + +def glob1(dirname, pattern): + if not dirname: + if isinstance(pattern, bytes): + dirname = os.curdir.encode('ASCII') + else: + dirname = os.curdir + try: + names = os.listdir(dirname) + except OSError: + return [] + return fnmatch.filter(names, pattern) + + +def glob0(dirname, basename): + if not basename: + # `os.path.split()` returns an empty basename for paths ending with a + # directory separator. 'q*x/' should match only directories. + if os.path.isdir(dirname): + return [basename] + else: + if os.path.lexists(os.path.join(dirname, basename)): + return [basename] + return [] + + +# This helper function recursively yields relative pathnames inside a literal +# directory. + + +def glob2(dirname, pattern): + assert _isrecursive(pattern) + yield pattern[:0] + for x in _rlistdir(dirname): + yield x + + +# Recursively yields relative pathnames inside a literal directory. +def _rlistdir(dirname): + if not dirname: + if isinstance(dirname, bytes): + dirname = os.curdir.encode('ASCII') + else: + dirname = os.curdir + try: + names = os.listdir(dirname) + except os.error: + return + for x in names: + yield x + path = os.path.join(dirname, x) if dirname else x + for y in _rlistdir(path): + yield os.path.join(x, y) + + +magic_check = re.compile('([*?[])') +magic_check_bytes = re.compile(b'([*?[])') + + +def has_magic(s): + if isinstance(s, bytes): + match = magic_check_bytes.search(s) + else: + match = magic_check.search(s) + return match is not None + + +def _isrecursive(pattern): + if isinstance(pattern, bytes): + return pattern == b'**' + else: + return pattern == '**' + + +def escape(pathname): + """Escape all special characters. + """ + # Escaping is done by wrapping any of "*?[" between square brackets. + # Metacharacters do not work in the drive part and shouldn't be escaped. + drive, pathname = os.path.splitdrive(pathname) + if isinstance(pathname, bytes): + pathname = magic_check_bytes.sub(br'[\1]', pathname) + else: + pathname = magic_check.sub(r'[\1]', pathname) + return drive + pathname diff --git a/poster/lib/python3.8/site-packages/setuptools/gui-64.exe b/poster/lib/python3.8/site-packages/setuptools/gui-64.exe new file mode 100644 index 0000000..330c51a Binary files /dev/null and b/poster/lib/python3.8/site-packages/setuptools/gui-64.exe differ diff --git a/poster/lib/python3.8/site-packages/setuptools/monkey.py b/poster/lib/python3.8/site-packages/setuptools/monkey.py new file mode 100644 index 0000000..3c77f8c --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools/monkey.py @@ -0,0 +1,179 @@ +""" +Monkey patching of distutils. +""" + +import sys +import distutils.filelist +import platform +import types +import functools +from importlib import import_module +import inspect + +from setuptools.extern import six + +import setuptools + +__all__ = [] +""" +Everything is private. Contact the project team +if you think you need this functionality. +""" + + +def _get_mro(cls): + """ + Returns the bases classes for cls sorted by the MRO. + + Works around an issue on Jython where inspect.getmro will not return all + base classes if multiple classes share the same name. Instead, this + function will return a tuple containing the class itself, and the contents + of cls.__bases__. See https://github.com/pypa/setuptools/issues/1024. + """ + if platform.python_implementation() == "Jython": + return (cls,) + cls.__bases__ + return inspect.getmro(cls) + + +def get_unpatched(item): + lookup = ( + get_unpatched_class if isinstance(item, six.class_types) else + get_unpatched_function if isinstance(item, types.FunctionType) else + lambda item: None + ) + return lookup(item) + + +def get_unpatched_class(cls): + """Protect against re-patching the distutils if reloaded + + Also ensures that no other distutils extension monkeypatched the distutils + first. + """ + external_bases = ( + cls + for cls in _get_mro(cls) + if not cls.__module__.startswith('setuptools') + ) + base = next(external_bases) + if not base.__module__.startswith('distutils'): + msg = "distutils has already been patched by %r" % cls + raise AssertionError(msg) + return base + + +def patch_all(): + # we can't patch distutils.cmd, alas + distutils.core.Command = setuptools.Command + + has_issue_12885 = sys.version_info <= (3, 5, 3) + + if has_issue_12885: + # fix findall bug in distutils (http://bugs.python.org/issue12885) + distutils.filelist.findall = setuptools.findall + + needs_warehouse = ( + sys.version_info < (2, 7, 13) + or + (3, 4) < sys.version_info < (3, 4, 6) + or + (3, 5) < sys.version_info <= (3, 5, 3) + ) + + if needs_warehouse: + warehouse = 'https://upload.pypi.org/legacy/' + distutils.config.PyPIRCCommand.DEFAULT_REPOSITORY = warehouse + + _patch_distribution_metadata() + + # Install Distribution throughout the distutils + for module in distutils.dist, distutils.core, distutils.cmd: + module.Distribution = setuptools.dist.Distribution + + # Install the patched Extension + distutils.core.Extension = setuptools.extension.Extension + distutils.extension.Extension = setuptools.extension.Extension + if 'distutils.command.build_ext' in sys.modules: + sys.modules['distutils.command.build_ext'].Extension = ( + setuptools.extension.Extension + ) + + patch_for_msvc_specialized_compiler() + + +def _patch_distribution_metadata(): + """Patch write_pkg_file and read_pkg_file for higher metadata standards""" + for attr in ('write_pkg_file', 'read_pkg_file', 'get_metadata_version'): + new_val = getattr(setuptools.dist, attr) + setattr(distutils.dist.DistributionMetadata, attr, new_val) + + +def patch_func(replacement, target_mod, func_name): + """ + Patch func_name in target_mod with replacement + + Important - original must be resolved by name to avoid + patching an already patched function. + """ + original = getattr(target_mod, func_name) + + # set the 'unpatched' attribute on the replacement to + # point to the original. + vars(replacement).setdefault('unpatched', original) + + # replace the function in the original module + setattr(target_mod, func_name, replacement) + + +def get_unpatched_function(candidate): + return getattr(candidate, 'unpatched') + + +def patch_for_msvc_specialized_compiler(): + """ + Patch functions in distutils to use standalone Microsoft Visual C++ + compilers. + """ + # import late to avoid circular imports on Python < 3.5 + msvc = import_module('setuptools.msvc') + + if platform.system() != 'Windows': + # Compilers only availables on Microsoft Windows + return + + def patch_params(mod_name, func_name): + """ + Prepare the parameters for patch_func to patch indicated function. + """ + repl_prefix = 'msvc9_' if 'msvc9' in mod_name else 'msvc14_' + repl_name = repl_prefix + func_name.lstrip('_') + repl = getattr(msvc, repl_name) + mod = import_module(mod_name) + if not hasattr(mod, func_name): + raise ImportError(func_name) + return repl, mod, func_name + + # Python 2.7 to 3.4 + msvc9 = functools.partial(patch_params, 'distutils.msvc9compiler') + + # Python 3.5+ + msvc14 = functools.partial(patch_params, 'distutils._msvccompiler') + + try: + # Patch distutils.msvc9compiler + patch_func(*msvc9('find_vcvarsall')) + patch_func(*msvc9('query_vcvarsall')) + except ImportError: + pass + + try: + # Patch distutils._msvccompiler._get_vc_env + patch_func(*msvc14('_get_vc_env')) + except ImportError: + pass + + try: + # Patch distutils._msvccompiler.gen_lib_options for Numpy + patch_func(*msvc14('gen_lib_options')) + except ImportError: + pass diff --git a/poster/lib/python3.8/site-packages/setuptools/py27compat.py b/poster/lib/python3.8/site-packages/setuptools/py27compat.py new file mode 100644 index 0000000..1d57360 --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools/py27compat.py @@ -0,0 +1,60 @@ +""" +Compatibility Support for Python 2.7 and earlier +""" + +import sys +import platform + +from setuptools.extern import six + + +def get_all_headers(message, key): + """ + Given an HTTPMessage, return all headers matching a given key. + """ + return message.get_all(key) + + +if six.PY2: + def get_all_headers(message, key): + return message.getheaders(key) + + +linux_py2_ascii = ( + platform.system() == 'Linux' and + six.PY2 +) + +rmtree_safe = str if linux_py2_ascii else lambda x: x +"""Workaround for http://bugs.python.org/issue24672""" + + +try: + from ._imp import find_module, PY_COMPILED, PY_FROZEN, PY_SOURCE + from ._imp import get_frozen_object, get_module +except ImportError: + import imp + from imp import PY_COMPILED, PY_FROZEN, PY_SOURCE # noqa + + def find_module(module, paths=None): + """Just like 'imp.find_module()', but with package support""" + parts = module.split('.') + while parts: + part = parts.pop(0) + f, path, (suffix, mode, kind) = info = imp.find_module(part, paths) + + if kind == imp.PKG_DIRECTORY: + parts = parts or ['__init__'] + paths = [path] + + elif parts: + raise ImportError("Can't find %r in %s" % (parts, module)) + + return info + + def get_frozen_object(module, paths): + return imp.get_frozen_object(module) + + def get_module(module, paths, info): + imp.load_module(module, *info) + return sys.modules[module] diff --git a/poster/lib/python3.8/site-packages/setuptools/py31compat.py b/poster/lib/python3.8/site-packages/setuptools/py31compat.py new file mode 100644 index 0000000..e1da7ee --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools/py31compat.py @@ -0,0 +1,32 @@ +__all__ = [] + +__metaclass__ = type + + +try: + # Python >=3.2 + from tempfile import TemporaryDirectory +except ImportError: + import shutil + import tempfile + + class TemporaryDirectory: + """ + Very simple temporary directory context manager. + Will try to delete afterward, but will also ignore OS and similar + errors on deletion. + """ + + def __init__(self, **kwargs): + self.name = None # Handle mkdtemp raising an exception + self.name = tempfile.mkdtemp(**kwargs) + + def __enter__(self): + return self.name + + def __exit__(self, exctype, excvalue, exctrace): + try: + shutil.rmtree(self.name, True) + except OSError: # removal errors are not the only possible + pass + self.name = None diff --git a/poster/lib/python3.8/site-packages/setuptools/py34compat.py b/poster/lib/python3.8/site-packages/setuptools/py34compat.py new file mode 100644 index 0000000..3ad9172 --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools/py34compat.py @@ -0,0 +1,13 @@ +import importlib + +try: + import importlib.util +except ImportError: + pass + + +try: + module_from_spec = importlib.util.module_from_spec +except AttributeError: + def module_from_spec(spec): + return spec.loader.load_module(spec.name) diff --git a/poster/lib/python3.8/site-packages/setuptools/script (dev).tmpl b/poster/lib/python3.8/site-packages/setuptools/script (dev).tmpl new file mode 100644 index 0000000..39a24b0 --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools/script (dev).tmpl @@ -0,0 +1,6 @@ +# EASY-INSTALL-DEV-SCRIPT: %(spec)r,%(script_name)r +__requires__ = %(spec)r +__import__('pkg_resources').require(%(spec)r) +__file__ = %(dev_path)r +with open(__file__) as f: + exec(compile(f.read(), __file__, 'exec')) diff --git a/poster/lib/python3.8/site-packages/setuptools/script.tmpl b/poster/lib/python3.8/site-packages/setuptools/script.tmpl new file mode 100644 index 0000000..ff5efbc --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools/script.tmpl @@ -0,0 +1,3 @@ +# EASY-INSTALL-SCRIPT: %(spec)r,%(script_name)r +__requires__ = %(spec)r +__import__('pkg_resources').run_script(%(spec)r, %(script_name)r) diff --git a/poster/lib/python3.8/site-packages/setuptools/site-patch.py b/poster/lib/python3.8/site-packages/setuptools/site-patch.py new file mode 100644 index 0000000..40b00de --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools/site-patch.py @@ -0,0 +1,74 @@ +def __boot(): + import sys + import os + PYTHONPATH = os.environ.get('PYTHONPATH') + if PYTHONPATH is None or (sys.platform == 'win32' and not PYTHONPATH): + PYTHONPATH = [] + else: + PYTHONPATH = PYTHONPATH.split(os.pathsep) + + pic = getattr(sys, 'path_importer_cache', {}) + stdpath = sys.path[len(PYTHONPATH):] + mydir = os.path.dirname(__file__) + + for item in stdpath: + if item == mydir or not item: + continue # skip if current dir. on Windows, or my own directory + importer = pic.get(item) + if importer is not None: + loader = importer.find_module('site') + if loader is not None: + # This should actually reload the current module + loader.load_module('site') + break + else: + try: + import imp # Avoid import loop in Python 3 + stream, path, descr = imp.find_module('site', [item]) + except ImportError: + continue + if stream is None: + continue + try: + # This should actually reload the current module + imp.load_module('site', stream, path, descr) + finally: + stream.close() + break + else: + raise ImportError("Couldn't find the real 'site' module") + + known_paths = dict([(makepath(item)[1], 1) for item in sys.path]) # 2.2 comp + + oldpos = getattr(sys, '__egginsert', 0) # save old insertion position + sys.__egginsert = 0 # and reset the current one + + for item in PYTHONPATH: + addsitedir(item) + + sys.__egginsert += oldpos # restore effective old position + + d, nd = makepath(stdpath[0]) + insert_at = None + new_path = [] + + for item in sys.path: + p, np = makepath(item) + + if np == nd and insert_at is None: + # We've hit the first 'system' path entry, so added entries go here + insert_at = len(new_path) + + if np in known_paths or insert_at is None: + new_path.append(item) + else: + # new path after the insert point, back-insert it + new_path.insert(insert_at, item) + insert_at += 1 + + sys.path[:] = new_path + + +if __name__ == 'site': + __boot() + del __boot diff --git a/poster/lib/python3.8/site-packages/setuptools/unicode_utils.py b/poster/lib/python3.8/site-packages/setuptools/unicode_utils.py new file mode 100644 index 0000000..7c63efd --- /dev/null +++ b/poster/lib/python3.8/site-packages/setuptools/unicode_utils.py @@ -0,0 +1,44 @@ +import unicodedata +import sys + +from setuptools.extern import six + + +# HFS Plus uses decomposed UTF-8 +def decompose(path): + if isinstance(path, six.text_type): + return unicodedata.normalize('NFD', path) + try: + path = path.decode('utf-8') + path = unicodedata.normalize('NFD', path) + path = path.encode('utf-8') + except UnicodeError: + pass # Not UTF-8 + return path + + +def filesys_decode(path): + """ + Ensure that the given path is decoded, + NONE when no expected encoding works + """ + + if isinstance(path, six.text_type): + return path + + fs_enc = sys.getfilesystemencoding() or 'utf-8' + candidates = fs_enc, 'utf-8' + + for enc in candidates: + try: + return path.decode(enc) + except UnicodeDecodeError: + continue + + +def try_encode(string, enc): + "turn unicode encoding into a functional routine" + try: + return string.encode(enc) + except UnicodeEncodeError: + return None diff --git a/poster/lib64 b/poster/lib64 new file mode 120000 index 0000000..7951405 --- /dev/null +++ b/poster/lib64 @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/poster/pyvenv.cfg b/poster/pyvenv.cfg new file mode 100644 index 0000000..f13958f --- /dev/null +++ b/poster/pyvenv.cfg @@ -0,0 +1,3 @@ +home = /bin +include-system-site-packages = false +version = 3.8.10 diff --git a/poster/share/python-wheels/CacheControl-0.12.6-py2.py3-none-any.whl b/poster/share/python-wheels/CacheControl-0.12.6-py2.py3-none-any.whl new file mode 100644 index 0000000..e16a419 Binary files /dev/null and b/poster/share/python-wheels/CacheControl-0.12.6-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/appdirs-1.4.3-py2.py3-none-any.whl b/poster/share/python-wheels/appdirs-1.4.3-py2.py3-none-any.whl new file mode 100644 index 0000000..9f828eb Binary files /dev/null and b/poster/share/python-wheels/appdirs-1.4.3-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/certifi-2019.11.28-py2.py3-none-any.whl b/poster/share/python-wheels/certifi-2019.11.28-py2.py3-none-any.whl new file mode 100644 index 0000000..b483ad9 Binary files /dev/null and b/poster/share/python-wheels/certifi-2019.11.28-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/chardet-3.0.4-py2.py3-none-any.whl b/poster/share/python-wheels/chardet-3.0.4-py2.py3-none-any.whl new file mode 100644 index 0000000..b27d317 Binary files /dev/null and b/poster/share/python-wheels/chardet-3.0.4-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/colorama-0.4.3-py2.py3-none-any.whl b/poster/share/python-wheels/colorama-0.4.3-py2.py3-none-any.whl new file mode 100644 index 0000000..0154dc2 Binary files /dev/null and b/poster/share/python-wheels/colorama-0.4.3-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/contextlib2-0.6.0-py2.py3-none-any.whl b/poster/share/python-wheels/contextlib2-0.6.0-py2.py3-none-any.whl new file mode 100644 index 0000000..5585d5e Binary files /dev/null and b/poster/share/python-wheels/contextlib2-0.6.0-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/distlib-0.3.0-py2.py3-none-any.whl b/poster/share/python-wheels/distlib-0.3.0-py2.py3-none-any.whl new file mode 100644 index 0000000..f73955c Binary files /dev/null and b/poster/share/python-wheels/distlib-0.3.0-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/distro-1.4.0-py2.py3-none-any.whl b/poster/share/python-wheels/distro-1.4.0-py2.py3-none-any.whl new file mode 100644 index 0000000..1ef0a86 Binary files /dev/null and b/poster/share/python-wheels/distro-1.4.0-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/html5lib-1.0.1-py2.py3-none-any.whl b/poster/share/python-wheels/html5lib-1.0.1-py2.py3-none-any.whl new file mode 100644 index 0000000..8447999 Binary files /dev/null and b/poster/share/python-wheels/html5lib-1.0.1-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/idna-2.8-py2.py3-none-any.whl b/poster/share/python-wheels/idna-2.8-py2.py3-none-any.whl new file mode 100644 index 0000000..5bbce45 Binary files /dev/null and b/poster/share/python-wheels/idna-2.8-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/ipaddr-2.2.0-py2.py3-none-any.whl b/poster/share/python-wheels/ipaddr-2.2.0-py2.py3-none-any.whl new file mode 100644 index 0000000..faade35 Binary files /dev/null and b/poster/share/python-wheels/ipaddr-2.2.0-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/lockfile-0.12.2-py2.py3-none-any.whl b/poster/share/python-wheels/lockfile-0.12.2-py2.py3-none-any.whl new file mode 100644 index 0000000..e1b4272 Binary files /dev/null and b/poster/share/python-wheels/lockfile-0.12.2-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/msgpack-0.6.2-py2.py3-none-any.whl b/poster/share/python-wheels/msgpack-0.6.2-py2.py3-none-any.whl new file mode 100644 index 0000000..eab16fc Binary files /dev/null and b/poster/share/python-wheels/msgpack-0.6.2-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/packaging-20.3-py2.py3-none-any.whl b/poster/share/python-wheels/packaging-20.3-py2.py3-none-any.whl new file mode 100644 index 0000000..0e9f26a Binary files /dev/null and b/poster/share/python-wheels/packaging-20.3-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/pep517-0.8.2-py2.py3-none-any.whl b/poster/share/python-wheels/pep517-0.8.2-py2.py3-none-any.whl new file mode 100644 index 0000000..f59b17b Binary files /dev/null and b/poster/share/python-wheels/pep517-0.8.2-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/pip-20.0.2-py2.py3-none-any.whl b/poster/share/python-wheels/pip-20.0.2-py2.py3-none-any.whl new file mode 100644 index 0000000..3f690ff Binary files /dev/null and b/poster/share/python-wheels/pip-20.0.2-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/pkg_resources-0.0.0-py2.py3-none-any.whl b/poster/share/python-wheels/pkg_resources-0.0.0-py2.py3-none-any.whl new file mode 100644 index 0000000..da0c3e8 Binary files /dev/null and b/poster/share/python-wheels/pkg_resources-0.0.0-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/progress-1.5-py2.py3-none-any.whl b/poster/share/python-wheels/progress-1.5-py2.py3-none-any.whl new file mode 100644 index 0000000..ece6b7c Binary files /dev/null and b/poster/share/python-wheels/progress-1.5-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/pyparsing-2.4.6-py2.py3-none-any.whl b/poster/share/python-wheels/pyparsing-2.4.6-py2.py3-none-any.whl new file mode 100644 index 0000000..dffab64 Binary files /dev/null and b/poster/share/python-wheels/pyparsing-2.4.6-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/requests-2.22.0-py2.py3-none-any.whl b/poster/share/python-wheels/requests-2.22.0-py2.py3-none-any.whl new file mode 100644 index 0000000..86327e5 Binary files /dev/null and b/poster/share/python-wheels/requests-2.22.0-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/retrying-1.3.3-py2.py3-none-any.whl b/poster/share/python-wheels/retrying-1.3.3-py2.py3-none-any.whl new file mode 100644 index 0000000..5c9ca2a Binary files /dev/null and b/poster/share/python-wheels/retrying-1.3.3-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/setuptools-44.0.0-py2.py3-none-any.whl b/poster/share/python-wheels/setuptools-44.0.0-py2.py3-none-any.whl new file mode 100644 index 0000000..ce69c02 Binary files /dev/null and b/poster/share/python-wheels/setuptools-44.0.0-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/six-1.14.0-py2.py3-none-any.whl b/poster/share/python-wheels/six-1.14.0-py2.py3-none-any.whl new file mode 100644 index 0000000..2ec73f5 Binary files /dev/null and b/poster/share/python-wheels/six-1.14.0-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/toml-0.10.0-py2.py3-none-any.whl b/poster/share/python-wheels/toml-0.10.0-py2.py3-none-any.whl new file mode 100644 index 0000000..4f393f9 Binary files /dev/null and b/poster/share/python-wheels/toml-0.10.0-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/urllib3-1.25.8-py2.py3-none-any.whl b/poster/share/python-wheels/urllib3-1.25.8-py2.py3-none-any.whl new file mode 100644 index 0000000..a93aab6 Binary files /dev/null and b/poster/share/python-wheels/urllib3-1.25.8-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/webencodings-0.5.1-py2.py3-none-any.whl b/poster/share/python-wheels/webencodings-0.5.1-py2.py3-none-any.whl new file mode 100644 index 0000000..c256daf Binary files /dev/null and b/poster/share/python-wheels/webencodings-0.5.1-py2.py3-none-any.whl differ diff --git a/poster/share/python-wheels/wheel-0.34.2-py2.py3-none-any.whl b/poster/share/python-wheels/wheel-0.34.2-py2.py3-none-any.whl new file mode 100644 index 0000000..519acb1 Binary files /dev/null and b/poster/share/python-wheels/wheel-0.34.2-py2.py3-none-any.whl differ diff --git a/requirements.txt b/requirements.txt index 54cce8c..cc11eef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,34 @@ -atproto==0.0.30 -Mastodon.py==1.8.0 +annotated-types==0.6.0 +anyio==4.2.0 +arrow==1.3.0 +atproto==0.0.37 +blurhash==1.1.4 +certifi==2023.11.17 +cffi==1.16.0 +charset-normalizer==3.3.2 +click==8.1.7 +cryptography==41.0.7 +decorator==5.1.1 +dnspython==2.4.2 +exceptiongroup==1.2.0 +h11==0.14.0 +httpcore==1.0.2 +httpx==0.25.2 +idna==3.6 +libipld==1.1.0 +Mastodon.py==1.8.1 +oauthlib==3.2.2 +pycparser==2.21 +pydantic==2.5.3 +pydantic-core==2.14.6 +python-dateutil==2.8.2 +python-magic==0.4.27 +requests==2.31.0 +requests-oauthlib==1.3.1 +six==1.16.0 +sniffio==1.3.0 tweepy==4.14.0 +types-python-dateutil==2.8.19.20240106 +typing-extensions==4.9.0 +urllib3==2.1.0 +websockets==12.0 \ No newline at end of file diff --git a/settings.py b/settings.py deleted file mode 100644 index dd69666..0000000 --- a/settings.py +++ /dev/null @@ -1,44 +0,0 @@ -import os - -# Enables/disables crossposting to twitter and mastodon -# Accepted values: True, False -Twitter = True -Mastodon = True -# Enables/disables logging -# Accepted values: True, False -Logging = True -# Sets default posting mode. True means all posts will be crossposted unless otherwise specified, -# False means no posts will be crossposted unless explicitly specified. If no toggle (below) is specified -# postDefault will be treated as True no matter what is set. -# Accepted values: True, False -postDefault = True -# The function to select what posts are crossposted (mis)uses the language function in Bluesky. -# Enter a language here and all posts will be filtered based on if that language is included -# in the post. -# E.g. if you set postDefault to True and add German ("de") as post toggle, all posts including -# German as a language will be skipped. If postDefault is set to False, only posts including -# german will be crossposted. You can use different languages as selectors for Mastodon -# and Twitter. You can have both the actual language of the tweet, and the selector language -# added to the tweet and it will still work. -# Accepted values: Any language tag in quotes (https://en.wikipedia.org/wiki/IETF_language_tag) -mastodonLang = "" -twitterLang = "" -# Sets maximum amount of times poster will retry a failed crosspost. -maxRetries = 5 -# Sets max time limit (in hours) for fetching posts. If no database exists, all posts within this time -# period will be posted. -postTimeLimit = 12 -# mastodonVisibility sets what visibility should be used when posting to Mastodon. Options are "public" for always public, "unlisted" for always unlisted, -# "private" for always private and "hybrid" for all posts public except responses in threads (meaning first post in a thread is public and the rest unlisted). -mastodonVisibility = "hybrid" - -# Override settings with environment variables if they exist -Twitter = os.environ.get('TWITTER_CROSSPOSTING').lower() == 'true' if os.environ.get('TWITTER_CROSSPOSTING') else Twitter -Mastodon = os.environ.get('MASTODON_CROSSPOSTING').lower() == 'true' if os.environ.get('MASTODON_CROSSPOSTING') else Mastodon -Logging = os.environ.get('LOGGING').lower() == 'true' if os.environ.get('LOGGING') else Logging -postDefault = os.environ.get('POST_DEFAULT').lower() == 'true' if os.environ.get('POST_DEFAULT') else postDefault -mastodonLang = os.environ.get('MASTODON_LANG') if os.environ.get('MASTODON_LANG') else mastodonLang -twitterLang = os.environ.get('TWITTER_LANG') if os.environ.get('TWITTER_LANG') else twitterLang -maxRetries = int(os.environ.get('MAX_RETRIES')) if os.environ.get('MAX_RETRIES') else maxRetries -postTimeLimit = int(os.environ.get('POST_TIME_LIMIT')) if os.environ.get('POST_TIME_LIMIT') else postTimeLimit -mastodonVisibility = os.environ.get('MASTODON_VISIBILITY') if os.environ.get('MASTODON_VISIBILITY') else mastodonVisibility diff --git a/auth.py b/settings/auth.py similarity index 74% rename from auth.py rename to settings/auth.py index 2da5cf9..a06de12 100644 --- a/auth.py +++ b/settings/auth.py @@ -2,10 +2,12 @@ import os # All necessary tokens, passwords, etc. # Your bluesky handle should include your instance, so for example handle.bsky.social if you are on the main one. -bsky_handle = "" +BSKY_HANDLE = "" # Generate an app password in the settings on bluesky. DO NOT use your main password. -bsky_password = "" -# The mastodon instance your account is on +BSKY_PASSWORD = "" +# Your mastodon handle. Not needed for authentication, but used for making "quote posts". +MASTODON_HANDLE = "" +# The mastodon instance your account is on. MASTODON_INSTANCE = "" # Generate your token in the development settings on your mastodon account. Token must have the permissions to # post statuses (write:statuses) @@ -18,9 +20,10 @@ TWITTER_ACCESS_TOKEN = "" TWITTER_ACCESS_TOKEN_SECRET = "" # Override settings with environment variables if they exist -bsky_handle = os.environ.get('BSKY_HANDLE') if os.environ.get('BSKY_HANDLE') else bsky_handle -bsky_password = os.environ.get('BSKY_PASSWORD') if os.environ.get('BSKY_PASSWORD') else bsky_password +BSKY_HANDLE = os.environ.get('BSKY_HANDLE') if os.environ.get('BSKY_HANDLE') else BSKY_HANDLE +BSKY_PASSWORD = os.environ.get('BSKY_PASSWORD') if os.environ.get('BSKY_PASSWORD') else BSKY_PASSWORD MASTODON_INSTANCE = os.environ.get('MASTODON_INSTANCE') if os.environ.get('MASTODON_INSTANCE') else MASTODON_INSTANCE +MASTODON_HANDLE = os.environ.get('MASTODON_HANDLE') if os.environ.get('MASTODON_HANDLE') else MASTODON_HANDLE MASTODON_TOKEN = os.environ.get('MASTODON_TOKEN') if os.environ.get('MASTODON_TOKEN') else MASTODON_TOKEN TWITTER_APP_KEY = os.environ.get('TWITTER_APP_KEY') if os.environ.get('TWITTER_APP_KEY') else TWITTER_APP_KEY TWITTER_APP_SECRET = os.environ.get('TWITTER_APP_SECRET') if os.environ.get('TWITTER_APP_SECRET') else TWITTER_APP_SECRET diff --git a/paths.py b/settings/paths.py similarity index 50% rename from paths.py rename to settings/paths.py index db6543f..f92fe22 100644 --- a/paths.py +++ b/settings/paths.py @@ -1,14 +1,17 @@ # This file contains all necessary file and folder paths. Make sure to end folder paths with "/". -# basePath is the path from root to the lowest common denominator for all of the other paths. +# base_path is the path from root to the lowest common denominator for all of the other paths. # Using an absolute path is especially important if running via cron. -basePath = "/" +base_path = "./" # Path to the database file. If you want it somewhere other than directly in the base path you can # either write the entire path manually, or just add the rest of the path on top of the basePath. -databasePath = basePath + "db/" + "database.json" +database_path = base_path + "db/database.json" +# Path to the cache-file, which keeps track of recent posts, allowing you to limit posts per hours and +# retweet yourself +post_cache_path = base_path + "db/post.cache" # Path to backup of database. -backupPath = basePath + "db/" + "database.bak" +backup_path = base_path + "backup/" + "database.bak" # Path for storing logs -logPath = basePath + "logs/" +log_path = base_path + "logs/" # Path to folder for temporary storage of images -imagePath = basePath + "images/" +image_path = base_path + "images/" diff --git a/settings/settings.py b/settings/settings.py new file mode 100644 index 0000000..92752ef --- /dev/null +++ b/settings/settings.py @@ -0,0 +1,78 @@ +import os + +# Enables/disables crossposting to twitter and mastodon +# Accepted values: True, False +Twitter = True +Mastodon = True +# log_level determines what messages will be written to the log. +# "error" means only error messages will be written to the log. +# "verbose" means all messages will be written to the log. +# "none" means no messages will be written to the log (not recommended). +# Accepted values: error, verbose, none +log_level = "verbose" +# visibility sets what visibility should be used when posting to Mastodon. Options are "public" for always public, "unlisted" for always unlisted, +# "private" for always private and "hybrid" for all posts public except responses in threads (meaning first post in a thread is public and the rest unlisted). +# Accepted values: public, private, hybrid +visibility = "hybrid" +# mentions set what is to be done with posts containing a mention of another user. Options are "ignore", +# for crossposting with no change, "skip" for skipping posts with mentions, "strip" for removing +# the starting @ of a username and "url" to replace the username with a link to their bluesky profile. +# Accepted values: ignore, skip, strip, url +mentions = "strip" +# post_default sets default posting mode. True means all posts will be crossposted unless otherwise specified, +# False means no posts will be crossposted unless explicitly specified. If no toggle (below) is specified +# post_default will be treated as True no matter what is set. +# Accepted values: True, False +post_default = True +# The function to select what posts are crossposted (mis)uses the language function in Bluesky. +# Enter a language here and all posts will be filtered based on if that language is included +# in the post. +# E.g. if you set post_default to True and add German ("de") as post toggle, all posts including +# German as a language will be skipped. If post_default is set to False, only posts including +# german will be crossposted. You can use different languages as selectors for Mastodon +# and Twitter. You can have both the actual language of the tweet, and the selector language +# added to the tweet and it will still work. +# Accepted values: Any language tag in quotes (https://en.wikipedia.org/wiki/IETF_language_tag) +mastodon_lang = "" +twitter_lang = "" +# quote_posts determines if quote reposts of other posts should be crossposted with the quoted post included as a link. If False these posts will be ignored. +quote_posts = True +# max_retries sets maximum amount of times poster will retry a failed crosspost. +# Accepted values: Integers greater than 0 +max_retries = 5 +# post_time_limit sets max time limit (in hours) for fetching posts. If no database exists, all posts within this time +# period will be posted. +# Accepted values: Integers greater than 0 +post_time_limit = 12 +# max_per_hour limits the amount of posts that can be crossposted withing an hour. 0 means no limit. +# Accepted values: Any integer +max_per_hour = 0 +# overflow_posts determines what happens to posts that are not crossposted due to the hourly limit. +# If set to "retry" the poster will attempt to send them again when posts per hour are below the limit. +# If set to "skip" the posts will be skipped and the poster will instead continue on with new posts. +# Accepted values: retry, skip +overflow_posts = "retry" + + + +# Override settings with environment variables if they exist +Twitter = os.environ.get('TWITTER_CROSSPOSTING').lower() == 'true' if os.environ.get('TWITTER_CROSSPOSTING') else Twitter +Mastodon = os.environ.get('MASTODON_CROSSPOSTING').lower() == 'true' if os.environ.get('MASTODON_CROSSPOSTING') else Mastodon +log_level = os.environ.get('LOG_LEVEL').lower() == 'true' if os.environ.get('LOG_LEVEL') else log_level +visibility = os.environ.get('MASTODON_VISIBILITY') if os.environ.get('MASTODON_VISIBILITY') else visibility +mentions = os.environ.get('MENTIONS') if os.environ.get('MENTIONS') else mentions +post_default = os.environ.get('POST_DEFAULT').lower() == 'true' if os.environ.get('POST_DEFAULT') else post_default +mastodon_lang = os.environ.get('MASTODON_LANG') if os.environ.get('MASTODON_LANG') else mastodon_lang +twitter_lang = os.environ.get('TWITTER_LANG') if os.environ.get('TWITTER_LANG') else twitter_lang +quote_posts = os.environ.get('QUOTE_POSTS') if os.environ.get('QUOTE_POSTS') else quote_posts +max_retries = int(os.environ.get('MAX_RETRIES')) if os.environ.get('MAX_RETRIES') else max_retries +post_time_limit = int(os.environ.get('POST_TIME_LIMIT')) if os.environ.get('POST_TIME_LIMIT') else post_time_limit +max_per_hour = int(os.environ.get('MAX_PER_HOUR')) if os.environ.get('MAX_PER_HOUR') else max_per_hour +overflow_posts = int(os.environ.get('OVERFLOW_POST')) if os.environ.get('OVERFLOW_POST') else overflow_posts + + + + + +max_per_hour = 0 +over_flow_posts = "retry" \ No newline at end of file