Follow SaraSortd 7.3: catch-alls as fallback, path parsing, nested variables

The tester tries specific patterns before catch-alls, directory paths only get
%Name% lookups and date/time variables, and nested %Name%s resolve. Warnings
for what 7.3 fixed are gone; a SaraSortd older than 7.3 gets a warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-08 18:18:02 +02:00
1 parent 28252e3101
commit d0e5eb2086
2 files changed
+87 -60

No files matched your search

+82 -55
View File
@@ -59,6 +59,11 @@ def GetConf(Parameter: str, Data: dict) -> Any:
return None return None
def IsCatchAll(Pattern: str) -> bool:
"""SaraSortd 7.3's IsCatchAll: a pattern of only "*" takes any file, so it's a fallback."""
return Pattern != "" and set(Pattern) == {"*"}
def MajorVersion(Version: str) -> str: def MajorVersion(Version: str) -> str:
return str(Version).split(".")[0] return str(Version).split(".")[0]
@@ -177,12 +182,44 @@ class Setup:
if not Content: if not Content:
return str(VarCall) if VarCall is not None else "" return str(VarCall) if VarCall is not None else ""
Found = GetConf(Content, ConfData) Found = GetConf(Content, ConfData)
return str(Found) if Found is not None else Match.group(0) return self.ResolveLookups(str(Found), ConfData) if Found is not None else Match.group(0)
return re.sub(f"{re.escape(Call[0])}(.*?){re.escape(Call[1])}", Replacer, String) return re.sub(f"{re.escape(Call[0])}(.*?){re.escape(Call[1])}", Replacer, String)
def ResolveLookups(self, String: str, ConfData: Optional[dict] = None, Depth: int = 0) -> str:
"""SaraSortd 7.3's ResolveLookups: %Name% lookups, also inside the values
they bring in (ImageDir = "%RootDir%/Images" used as "%ImageDir%").
Unknown names are left as they are."""
ConfData = self.Data if ConfData is None else ConfData
Call = self.Vars.get("VarCall", "%%")
if len(Call) < 2:
return String
def Replacer(Match: re.Match) -> str:
Found = GetConf(Match.group(1), ConfData) if Match.group(1) else None
if Found is None or Depth >= 10:
return Match.group(0)
return self.ResolveLookups(str(Found), ConfData, Depth + 1)
return re.sub(f"{re.escape(Call[0])}(.*?){re.escape(Call[1])}", Replacer, String)
def ParsePath(self, String: str) -> str:
"""SaraSortd 7.3's ParsePath: directory paths only get %Name% lookups and
the date and time variables, so "~/Stuff&Things" stays as it is; a
leading "~" is the home directory."""
Now = datetime.datetime.now()
V = self.Vars
for Key, Value in {
V.get("Year"): str(Now.year), V.get("Month"): str(Now.month).rjust(2, "0"),
V.get("Day"): str(Now.day).rjust(2, "0"), V.get("Hour"): str(Now.hour).rjust(2, "0"),
V.get("Minute"): str(Now.minute).rjust(2, "0"), V.get("Second"): str(Now.second).rjust(2, "0"),
}.items():
if Key and Key in String:
String = String.replace(Key, Value)
return os.path.expanduser(self.ResolveLookups(String))
def ResolveDir(self, Raw: str) -> str: def ResolveDir(self, Raw: str) -> str:
return self.Parse(Raw) return self.ParsePath(Raw)
def DirList(self, Key: str) -> list[str]: def DirList(self, Key: str) -> list[str]:
Value = self.Dirs.get(Key, []) Value = self.Dirs.get(Key, [])
@@ -216,14 +253,14 @@ class Setup:
def ConfFolder(self, PathText: str) -> Path: def ConfFolder(self, PathText: str) -> Path:
"""SaraSortd's ConfFolder: relative paths count from GlobalConf.toml's folder.""" """SaraSortd's ConfFolder: relative paths count from GlobalConf.toml's folder."""
Parsed = self.Parse(str(PathText)) Parsed = self.ParsePath(str(PathText))
if os.path.isabs(Parsed): if os.path.isabs(Parsed):
return Path(os.path.normpath(Parsed)) return Path(os.path.normpath(Parsed))
return Path(os.path.normpath(self.GlobalPath.parent / Parsed)) return Path(os.path.normpath(self.GlobalPath.parent / Parsed))
def FlatName(self, DirPath: str) -> str: def FlatName(self, DirPath: str) -> str:
"""SaraSortd's FlatName: "%RootDir%/Pictures/Cats" -> "Pictures_Cats".""" """SaraSortd's FlatName: "%RootDir%/Pictures/Cats" -> "Pictures_Cats"."""
Root = self.Parse(str(self.Dirs.get("RootDir", ""))).rstrip("/") Root = self.ParsePath(str(self.Dirs.get("RootDir", ""))).rstrip("/")
DirPath = DirPath.rstrip("/") DirPath = DirPath.rstrip("/")
if Root and DirPath.startswith(Root + "/"): if Root and DirPath.startswith(Root + "/"):
Relative = DirPath[len(Root) + 1:] Relative = DirPath[len(Root) + 1:]
@@ -235,7 +272,7 @@ class Setup:
"""SaraSortd 7.2: a config named in [DirConfs], or one in DirConfDir. """SaraSortd 7.2: a config named in [DirConfs], or one in DirConfDir.
None when the config lives inside the directory.""" None when the config lives inside the directory."""
for Key, Value in (self.Data.get("DirConfs") or {}).items(): for Key, Value in (self.Data.get("DirConfs") or {}).items():
if self.Parse(str(Key)).rstrip("/") == DirPath.rstrip("/"): if self.ParsePath(str(Key)).rstrip("/") == DirPath.rstrip("/"):
if isinstance(Value, dict): if isinstance(Value, dict):
Value = Value.get(Kind) Value = Value.get(Kind)
if Value: if Value:
@@ -343,9 +380,8 @@ class Setup:
def _DirAccepts(self, OutConf: dict, OutputPath: str, InputPath: str) -> bool: def _DirAccepts(self, OutConf: dict, OutputPath: str, InputPath: str) -> bool:
"""The output directory's own ValidInputDirs, as DecideNewPath checks it.""" """The output directory's own ValidInputDirs, as DecideNewPath checks it."""
Parent = GetConf("ParentDir", OutConf)
for Valid in GetConf("ValidInputDirs", OutConf) or []: for Valid in GetConf("ValidInputDirs", OutConf) or []:
if fnmatch.fnmatchcase(InputPath, self.Parse(str(Valid), Parent=Parent)): if fnmatch.fnmatchcase(InputPath, self.ParsePath(str(Valid))):
return True return True
return False return False
@@ -354,8 +390,7 @@ class Setup:
Own = Entry.get("ValidInputDirs") Own = Entry.get("ValidInputDirs")
if Own is None: if Own is None:
return True return True
Parent = os.path.basename(OutputPath) return any(fnmatch.fnmatchcase(InputPath, self.ParsePath(str(Valid))) for Valid in Own)
return any(fnmatch.fnmatchcase(InputPath, self.Parse(str(Valid), Parent=Parent)) for Valid in Own)
def Edges(self) -> list[Edge]: def Edges(self) -> list[Edge]:
Edges = [] Edges = []
@@ -580,7 +615,6 @@ class Setup:
return "That rule no longer exists." return "That rule no longer exists."
Entry = Files[Index] Entry = Files[Index]
Own = Entry.get("ValidInputDirs") Own = Entry.get("ValidInputDirs")
Parent = os.path.basename(OutputPath)
if Own is None: if Own is None:
# It took files from every input: list the others explicitly. # It took files from every input: list the others explicitly.
Others = [Other for Other in self.ConnectedInputs(OutputPath) if Other != InputPath] Others = [Other for Other in self.ConnectedInputs(OutputPath) if Other != InputPath]
@@ -588,7 +622,7 @@ class Setup:
Own = Entry["ValidInputDirs"] Own = Entry["ValidInputDirs"]
else: else:
for Position in reversed(range(len(Own))): for Position in reversed(range(len(Own))):
if self.Parse(str(Own[Position]), Parent=Parent) == InputPath: if self.ParsePath(str(Own[Position])) == InputPath:
del Own[Position] del Own[Position]
Message = None Message = None
if self._EntryAccepts(Entry.unwrap(), OutputPath, InputPath): if self._EntryAccepts(Entry.unwrap(), OutputPath, InputPath):
@@ -636,8 +670,7 @@ class Setup:
if Own is None: if Own is None:
Remaining = [self.Shorthand(Other) for Other in Others] Remaining = [self.Shorthand(Other) for Other in Others]
else: else:
Parent = os.path.basename(OutputPath) Remaining = [Valid for Valid in Own if self.ParsePath(str(Valid)) != InputPath]
Remaining = [Valid for Valid in Own if self.Parse(str(Valid), Parent=Parent) != InputPath]
Test = dict(Entry.unwrap(), ValidInputDirs=Remaining) Test = dict(Entry.unwrap(), ValidInputDirs=Remaining)
if self._EntryAccepts(Test, OutputPath, InputPath): if self._EntryAccepts(Test, OutputPath, InputPath):
Remaining = None # a wildcard still matches this input: can't split it off Remaining = None # a wildcard still matches this input: can't split it off
@@ -675,20 +708,18 @@ class Setup:
Changed = False Changed = False
DirValid = Document.get("ValidInputDirs") DirValid = Document.get("ValidInputDirs")
if isinstance(DirValid, list): if isinstance(DirValid, list):
Parent = Document.get("ParentDir")
for Position in reversed(range(len(DirValid))): for Position in reversed(range(len(DirValid))):
if self.Parse(str(DirValid[Position]), Parent=Parent) == PathText: if self.ParsePath(str(DirValid[Position])) == PathText:
del DirValid[Position] del DirValid[Position]
Changed = True Changed = True
Files = Document.get("Files") Files = Document.get("Files")
Parent = os.path.basename(OutputPath)
for Index in reversed(range(len(Files) if Files is not None else 0)): for Index in reversed(range(len(Files) if Files is not None else 0)):
Own = Files[Index].get("ValidInputDirs") Own = Files[Index].get("ValidInputDirs")
if not isinstance(Own, list): if not isinstance(Own, list):
continue continue
Before = len(Own) Before = len(Own)
for Position in reversed(range(Before)): for Position in reversed(range(Before)):
if self.Parse(str(Own[Position]), Parent=Parent) == PathText: if self.ParsePath(str(Own[Position])) == PathText:
del Own[Position] del Own[Position]
if len(Own) != Before: if len(Own) != Before:
Changed = True Changed = True
@@ -729,6 +760,7 @@ class Setup:
Steps.append("None of the input's own patterns match, so it stays where it is.") Steps.append("None of the input's own patterns match, so it stays where it is.")
return SimulationResult(Steps, None, "Skipped") return SimulationResult(Steps, None, "Skipped")
Candidates = []
for OutputPath in self.Outputs: for OutputPath in self.Outputs:
OutConf = self.LoadDirConf(OutputPath, "Out") OutConf = self.LoadDirConf(OutputPath, "Out")
if OutConf is None: if OutConf is None:
@@ -746,24 +778,30 @@ class Setup:
if Count >= Limit: if Count >= Limit:
Steps.append(f"{OutputPath}: full ({Count}/{Limit} files).") Steps.append(f"{OutputPath}: full ({Count}/{Limit} files).")
continue continue
for Entry in OutConf.get("Files", []): Candidates.append((OutputPath, OutConf))
if not self._EntryAccepts(Entry, OutputPath, InputPath):
continue # SaraSortd 7.3: specific patterns first, catch-alls ("*") only as a fallback.
Pattern = str(Entry.get("Pattern", "")) for CatchAll in (False, True):
if Entry.get("CaseSensitive", 0) == 1: if CatchAll:
Match = fnmatch.fnmatchcase(FileName, Pattern) Steps.append("No specific pattern takes it; trying catch-alls (\"*\").")
else: for OutputPath, OutConf in Candidates:
Match = fnmatch.fnmatch(FileName.lower(), Pattern.lower()) for Entry in OutConf.get("Files", []):
if not Match: Pattern = str(Entry.get("Pattern", ""))
continue if IsCatchAll(Pattern) != CatchAll or not self._EntryAccepts(Entry, OutputPath, InputPath):
NewName = self.PreviewName(str(Entry.get("NewFileName", "~^")), FileName, OutputPath, Entry, OutConf) continue
if Entry.get("Overwrite", 1) == 0 and os.path.exists(os.path.join(OutputPath, NewName)): if Entry.get("CaseSensitive", 0) == 1:
Steps.append(f"{OutputPath}: {Pattern} matches, but {NewName} exists and Overwrite = 0.") Match = fnmatch.fnmatchcase(FileName, Pattern)
continue else:
Action = "moved" if OutConf.get("DeleteOrg", 1) == 1 else "copied" Match = fnmatch.fnmatch(FileName.lower(), Pattern.lower())
Steps.append(f"{OutputPath}: {Pattern} matches, so it's {Action} and named {NewName}.") if not Match:
return SimulationResult(Steps, f"{OutputPath}/{NewName}", "Sorted") continue
Steps.append(f"{OutputPath}: no pattern matches.") NewName = self.PreviewName(str(Entry.get("NewFileName", "~^")), FileName, OutputPath, Entry, OutConf)
if Entry.get("Overwrite", 1) == 0 and os.path.exists(os.path.join(OutputPath, NewName)):
Steps.append(f"{OutputPath}: {Pattern} matches, but {NewName} exists and Overwrite = 0.")
continue
Action = "moved" if OutConf.get("DeleteOrg", 1) == 1 else "copied"
Steps.append(f"{OutputPath}: {Pattern} matches, so it's {Action} and named {NewName}.")
return SimulationResult(Steps, f"{OutputPath}/{NewName}", "Sorted")
Failed = self.SpecialDir("FailedDir") Failed = self.SpecialDir("FailedDir")
Steps.append(f"No output takes it, so it goes to {Failed}.") Steps.append(f"No output takes it, so it goes to {Failed}.")
return SimulationResult(Steps, f"{Failed}/{FileName}" if Failed else None, "Failed") return SimulationResult(Steps, f"{Failed}/{FileName}" if Failed else None, "Failed")
@@ -813,7 +851,7 @@ class Setup:
for Key in ("InputDir", "OutputDir"): for Key in ("InputDir", "OutputDir"):
for Raw in self.DirList(Key): for Raw in self.DirList(Key):
if Leftover.search(self.ResolveDir(Raw)): if Leftover.search(self.ResolveDir(Raw)):
Found.append(("Error", f"{Raw} doesn't resolve (SaraSortd only replaces one level of variables).")) Found.append(("Error", f"{Raw} uses a %Name% that GlobalConf.toml doesn't define."))
Inputs = list(dict.fromkeys(self.Inputs)) Inputs = list(dict.fromkeys(self.Inputs))
Outputs = list(dict.fromkeys(self.Outputs)) Outputs = list(dict.fromkeys(self.Outputs))
@@ -844,20 +882,14 @@ class Setup:
UsesOwnInputs = UsesOwnInputs or "ValidInputDirs" in Entry UsesOwnInputs = UsesOwnInputs or "ValidInputDirs" in Entry
for Missing in ("Pattern", "NewFileName", "NextNum", "NextChar", "CaseSensitive", "Overwrite"): for Missing in ("Pattern", "NewFileName", "NextNum", "NextChar", "CaseSensitive", "Overwrite"):
if Missing not in Entry: if Missing not in Entry:
Found.append(("Error", f"{OutputPath}: [[Files]] #{Position + 1} has no {Missing}; SaraSortd will crash on it.")) if Missing != "Pattern" and Daemon and VersionAtLeast(Daemon, (7, 3)):
if Entry.get("Pattern") == "*" and "ValidInputDirs" not in Entry and Position < len(Entries) - 1: Found.append(("Info", f"{OutputPath}: [[Files]] #{Position + 1} has no {Missing}; SaraSortd fills it in from the template on its next start."))
Found.append(("Warning", f"{OutputPath}: [[Files]] #{Position + 1} is a catch-all \"*\", so the entries after it never get a file.")) else:
for InputPath in Inputs: Found.append(("Error", f"{OutputPath}: [[Files]] #{Position + 1} has no {Missing}; SaraSortd will crash on it."))
# SaraSortd tries outputs in OutputDir order and stops at the first match. if Daemon and not VersionAtLeast(Daemon, (7, 3)):
CatchAll = None Found.append(("Warning", f"SaraSort shows how SaraSortd 7.3 sorts, and SaraSortd {Daemon} differs: a catch-all (\"*\") "
for FoundEdge in sorted( "output listed first takes every file, nested %Name%s don't resolve, and ! & @ ~ ^ in "
(FoundEdge for FoundEdge in Edges if FoundEdge.Input == InputPath), "paths get swapped. Update SaraSortd to 7.3."))
key=lambda FoundEdge: Outputs.index(FoundEdge.Output),
):
if CatchAll:
Found.append(("Warning", f"{CatchAll} takes every file from {InputPath} first, so {FoundEdge.Output} never gets any ({', '.join(FoundEdge.Patterns)})."))
elif "*" in FoundEdge.Patterns:
CatchAll = FoundEdge.Output
Central = self.CentralFolder() Central = self.CentralFolder()
if (Central or self.Data.get("DirConfs")) and Daemon and not VersionAtLeast(Daemon, (7, 2)): if (Central or self.Data.get("DirConfs")) and Daemon and not VersionAtLeast(Daemon, (7, 2)):
Found.append(("Error", f"Directory configs are kept outside their directories (DirConfDir / [DirConfs]), " Found.append(("Error", f"Directory configs are kept outside their directories (DirConfDir / [DirConfs]), "
@@ -870,10 +902,5 @@ class Setup:
if UsesOwnInputs and Daemon and not VersionAtLeast(Daemon, (7, 1)): if UsesOwnInputs and Daemon and not VersionAtLeast(Daemon, (7, 1)):
Found.append(("Error", f"Some [[Files]] entries have their own ValidInputDirs, which SaraSortd {Daemon} ignores. Update SaraSortd to 7.1.")) Found.append(("Error", f"Some [[Files]] entries have their own ValidInputDirs, which SaraSortd {Daemon} ignores. Update SaraSortd to 7.1."))
for Raw in self.DirList("InputDir") + self.DirList("OutputDir"):
Odd = [Char for Char in (self.Vars.get(Key) for Key in ("NextNum", "NextChar", "Parent", "OrgFileName", "OrgFileType")) if Char and Char in Raw]
if Odd:
Found.append(("Warning", f"{Raw} contains {', '.join(Odd)}, which SaraSortd swaps for a variable."))
Order = {"Error": 0, "Warning": 1, "Info": 2} Order = {"Error": 0, "Warning": 1, "Info": 2}
return sorted(Found, key=lambda Item: Order[Item[0]]) return sorted(Found, key=lambda Item: Order[Item[0]])
+5 -5
View File
@@ -151,12 +151,12 @@ def Backup(Config: AppConfig) -> Optional[Path]:
return BackupPath return BackupPath
def _RemapList(TheSetup: Setup, Values, Remap: dict[str, str], Parent: Optional[str] = None) -> None: def _RemapList(TheSetup: Setup, Values, Remap: dict[str, str]) -> None:
"""Points entries that resolve to a moved directory at its new path.""" """Points entries that resolve to a moved directory at its new path."""
if not isinstance(Values, list): if not isinstance(Values, list):
return return
for Index in range(len(Values)): for Index in range(len(Values)):
Resolved = TheSetup.Parse(str(Values[Index]), Parent=Parent) Resolved = TheSetup.ParsePath(str(Values[Index]))
if Resolved in Remap: if Resolved in Remap:
Values[Index] = TheSetup.Shorthand(Remap[Resolved]) Values[Index] = TheSetup.Shorthand(Remap[Resolved])
@@ -175,7 +175,7 @@ def ApplyBundle(Config: AppConfig, TheBundle: Bundle, Remap: dict[str, str]) ->
# A moved directory keeps its own config file from [DirConfs] (SaraSortd 7.2). # A moved directory keeps its own config file from [DirConfs] (SaraSortd 7.2).
DirConfs = GlobalDoc.get("DirConfs") DirConfs = GlobalDoc.get("DirConfs")
for Key in list(DirConfs.keys()) if DirConfs is not None else []: for Key in list(DirConfs.keys()) if DirConfs is not None else []:
Resolved = TheSetup.Parse(str(Key)).rstrip("/") Resolved = TheSetup.ParsePath(str(Key)).rstrip("/")
if Resolved in Remap: if Resolved in Remap:
DirConfs[TheSetup.Shorthand(Remap[Resolved])] = DirConfs[Key] DirConfs[TheSetup.Shorthand(Remap[Resolved])] = DirConfs[Key]
del DirConfs[Key] del DirConfs[Key]
@@ -191,9 +191,9 @@ def ApplyBundle(Config: AppConfig, TheBundle: Bundle, Remap: dict[str, str]) ->
Target = Remap.get(Entry.Directory, Entry.Directory) Target = Remap.get(Entry.Directory, Entry.Directory)
Document = tomlkit.parse(Entry.Content) Document = tomlkit.parse(Entry.Content)
Parent = os.path.basename(Entry.Directory) Parent = os.path.basename(Entry.Directory)
_RemapList(TheSetup, Document.get("ValidInputDirs"), Remap, Parent) _RemapList(TheSetup, Document.get("ValidInputDirs"), Remap)
for FileEntry in Document.get("Files", []): for FileEntry in Document.get("Files", []):
_RemapList(TheSetup, FileEntry.get("ValidInputDirs"), Remap, Parent) _RemapList(TheSetup, FileEntry.get("ValidInputDirs"), Remap)
NewName = os.path.basename(Target) NewName = os.path.basename(Target)
if NewName != Parent: if NewName != Parent:
if "ParentDir" in Document: if "ParentDir" in Document: