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 - 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. 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.) - Set file rules for generic file types (images, documents, etc.)
- Save your file rules configuration for future use
## Usage ## 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 ## About

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

View file

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

View file

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

View file

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

View file

@ -37,42 +37,42 @@ def format_date(timestamp):
def load_json_file(filepath): def load_json_file(filepath):
"""Opens a JSON file, and returns JSON data as a dict.""" """Opens a JSON file, and returns JSON data as a dict."""
try: # Needs improvement! try:
with open(filepath, "r", encoding="utf-8") as f: with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
return data return data
except: except Exception as error:
return None raise error
def save_json_file(filepath, data, raw=False): def save_json_file(filepath, data, raw=False):
"""Saves a JSON file, given a dict.""" """Saves a JSON file, given a dict."""
try: try:
with open(filepath, "w", encoding="utf-8") as f: with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, sort_keys=True, indent=None if raw else 2) json.dump(data, f, sort_keys=True, indent=None if raw else 2)
except: except Exception as error:
return False raise error
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)
def parse_dir(path="", ignore_mkdir=False): def parse_dir(path="", ignore_mkdir=False):
"""Converts and creates a directory (can be full file path).""" """Converts and creates a directory (can be full file path)."""
if path[0] == "~": # Denotes the current user directory 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 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: 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): def get_log_config(path="~/", level=None):
return { return {