From 2e01a4db14c6ff9d0a45097e798c5c0b9cc963a9 Mon Sep 17 00:00:00 2001 From: lordwelch Date: Sat, 7 Aug 2021 22:07:10 -0700 Subject: [PATCH] Improve file renaming Moves to Python format strings for renaming, handles directory structures, moving of files to a destination directory, sanitizes file paths with pathvalidate and takes a different approach to smart filename cleanup using the Python string.Formatter class Moving to Python format strings means we can point to python documentation for syntax and all we have to do is document the properties and types that are attached to the GenericMetadata class. Switching to pathvalidate allows comictagger to more simply handle both directories and symbols in filenames. The only changes to the string.Formatter class is: 1. format_field returns an empty string if the value is none or an empty string regardless of the format specifier. 2. _vformat drops the previous literal text if the field value is an empty string and lstrips the following literal text of closing special characters. --- comictaggerlib/cli.py | 20 ++- comictaggerlib/filerenamer.py | 192 ++++++++++++++-------------- comictaggerlib/renamewindow.py | 28 +++- comictaggerlib/settings.py | 10 +- comictaggerlib/settingswindow.py | 96 ++++++++++++++ comictaggerlib/ui/TemplateHelp.ui | 107 ++++++++++++++++ comictaggerlib/ui/settingswindow.ui | 107 +++++++++++++--- requirements.txt | 1 + 8 files changed, 435 insertions(+), 126 deletions(-) create mode 100644 comictaggerlib/ui/TemplateHelp.ui diff --git a/comictaggerlib/cli.py b/comictaggerlib/cli.py index 688907d..25acae1 100644 --- a/comictaggerlib/cli.py +++ b/comictaggerlib/cli.py @@ -507,19 +507,31 @@ def process_file_cli(filename, opts, settings, match_results): renamer.setTemplate(settings.rename_template) renamer.setIssueZeroPadding(settings.rename_issue_number_padding) renamer.setSmartCleanup(settings.rename_use_smart_string_cleanup) + renamer.move = settings.rename_move_dir - new_name = renamer.determineName(filename, ext=new_ext) - - if new_name == os.path.basename(filename): - print(msg_hdr + "Filename is already good!", file=sys.stderr) + try: + new_name = renamer.determineName(filename, ext=new_ext) + except Exception as e: + print(msg_hdr + "Invalid format string!\nYour rename template is invalid!\n\n" + "{}\n\nPlease consult the template help in the settings " + "and the documentation on the format at " + "https://docs.python.org/3/library/string.html#format-string-syntax", file=sys.stderr) return folder = os.path.dirname(os.path.abspath(filename)) + if settings.rename_move_dir and len(settings.rename_dir.strip()) > 3: + folder = settings.rename_dir.strip() + new_abs_path = utils.unique_file(os.path.join(folder, new_name)) + if os.path.join(folder, new_name) == os.path.abspath(filename): + print(msg_hdr + "Filename is already good!", file=sys.stderr) + return + suffix = "" if not opts.dryrun: # rename the file + os.makedirs(os.path.dirname(new_abs_path), 0o777, True) os.rename(filename, new_abs_path) else: suffix = " (dry-run, no change)" diff --git a/comictaggerlib/filerenamer.py b/comictaggerlib/filerenamer.py index 0f18151..150c01b 100644 --- a/comictaggerlib/filerenamer.py +++ b/comictaggerlib/filerenamer.py @@ -17,19 +17,96 @@ import os import re import datetime +import sys +import string + +from pathvalidate import sanitize_filepath from . import utils from .issuestring import IssueString +class MetadataFormatter(string.Formatter): + def __init__(self, smart_cleanup=False): + super().__init__() + self.smart_cleanup = smart_cleanup + + def format_field(self, value, format_spec): + if value is None or value == "": + return "" + return super().format_field(value, format_spec) + + def _vformat(self, format_string, args, kwargs, used_args, recursion_depth, + auto_arg_index=0): + if recursion_depth < 0: + raise ValueError('Max string recursion exceeded') + result = [] + lstrip = False + for literal_text, field_name, format_spec, conversion in \ + self.parse(format_string): + + # output the literal text + if literal_text: + if lstrip: + result.append(literal_text.lstrip("-_)}]#")) + else: + result.append(literal_text) + lstrip = False + # if there's a field, output it + if field_name is not None: + # this is some markup, find the object and do + # the formatting + + # handle arg indexing when empty field_names are given. + if field_name == '': + if auto_arg_index is False: + raise ValueError('cannot switch from manual field ' + 'specification to automatic field ' + 'numbering') + field_name = str(auto_arg_index) + auto_arg_index += 1 + elif field_name.isdigit(): + if auto_arg_index: + raise ValueError('cannot switch from manual field ' + 'specification to automatic field ' + 'numbering') + # disable auto arg incrementing, if it gets + # used later on, then an exception will be raised + auto_arg_index = False + + # given the field_name, find the object it references + # and the argument it came from + obj, arg_used = self.get_field(field_name, args, kwargs) + used_args.add(arg_used) + + # do any conversion on the resulting object + obj = self.convert_field(obj, conversion) + + # expand the format spec, if needed + format_spec, auto_arg_index = self._vformat( + format_spec, args, kwargs, + used_args, recursion_depth-1, + auto_arg_index=auto_arg_index) + + # format the object and append to the result + fmtObj = self.format_field(obj, format_spec) + if fmtObj == "" and len(result) > 0 and self.smart_cleanup: + lstrip = True + result.pop() + result.append(fmtObj) + + return ''.join(result), auto_arg_index + + class FileRenamer: def __init__(self, metadata): self.setMetadata(metadata) self.setTemplate( - "%series% v%volume% #%issue% (of %issuecount%) (%year%)") + "{publisher}/{series}/{series} v{volume} #{issue} (of {issueCount}) ({year})") self.smart_cleanup = True self.issue_zero_padding = 3 + self.move = False def setMetadata(self, metadata): self.metdata = metadata @@ -43,114 +120,37 @@ class FileRenamer: def setTemplate(self, template): self.template = template - def replaceToken(self, text, value, token): - # helper func - def isToken(word): - return (word[0] == "%" and word[-1:] == "%") - - if value is not None: - return text.replace(token, str(value)) - else: - if self.smart_cleanup: - # smart cleanup means we want to remove anything appended to token if it's empty - # (e.g "#%issue%" or "v%volume%") - # (TODO: This could fail if there is more than one token appended together, I guess) - text_list = text.split() - - # special case for issuecount, remove preceding non-token word, - # as in "...(of %issuecount%)..." - if token == '%issuecount%': - for idx, word in enumerate(text_list): - if token in word and not isToken(text_list[idx - 1]): - text_list[idx - 1] = "" - - text_list = [x for x in text_list if token not in x] - return " ".join(text_list) - else: - return text.replace(token, "") - def determineName(self, filename, ext=None): - + class Default(dict): + def __missing__(self, key): + return "{" + key + "}" md = self.metdata - new_name = self.template - preferred_encoding = utils.get_actual_preferred_encoding() - # print(u"{0}".format(md)) - new_name = self.replaceToken(new_name, md.series, '%series%') - new_name = self.replaceToken(new_name, md.volume, '%volume%') + # padding for issue + md.issue = IssueString(md.issue).asString(pad=self.issue_zero_padding) - if md.issue is not None: - issue_str = "{0}".format( - IssueString(md.issue).asString(pad=self.issue_zero_padding)) - else: - issue_str = None - new_name = self.replaceToken(new_name, issue_str, '%issue%') + template = self.template - new_name = self.replaceToken(new_name, md.issueCount, '%issuecount%') - new_name = self.replaceToken(new_name, md.year, '%year%') - new_name = self.replaceToken(new_name, md.publisher, '%publisher%') - new_name = self.replaceToken(new_name, md.title, '%title%') - new_name = self.replaceToken(new_name, md.month, '%month%') - month_name = None - if md.month is not None: - if (isinstance(md.month, str) and md.month.isdigit()) or isinstance( - md.month, int): - if int(md.month) in range(1, 13): - dt = datetime.datetime(1970, int(md.month), 1, 0, 0) - #month_name = dt.strftime("%B".encode(preferred_encoding)).decode(preferred_encoding) - month_name = dt.strftime("%B") - new_name = self.replaceToken(new_name, month_name, '%month_name%') + pathComponents = template.split(os.sep) + new_name = "" - new_name = self.replaceToken(new_name, md.genre, '%genre%') - new_name = self.replaceToken(new_name, md.language, '%language_code%') - new_name = self.replaceToken( - new_name, md.criticalRating, '%criticalrating%') - new_name = self.replaceToken( - new_name, md.alternateSeries, '%alternateseries%') - new_name = self.replaceToken( - new_name, md.alternateNumber, '%alternatenumber%') - new_name = self.replaceToken( - new_name, md.alternateCount, '%alternatecount%') - new_name = self.replaceToken(new_name, md.imprint, '%imprint%') - new_name = self.replaceToken(new_name, md.format, '%format%') - new_name = self.replaceToken( - new_name, md.maturityRating, '%maturityrating%') - new_name = self.replaceToken(new_name, md.storyArc, '%storyarc%') - new_name = self.replaceToken(new_name, md.seriesGroup, '%seriesgroup%') - new_name = self.replaceToken(new_name, md.scanInfo, '%scaninfo%') + fmt = MetadataFormatter(self.smart_cleanup) + for Component in pathComponents: + new_name = os.path.join(new_name, fmt.vformat(Component, args=[], kwargs=Default(vars(md))).replace("/", "-")) - if self.smart_cleanup: - - # remove empty braces,brackets, parentheses - new_name = re.sub("\(\s*[-:]*\s*\)", "", new_name) - new_name = re.sub("\[\s*[-:]*\s*\]", "", new_name) - new_name = re.sub("\{\s*[-:]*\s*\}", "", new_name) - - # remove duplicate spaces - new_name = " ".join(new_name.split()) - - # remove remove duplicate -, _, - new_name = re.sub("[-_]{2,}\s+", "-- ", new_name) - new_name = re.sub("(\s--)+", " --", new_name) - new_name = re.sub("(\s-)+", " -", new_name) - - # remove dash or double dash at end of line - new_name = re.sub("[-]{1,2}\s*$", "", new_name) - - # remove duplicate spaces (again!) - new_name = " ".join(new_name.split()) - - if ext is None: + if ext is None or ext == "": ext = os.path.splitext(filename)[1] new_name += ext # some tweaks to keep various filesystems happy - new_name = new_name.replace("/", "-") - new_name = new_name.replace(" :", " -") new_name = new_name.replace(": ", " - ") new_name = new_name.replace(":", "-") - new_name = new_name.replace("?", "") - return new_name + # remove padding + md.issue = IssueString(md.issue).asString() + if self.move: + return sanitize_filepath(new_name.strip()) + else: + return os.path.basename(sanitize_filepath(new_name.strip())) diff --git a/comictaggerlib/renamewindow.py b/comictaggerlib/renamewindow.py index 9d1e2e1..6ff51c1 100644 --- a/comictaggerlib/renamewindow.py +++ b/comictaggerlib/renamewindow.py @@ -76,7 +76,20 @@ class RenameWindow(QtWidgets.QDialog): if md.isEmpty: md = ca.metadataFromFilename(self.settings.parse_scan_info) self.renamer.setMetadata(md) - new_name = self.renamer.determineName(ca.path, ext=new_ext) + self.renamer.move = self.settings.rename_move_dir + + try: + new_name = self.renamer.determineName(ca.path, ext=new_ext) + except Exception as e: + QtWidgets.QMessageBox.critical(self, 'Invalid format string!', + 'Your rename template is invalid!' + '

{}

' + 'Please consult the template help in the ' + 'settings and the documentation on the format at ' + '' + 'https://docs.python.org/3/library/string.html#format-string-syntax'.format(e)) + return + row = self.twList.rowCount() self.twList.insertRow(row) @@ -149,17 +162,20 @@ class RenameWindow(QtWidgets.QDialog): centerWindowOnParent(progdialog) QtCore.QCoreApplication.processEvents() - if item['new_name'] == os.path.basename(item['archive'].path): + folder = os.path.dirname(os.path.abspath(item['archive'].path)) + if self.settings.rename_move_dir and len(self.settings.rename_dir.strip()) > 3: + folder = self.settings.rename_dir.strip() + + new_abs_path = utils.unique_file(os.path.join(folder, item['new_name'])) + + if os.path.join(folder, item['new_name']) == item['archive'].path: print(item['new_name'], "Filename is already good!") continue if not item['archive'].isWritable(check_rar_status=False): continue - folder = os.path.dirname(os.path.abspath(item['archive'].path)) - new_abs_path = utils.unique_file( - os.path.join(folder, item['new_name'])) - + os.makedirs(os.path.dirname(new_abs_path), 0o777, True) os.rename(item['archive'].path, new_abs_path) item['archive'].rename(new_abs_path) diff --git a/comictaggerlib/settings.py b/comictaggerlib/settings.py index 8808788..3442527 100644 --- a/comictaggerlib/settings.py +++ b/comictaggerlib/settings.py @@ -108,10 +108,12 @@ class ComicTaggerSettings: self.apply_cbl_transform_on_bulk_operation = False # Rename settings - self.rename_template = "%series% #%issue% (%year%)" + self.rename_template = "{publisher}/{series}/{series} #{issue} - {title} ({year})" self.rename_issue_number_padding = 3 self.rename_use_smart_string_cleanup = True self.rename_extension_based_on_archive = True + self.rename_dir = "" + self.rename_move_dir = False # Auto-tag stickies self.save_on_low_confidence = False @@ -301,6 +303,10 @@ class ComicTaggerSettings: 'rename', 'rename_extension_based_on_archive'): self.rename_extension_based_on_archive = self.config.getboolean( 'rename', 'rename_extension_based_on_archive') + if self.config.has_option('rename', 'rename_dir'): + self.rename_dir = self.config.get('rename', 'rename_dir') + if self.config.has_option('rename', 'rename_move_dir'): + self.rename_move_dir = self.config.getboolean('rename', 'rename_move_dir') if self.config.has_option('autotag', 'save_on_low_confidence'): self.save_on_low_confidence = self.config.getboolean( @@ -462,6 +468,8 @@ class ComicTaggerSettings: self.rename_use_smart_string_cleanup) self.config.set('rename', 'rename_extension_based_on_archive', self.rename_extension_based_on_archive) + self.config.set('rename', 'rename_dir', self.rename_dir) + self.config.set('rename', 'rename_move_dir', self.rename_move_dir) if not self.config.has_section('autotag'): self.config.add_section('autotag') diff --git a/comictaggerlib/settingswindow.py b/comictaggerlib/settingswindow.py index 0149a12..79fb11d 100644 --- a/comictaggerlib/settingswindow.py +++ b/comictaggerlib/settingswindow.py @@ -24,6 +24,8 @@ from .settings import ComicTaggerSettings from .comicvinecacher import ComicVineCacher from .comicvinetalker import ComicVineTalker from .imagefetcher import ImageFetcher +from .filerenamer import FileRenamer +from .genericmetadata import GenericMetadata from . import utils @@ -113,6 +115,66 @@ class SettingsWindow(QtWidgets.QDialog): self.btnClearCache.clicked.connect(self.clearCache) self.btnResetSettings.clicked.connect(self.resetSettings) self.btnTestKey.clicked.connect(self.testAPIKey) + self.btnTemplateHelp.clicked.connect(self.showTemplateHelp) + + def configRenamer(self): + md = GenericMetadata() + md.isEmpty = False + md.tagOrigin = "testing" + + md.series = "series name" + md.issue = "1" + md.title = "issue title" + md.publisher = "publisher" + md.year = 1998 + md.month = 4 + md.day = 4 + md.issueCount = 1 + md.volume = 256 + md.genre = "test" + md.language = "en" # 2 letter iso code + md.comments = "This is definitly a comic." # use same way as Summary in CIX + + md.volumeCount = 4096 + md.criticalRating = "Worst Comic Ever" + md.country = "US" + + md.alternateSeries = "None" + md.alternateNumber = 4.4 + md.alternateCount = 4444 + md.imprint = 'imprint' + md.notes = "This doesn't actually exist" + md.webLink = "https://example.com/series name/1" + md.format = "Box Set" + md.manga = "Yes" + md.blackAndWhite = False + md.pageCount = 4 + md.maturityRating = "Everyone" + + md.storyArc = "story" + md.seriesGroup = "seriesGroup" + md.scanInfo = "(lordwelch)" + + md.characters = "character 1, character 2" + md.teams = "None" + md.locations = "Earth, 444 B.C." + + md.credits = [dict({'role': 'Everything', 'person': 'author', 'primary': True})] + md.tags = ["testing", "not real"] + md.pages = [dict({'Image': '0', 'Type': 'Front Cover'}), dict({'Image': '1', 'Type': 'Story'})] + + # Some CoMet-only items + md.price = 0.00 + md.isVersionOf = "SERIES #1" + md.rights = "None" + md.identifier = "LW4444-Comic" + md.lastMark = "0" + md.coverImage = "https://example.com/series name/1/cover" + + self.renamer = FileRenamer(md) + self.renamer.setTemplate(str(self.leRenameTemplate.text())) + self.renamer.setIssueZeroPadding(self.settings.rename_issue_number_padding) + self.renamer.setSmartCleanup(self.settings.rename_use_smart_string_cleanup) def settingsToForm(self): @@ -165,9 +227,27 @@ class SettingsWindow(QtWidgets.QDialog): self.cbxSmartCleanup.setCheckState(QtCore.Qt.Checked) if self.settings.rename_extension_based_on_archive: self.cbxChangeExtension.setCheckState(QtCore.Qt.Checked) + if self.settings.rename_move_dir: + self.cbxMoveFiles.setCheckState(QtCore.Qt.Checked) + self.leDirectory.setText(self.settings.rename_dir) def accept(self): + self.configRenamer() + + + try: + new_name = self.renamer.determineName('test.cbz') + except Exception as e: + QtWidgets.QMessageBox.critical(self, 'Invalid format string!', + 'Your rename template is invalid!' + '

{}

' + 'Please consult the template help in the ' + 'settings and the documentation on the format at ' + '' + 'https://docs.python.org/3/library/string.html#format-string-syntax'.format(e)) + return + # Copy values from form to settings and save self.settings.rar_exe_path = str(self.leRarExePath.text()) @@ -210,6 +290,8 @@ class SettingsWindow(QtWidgets.QDialog): self.leIssueNumPadding.text()) self.settings.rename_use_smart_string_cleanup = self.cbxSmartCleanup.isChecked() self.settings.rename_extension_based_on_archive = self.cbxChangeExtension.isChecked() + self.settings.rename_move_dir = self.cbxMoveFiles.isChecked() + self.settings.rename_dir = self.leDirectory.text() self.settings.save() QtWidgets.QDialog.accept(self) @@ -268,3 +350,17 @@ class SettingsWindow(QtWidgets.QDialog): def showRenameTab(self): self.tabWidget.setCurrentIndex(5) + + def showTemplateHelp(self): + TemplateHelpWin = TemplateHelpWindow(self) + TemplateHelpWin.setModal(False) + TemplateHelpWin.show() + +class TemplateHelpWindow(QtWidgets.QDialog): + + def __init__(self, parent): + super(TemplateHelpWindow, self).__init__(parent) + + uic.loadUi(ComicTaggerSettings.getUIFile('TemplateHelp.ui'), self) + + diff --git a/comictaggerlib/ui/TemplateHelp.ui b/comictaggerlib/ui/TemplateHelp.ui new file mode 100644 index 0000000..2b581ed --- /dev/null +++ b/comictaggerlib/ui/TemplateHelp.ui @@ -0,0 +1,107 @@ + + + Dialog + + + + 0 + 0 + 702 + 452 + + + + Template Help + + + true + + + + 0 + + + 2 + + + 2 + + + + + <html> + <head/> + <body> + <h1 style="text-align: center">Template help</h1> + <p>The template uses Python format strings, in the simplest use it replaces the field (e.g. {issue}) with the value for that particular comic (e.g. 1) for advanced formatting please reference the + + <a href="https://docs.python.org/3/library/string.html#format-string-syntax">Python 3 documentation</a></p> + <pre>Accepts the following variables: +{isEmpty} (boolean) +{tagOrigin} (string) +{series} (string) +{issue} (string) +{title} (string) +{publisher} (string) +{month} (integer) +{year} (integer) +{day} (integer) +{issueCount} (integer) +{volume} (integer) +{genre} (string) +{language} (string) +{comments} (string) +{volumeCount} (integer) +{criticalRating} (string) +{country} (string) +{alternateSeries} (string) +{alternateNumber} (string) +{alternateCount} (integer) +{imprint} (string) +{notes} (string) +{webLink} (string) +{format} (string) +{manga} (string) +{blackAndWhite} (boolean) +{pageCount} (integer) +{maturityRating} (string) +{storyArc} (string) +{seriesGroup} (string) +{scanInfo} (string) +{characters} (string) +{teams} (string) +{locations} (string) +{credits} (list of dict({'role': 'str', 'person': 'str', 'primary': boolean})) +{tags} (list of str) +{pages} (list of dict({'Image': 'str(int)', 'Type': 'str'})) + +CoMet-only items: +{price} (float) +{isVersionOf} (string) +{rights} (string) +{identifier} (string) +{lastMark} (string) +{coverImage} (string) + +Examples: + +{series} {issue} ({year}) +Spider-Geddon 1 (2018) + +{series} #{issue} - {title} +Spider-Geddon #1 - New Players; Check In + +</pre> + </body> +</html> + + + true + + + + + + + + diff --git a/comictaggerlib/ui/settingswindow.ui b/comictaggerlib/ui/settingswindow.ui index c64f5f5..82805c5 100644 --- a/comictaggerlib/ui/settingswindow.ui +++ b/comictaggerlib/ui/settingswindow.ui @@ -503,7 +503,7 @@ QFormLayout::AllNonFixedFieldsGrow - + Template: @@ -512,31 +512,80 @@ - <html><head/><body><p>The template for the new filename. Accepts the following variables:</p><p>%series%<br/>%issue%<br/>%volume%<br/>%issuecount%<br/>%year%<br/>%month%<br/>%month_name%<br/>%publisher%<br/>%title%<br/> -%genre%<br/> -%language_code%<br/> -%criticalrating%<br/> -%alternateseries%<br/> -%alternatenumber%<br/> -%alternatecount%<br/> -%imprint%<br/> -%format%<br/> -%maturityrating%<br/> -%storyarc%<br/> -%seriesgroup%<br/> -%scaninfo% -</p><p>Examples:</p><p><span style=" font-style:italic;">%series% %issue% (%year%)</span><br/><span style=" font-style:italic;">%series% #%issue% - %title%</span></p></body></html> + <pre>The template for the new filename. Uses python format strings https://docs.python.org/3/library/string.html#format-string-syntax +Accepts the following variables: +{isEmpty} (boolean) +{tagOrigin} (string) +{series} (string) +{issue} (string) +{title} (string) +{publisher} (string) +{month} (integer) +{year} (integer) +{day} (integer) +{issueCount} (integer) +{volume} (integer) +{genre} (string) +{language} (string) +{comments} (string) +{volumeCount} (integer) +{criticalRating} (string) +{country} (string) +{alternateSeries} (string) +{alternateNumber} (string) +{alternateCount} (integer) +{imprint} (string) +{notes} (string) +{webLink} (string) +{format} (string) +{manga} (string) +{blackAndWhite} (boolean) +{pageCount} (integer) +{maturityRating} (string) +{storyArc} (string) +{seriesGroup} (string) +{scanInfo} (string) +{characters} (string) +{teams} (string) +{locations} (string) +{credits} (list of dict({&apos;role&apos;: &apos;str&apos;, &apos;person&apos;: &apos;str&apos;, &apos;primary&apos;: boolean})) +{tags} (list of str) +{pages} (list of dict({&apos;Image&apos;: &apos;str(int)&apos;, &apos;Type&apos;: &apos;str&apos;})) + +CoMet-only items: +{price} (float) +{isVersionOf} (string) +{rights} (string) +{identifier} (string) +{lastMark} (string) +{coverImage} (string) + +Examples: + +{series} {issue} ({year}) +Spider-Geddon 1 (2018) + +{series} #{issue} - {title} +Spider-Geddon #1 - New Players; Check In +</pre> - + + + Template Help + + + + + Issue # Zero Padding - + @@ -555,7 +604,7 @@ - + <html><head/><body><p><span style=" font-weight:600;">&quot;Smart Text Cleanup&quot; </span>will attempt to clean up the new filename if there are missing fields from the template. For example, removing empty braces, repeated spaces and dashes, and more. Experimental feature.</p></body></html> @@ -565,13 +614,33 @@ - + Change Extension Based On Archive Type + + + + If checked moves files to specified folder + + + Move files when renaming + + + + + + + Destination Directory: + + + + + + diff --git a/requirements.txt b/requirements.txt index 792c868..e5a7087 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ configparser natsort pillow>=4.3.0 requests +pathvalidate