Error handling, feature cleanup

This commit is contained in:
Thomas Wilczynski 2025-11-05 14:34:01 -08:00
commit 94755aa78f
6 changed files with 181 additions and 107 deletions

View file

@ -12,7 +12,7 @@ Other use cases:
- Copying only certain types of documents into a backup folder
- TODO: Grouping all of your photos into separate folders, by year
- Moving only files that contain the name "document"
This is my first real Python project, after many unimportant scripts and experiments. I really needed a project like this in my portfolio, even if it's not truly amazing.
@ -24,9 +24,11 @@ This is my first real Python project, after many unimportant scripts and experim
- Set file rules for generic file types (images, documents, etc.)
- Save your file rules configuration for future use
## Usage
You can run either the app or the script, but the script won't use the file rules if you haven't set them up.
You can run either the app or the script
## About

154
app.py
View file

@ -4,14 +4,8 @@ Frontend GUI that displays a list of files from a folder.
File actions (move, copy, or delete) can be set for generic file types.
"""
# TODO:
# Add comments
# Add some error handling
# Enable loading custom configuration json
# Improve user experience of rules tab
__name__ = "__main__"
__version__ = "0.9"
__version__ = "1.0"
__author__ = "Gull"
import tkinter as tk
@ -23,8 +17,8 @@ from tkinter import messagebox as mb
from tkinter import filedialog as fd
APP_NAME = "Finch Filer"
LOG_PATH = "%/Gull/app.log"
RULES_PATH = "%/Gull/file_rules_custom.json"
LOG_PATH = "%/Gullbase/FinchFiler/app.log"
RULES_PATH = "%/Gullbase/FinchFiler/file_rules_custom.json"
def setup_log():
"""Initializes the logging system."""
@ -127,44 +121,73 @@ class App(tk.Tk):
def load_config(self, filepath=""):
"""Loads the file sorting rules from the given file path."""
if filepath == "": filepath = self.custom_rules_path
path = ut.parse_dir(filepath)
data = ut.load_json_file(path)
if data != None:
self.fm.setup_file_rules(data, True)
log.info(f"Loaded custom file rules: {path}")
try:
path = ut.parse_dir(filepath)
except FileNotFoundError as error:
print(error)
else:
try:
data = ut.load_json_file(path)
self.fm.setup_file_rules(data, True)
self.prep()
log.info(f"Loaded custom file rules: {path}")
except Exception as error:
print(error)
def save_config(self, filepath=""):
"""Saves the file sorting rules to the given file path."""
if filepath == "": filepath = self.custom_rules_path
path = ut.parse_dir(filepath)
ut.save_json_file(path, {"filemodes": self.fm.filemodes})
log.info(f"Saved custom file rules: {path}")
try:
path = ut.parse_dir(filepath)
except FileNotFoundError as error:
print(error)
else:
try:
ut.save_json_file(path, {"filemodes": self.fm.filemodes})
log.info(f"Saved custom file rules: {path}")
except Exception as error:
print(error)
def reset_config(self):
"""Resets the file sorting rules back to default."""
data = ut.load_json_file(fm.Manager.RULES_PATH)
self.fm.setup_file_rules(data, True)
self.prep()
log.info(f"Reloaded default file rules")
try:
data = ut.load_json_file(fm.Manager.RULES_PATH)
self.fm.setup_file_rules(data, True)
self.prep()
log.info(f"Reloaded default file rules")
except Exception as error:
print(error)
log.error("Failed to load default file rules!")
def update_mode_data(self, mode, key, value):
"""Updates file mode data (key and value)."""
if mode == "": mode = self.current_mode
if not mode in self.fm.filemodes: return
self.fm.filemodes[mode].update({key: value})
if mode == "": mode = self.current_mode # Uses current mode
if not mode in self.fm.filemodes:
log.warning(f"File mode not found: {mode}")
return
data = self.fm.filemodes[mode]
data.update({key: value})
log.info(f"Set properties for file mode: {key} = {value}")
def set_rules_variables(self, key):
if key == "name": # Refreshes mode list
self.v_modelist.set([v["name"] for v in self.fm.filemodes.values()])
elif key == "extensions": # Refreshes extensions cache
value = value.replace(" ", "") # Removes all spaces
data.update({"extensions": value.split(",")}) # Converts to list
self.fm.match_file_types()
self.fm.update_file_data()
self.update_fileview()
def set_rules_variables(self, mode):
"""Updates the internal variables of the file rules (key is mode)."""
if not key in self.fm.filemodes: return
data = self.fm.filemodes[key]
self.v_radio.set(data["action"])
if not mode in self.fm.filemodes:
log.warning(f"File mode not found: {mode}")
return
data = self.fm.filemodes[mode]
self.v_name.set(data["name"])
self.v_action.set(data["action"])
self.v_targetdir.set(data["destination"])
# TODO: Change string parsing
if type(data["extensions"]) == list: # Needs improvement
self.v_extstr.set(", ".join(data["extensions"]))
else:
self.v_extstr.set(data["extensions"])
self.v_extstr.set(", ".join(data["extensions"]))
def select_mode_from_list(self, event):
"""Selects the current file mode to use; also updates variables."""
@ -188,8 +211,9 @@ class App(tk.Tk):
data = self.fm.filemodes[self.current_mode]
dir = fd.askdirectory(initialdir = ut.parse_dir(data["destination"]),
title = "Select target directory")
self.v_targetdir.set(dir)
self.update_mode_data("", "destination", dir)
if len(dir) > 0:
self.v_targetdir.set(dir)
self.update_mode_data("", "destination", dir)
def set_directory(self, dir):
"""Sets the file directory for the app to use."""
@ -201,7 +225,8 @@ class App(tk.Tk):
"""Prompts which file directory for the app to use."""
dir = fd.askdirectory(initialdir = ut.parse_dir(self.fm.dir),
title = "Select directory")
self.set_directory(dir)
if len(dir) > 0:
self.set_directory(dir)
def ask_load_rules(self):
"""Prompts which file rules config for the app to use."""
@ -210,27 +235,38 @@ class App(tk.Tk):
dir = fd.askopenfilename(initialdir = initdir,
filetypes = filetypes,
title = "Load config file")
self.load_config(dir)
if len(dir) > 0:
self.custom_rules_path = dir
self.load_config(dir)
def ask_save_rules(self):
"""Prompts which file name to use to save the file rules config."""
initdir = ut.parse_dir(self.custom_rules_path)
filetypes = (("Config files", "*.json"))
initdir = ""
if self.custom_rules_path == RULES_PATH:
initdir = ut.parse_dir("~/") # Switch to home directory
else:
initdir = ut.parse_dir(self.custom_rules_path)
filetypes = (("Config files", "*.json"), ("All files", "*.*"))
dir = fd.asksaveasfilename(initialdir = initdir,
filetypes = filetypes,
title = "Save config file")
self.custom_rules_path = dir
self.save_config(dir)
if len(dir) > 0:
if ".json" not in dir: dir += ".json"
self.custom_rules_path = dir
self.save_config(dir)
def prep(self):
"""Makes the app ready to use after initialization."""
self.update_fileview()
self.v_modelist.set([v["name"] for v in self.fm.filemodes.values()])
if self.current_mode in self.fm.filemodes:
self.set_rules_variables(self.current_mode)
def run_task(self):
"""Runs the main file sorting task."""
result = mb.askyesno("Task", "Ready to sort all files?")
result = mb.askyesno("Task", f"Ready? {len(self.fm.filedata)} files "
"will be affected.")
if not result: return
log.info("File organization started")
@ -299,15 +335,8 @@ class App(tk.Tk):
"""Files tab of the app."""
frame = ttk.Frame(self.book)
box = ttk.Frame(frame)
button = ttk.Button(box, text="Start Task", command=self.run_task)
button.grid(column=0, row=0, pady=5)
button = ttk.Button(box, text="Refresh", command=self.update_fileview)
button.grid(column=1, row=0, pady=5)
box.pack(side=tk.TOP, fill=tk.X)
button = ttk.Button(frame, text="Start Task", command=self.run_task)
button.pack(side=tk.TOP, anchor=tk.W, pady=5)
self.fileview_columns = ("Last Modified", "Size")
self.fileview = ttk.Treeview(frame, columns=self.fileview_columns)
@ -353,22 +382,29 @@ class App(tk.Tk):
details = ttk.Frame(main)
i_frame = ttk.Labelframe(details, text="Name")
self.v_name = tk.StringVar()
f_name = lambda e: um("", "name", self.v_name.get())
entry = ttk.Entry(i_frame, textvariable=self.v_name)
entry.bind('<Return>', f_name)
entry.pack(side=tk.LEFT)
i_frame.pack(side=tk.TOP, anchor=tk.W, pady=5)
i_frame = ttk.Labelframe(details, text="Action")
self.v_radio = tk.StringVar()
self.v_action = tk.StringVar()
self.v_actions = ("move", "copy", "delete", "ignore")
f_radio = lambda: um("", "action", self.v_radio.get())
f_action = lambda: um("", "action", self.v_action.get())
for s in self.v_actions:
str = f"{s.capitalize()} "
radio = ttk.Radiobutton(i_frame, text=str, value=s,
variable=self.v_radio, command = f_radio)
variable=self.v_action, command = f_action)
radio.pack(side=tk.LEFT)
i_frame.pack(side=tk.TOP, anchor=tk.W, pady=5)
i_frame = ttk.Labelframe(details, text="Target Directory")
# text="Start with \"~/\" for your home (user) directory.")
self.v_targetdir = tk.StringVar()
f_targetdir = lambda e: um("", "destination", self.v_targetdir.get())
entry = ttk.Entry(i_frame, textvariable=self.v_targetdir)
@ -381,11 +417,7 @@ class App(tk.Tk):
i_frame.pack(side=tk.TOP, fill=tk.X, pady=5)
i_frame = ttk.Labelframe(details, text="File Extensions")
label = ttk.Label(i_frame,
text="Separate extensions with a comma and space.")
label.pack(side=tk.TOP, anchor=tk.W)
i_frame = ttk.Labelframe(details, text="File Filters")
self.v_extstr = tk.StringVar()
f_extstr = lambda e: um("", "extensions", self.v_extstr.get())

View file

@ -3,57 +3,57 @@
"all": {
"action": "ignore",
"active": true,
"destination": "~/Downloads/FinchFiler/",
"extensions": ["*"],
"destination": "~/",
"extensions": ["*.*"],
"name": "All Files"
},
"image": {
"action": "ignore",
"active": true,
"destination": "~/Pictures/",
"extensions": ["jpg", "jpeg", "png", "gif", "bmp", "psd", "raw", "webp"],
"extensions": ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.bmp", "*.psd", "*.raw", "*.webp"],
"name": "Images"
},
"audio": {
"action": "ignore",
"active": true,
"destination": "~/Music/",
"extensions": ["wav", "mp3", "ogg", "flac", "wma", "aiff", "aac"],
"extensions": ["*.wav", "*.mp3", "*.ogg", "*.flac", "*.wma", "*.aiff", "*.aac"],
"name": "Audio"
},
"video": {
"action": "ignore",
"active": true,
"destination": "~/Videos/",
"extensions": ["avi", "mpeg", "mp4", "mov", "mkv", "ogv", "webm"],
"extensions": ["*.avi", "*.mpeg", "*.mp4", "*.mov", "*.mkv", "*.ogv", "*.webm"],
"name": "Video"
},
"document": {
"action": "ignore",
"active": true,
"destination": "~/Documents/",
"extensions": ["txt", "doc", "docx", "pdf", "rtf"],
"extensions": ["*.txt", "*.doc", "*.docx", "*.pdf", "*.rtf"],
"name": "Documents"
},
"data": {
"action": "ignore",
"active": true,
"destination": "~/Documents/data/",
"extensions": ["json", "csv", "db"],
"extensions": ["*.json", "*.csv", "*.db"],
"name": "Data"
},
"program": {
"action": "ignore",
"active": true,
"destination": "~/Downloads/",
"extensions": ["exe", "msi", "elf"],
"extensions": ["*.exe", "*.msi", "*.elf"],
"name": "Programs"
},
"archive": {
"action": "ignore",
"active": true,
"destination": "~/Downloads/",
"extensions": ["zip", "rar", "tar", "iso", "gz", "lz", "rz", "7z", "dmg"],
"extensions": ["*.zip", "*.rar", "*.tar", "*.iso", "*.gz", "*.lz", "*.rz", "*.7z", "*.dmg"],
"name": "Archives"
},
"other": {

View file

@ -8,7 +8,7 @@ import logging as log
import utils as ut
import send2trash as s2t
debug_mode = True
debug_mode = False
class Manager:
"""File manager and organizer system."""
@ -37,11 +37,15 @@ class Manager:
"""Sets the working directory of the file manager."""
if os.path.exists(dir):
self.dir = dir
else:
raise FileNotFoundError(f"Directory path not found: {dir}")
def set_config_path(self, dir):
"""Sets the working config path of the file manager."""
if os.path.exists(dir):
self.config_path = dir
else:
raise FileNotFoundError(f"Config path not found: {dir}")
def setup_file_rules(self, data, reset=False):
"""Sets or resets file rules from the given data."""
@ -70,10 +74,21 @@ class Manager:
def get_file_properties(self, short_filename):
"""Returns the time, size, mode, and full path of a single file."""
fullpath = os.path.join(self.dir, short_filename)
# Maybe check if fullpath exists (os.path.exists(fullpath))
try:
os.stat(fullpath)
except Exception as error:
raise error
_, ext = os.path.splitext(fullpath)
ext = ext[1:].lower()
mode = self.filetypes[ext] if ext in self.filetypes else "other"
mode = "other"
for k, v in self.filetypes.items():
if ext in k: mode = v # Matched extension
for k, v in self.filetypes.items():
if k in short_filename: mode = v # Matched file name
# File name has priority over extension
stat = os.stat(fullpath)
return {"time": stat.st_mtime, "size": stat.st_size, "mode": mode,
"fullpath": fullpath}
@ -123,12 +138,16 @@ class Manager:
if action in w_stats: w_stats[action] += 1
if action != "ignore": w_stats["total"] += 1
log.info(f"Successfully processed {w_stats['total']} files in total")
if debug_mode:
log.info(f"Simulated {w_stats['total']} files in total")
else:
log.info(f"Processed {w_stats['total']} files in total")
log.info(f"Moved {w_stats['move']} files")
log.info(f"Copied {w_stats['copy']} files")
log.info(f"Deleted {w_stats['delete']} files")
def set_debug_mode(value):
"""Enables or disables the file manager debug mode."""
global debug_mode
if type(value) == None:
debug_mode = not debug_mode # Toggles the mode
@ -136,25 +155,34 @@ def set_debug_mode(value):
debug_mode = value
def move(src, dst):
"""Moves a file from a source to a destination."""
src, dst = ut.parse_dir(src), ut.parse_dir(dst)
log.info(f"Moved file: {src} > {dst}")
if not debug_mode:
shutil.move(src, dst)
try:
shutil.move(src, dst)
except Exception as error:
print(error)
return src, dst
def copy(src, dst):
"""Copies a file from a source to a destination."""
src, dst = ut.parse_dir(src), ut.parse_dir(dst)
log.info(f"Copied file: {src} > {dst}")
if not debug_mode:
shutil.copy2(src, dst)
try:
shutil.copy2(src, dst)
except Exception as error:
print(error)
return src, dst
def delete(src):
"""Moves a file to the OS trash equivalent."""
src = ut.parse_dir(src)
log.info(f"Deleted file: {src}")
if not debug_mode:
try:
s2t.send2trash(src)
except s2t.TrashPermissionError:
pass
except s2t.TrashPermissionError as error:
print(error)
return src

View file

@ -1,4 +1,4 @@
"""Script."""
"""Script mode for Finch Filer."""
__author__ = "Gull"
@ -8,9 +8,10 @@ import utils as ut
import file_manager as fm
from argparse import ArgumentParser as ap
LOG_PATH = "%/Gull/script.log"
LOG_PATH = "%/Gullbase/FinchFiler/script.log"
SOURCE_PATH = "~/Downloads"
RULES_PATH = "%/Gull/file_rules_custom.json"
RULES_PATH = "%/Gullbase/FinchFiler/file_rules_custom.json"
RULES_PATH_ALT = "data/file_rules_default.json"
def setup_log():
log.basicConfig(**ut.get_log_config(LOG_PATH, log.DEBUG))
@ -40,14 +41,25 @@ def run():
setup_log()
fm.set_debug_mode(True) # Temporary
mgr = fm.Manager()
mgr.set_directory(source)
mgr.setup_file_rules(ut.load_json_file(rules))
try:
mgr.set_directory(source)
except FileNotFoundError as error:
print(error)
return
try:
mgr.setup_file_rules(ut.load_json_file(rules))
except FileNotFoundError as error:
try:
rules = ut.parse_dir(RULES_PATH_ALT, True)
mgr.setup_file_rules(ut.load_json_file(rules))
except FileNotFoundError as error:
print(error)
return
if mode != "default" and "all" in mgr.filemodes:
mgr.filemodes["all"]["action"] = mode
# TODO: Error handling
mgr.update_file_data()
mgr.run_task()

View file

@ -37,42 +37,42 @@ def format_date(timestamp):
def load_json_file(filepath):
"""Opens a JSON file, and returns JSON data as a dict."""
try: # Needs improvement!
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
return data
except:
return None
except Exception as error:
raise error
def save_json_file(filepath, data, raw=False):
"""Saves a JSON file, given a dict."""
try:
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, sort_keys=True, indent=None if raw else 2)
except:
return False
else:
return True
def get_user_dir(dir=""):
"""Returns the joined user directory."""
return os.path.join(os.path.expanduser("~"), dir)
def get_appdata_dir(dir=""):
"""Returns the joined appdata directory."""
return os.path.join(ad.user_data_dir(None, False), dir)
except Exception as error:
raise error
def parse_dir(path="", ignore_mkdir=False):
"""Converts and creates a directory (can be full file path)."""
if path[0] == "~": # Denotes the current user directory
path = get_user_dir(path.lstrip("~/"))
path = pathlib.Path(path).expanduser()
elif path[0] == "%": # Denotes the local appdata directory
path = get_appdata_dir(path.lstrip("%/"))
path = pathlib.Path(ad.user_data_dir(None, False)) / path.lstrip("%/")
else:
path = pathlib.Path(path)
if not ignore_mkdir:
pathlib.Path(os.path.split(path)[0]).mkdir(parents=True, exist_ok=True)
try:
# This seems sloppy
dir = str(path)
_, ext = os.path.splitext(dir)
if len(ext) > 0: dir = os.path.dirname(dir) #Is file
pathlib.Path(dir).mkdir(parents=True, exist_ok=True)
except Exception as error:
raise error
return path
# Returns as a string
return str(path)
def get_log_config(path="~/", level=None):
return {