"""Shared app-wide constants: palette, theme, cache paths. Palette and role-mapping deliberately mirror SaraView's (see SaraView/Backend/DefaultThemes/CatppuccinMocha.toml and Frontend/src/Styles/Theme.css): one accent colour (Purple/Mauve) used everywhere, not a mauve+lavender mix. """ import shutil import subprocess from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Callable, Optional import flet as ft import toml class Palette: """Live values for the active theme (see ApplyTheme). Starts as Catppuccin Mocha so anything constructed before ApplyTheme() runs still looks right. Role names/values are mutated in place from ~/.config/SaraSuite/Themes/*.toml, which uses the exact same [Palette] schema as SaraView's own theme files — see SaraView/Backend/DefaultThemes/*.toml and Frontend/src/ThemeRoles.js.""" Mauve = "#cba6f7" # SaraView's "Purple" role: the one accent colour Red = "#f38ba8" Peach = "#fab387" # SaraView's "Orange" Yellow = "#f9e2af" Green = "#a6e3a1" Blue = "#89b4fa" Text = "#cdd6f4" White = "#cdd6f4" Overlay0 = "#6c7086" # SaraView's "TextMuted" Surface1 = "#45475a" # SaraView's "Border" Surface0 = "#313244" # SaraView's "SurfaceAlt" Base = "#1e1e2e" # SaraView's "Background" Mantle = "#181825" # SaraView's "Surface" Crust = "#11111b" # SaraView's "Black" AppName = "SaraFem" # Bundled in assets/fonts and always registered, so it works as FontFamily # without being installed. Any other FontFamily is looked up among the # system's installed fonts. BundledFontFamily = "Fixedsys Excelsior" ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme CacheDir = Path.home() / ".cache" / "SaraFem" ThumbnailCacheDir = CacheDir / "Thumbnails" # Config moved from ~/.config/SaraFem/ into a SaraSuite-wide layout so other # Sara* programs (SaraTag, SaraView) can eventually read from the same place. SharedConfigDir = Path.home() / ".config" / "SaraSuite" ThemesDir = SharedConfigDir / "Themes" ConfigDir = SharedConfigDir / "SaraFem" ConfigPath = ConfigDir / "Config.toml" # Settings shared by every SaraSuite program. Same key names as the matching # fields in each program's own Config.toml. SharedConfigPath = SharedConfigDir / "Config.toml" BookmarksPath = ConfigDir / ".sfem_bookmarks.toml" _OldConfigDir = Path.home() / ".config" / "SaraFem" # The exact role names SaraView's theme files use, and the SaraFem Palette # attribute each one maps to. _ThemeRoleMap = { "Background": "Base", "Surface": "Mantle", "SurfaceAlt": "Surface0", "Border": "Surface1", "Text": "Text", "TextMuted": "Overlay0", "Black": "Crust", "White": "White", "Red": "Red", "Orange": "Peach", "Yellow": "Yellow", "Green": "Green", "Blue": "Blue", "Purple": "Mauve", } # Bundled so SaraFem has real theme choices even before SaraView (or anything # else) has populated the shared Themes directory. Copied directly from # SaraView/Backend/DefaultThemes/*.toml so the colours actually match. BuiltinThemes: dict[str, dict[str, str]] = { "CatppuccinMocha": { "Background": "#1e1e2e", "Surface": "#181825", "SurfaceAlt": "#313244", "Border": "#45475a", "Text": "#cdd6f4", "TextMuted": "#6c7086", "Black": "#11111b", "White": "#cdd6f4", "Red": "#f38ba8", "Orange": "#fab387", "Yellow": "#f9e2af", "Green": "#a6e3a1", "Blue": "#89b4fa", "Purple": "#cba6f7", }, "Forest": { "Background": "#1b2420", "Surface": "#141b17", "SurfaceAlt": "#243029", "Border": "#334539", "Text": "#d6e5d0", "TextMuted": "#7c9184", "Black": "#0d120f", "White": "#eef5ea", "Red": "#e0685a", "Orange": "#e0a458", "Yellow": "#e0c458", "Green": "#7fb069", "Blue": "#5c9e8f", "Purple": "#9b7fb0", }, "Gruvbox": { "Background": "#282828", "Surface": "#1d2021", "SurfaceAlt": "#3c3836", "Border": "#504945", "Text": "#ebdbb2", "TextMuted": "#a89984", "Black": "#1d2021", "White": "#fbf1c7", "Red": "#fb4934", "Orange": "#fe8019", "Yellow": "#fabd2f", "Green": "#b8bb26", "Blue": "#83a598", "Purple": "#d3869b", }, } def _MigrateOldConfig() -> None: """One-time move from the pre-SaraSuite ~/.config/SaraFem/ location.""" if ConfigDir.exists() or not _OldConfigDir.exists(): return ConfigDir.mkdir(parents=True, exist_ok=True) for FileName in ("Config.toml", ".sfem_bookmarks.toml"): OldFile = _OldConfigDir / FileName NewFile = ConfigDir / FileName if OldFile.exists() and not NewFile.exists(): NewFile.write_text(OldFile.read_text()) def EnsureSharedThemes() -> None: """Seed the shared Themes directory with the bundled defaults, so the location is actually populated for other SaraSuite programs to read.""" ThemesDir.mkdir(parents=True, exist_ok=True) for Name, RoleValues in BuiltinThemes.items(): ThemeFile = ThemesDir / f"{Name}.toml" if not ThemeFile.exists(): ThemeFile.write_text(toml.dumps({"Palette": RoleValues})) def AvailableThemes() -> list[str]: EnsureSharedThemes() return sorted(F.stem for F in ThemesDir.glob("*.toml")) def ApplyTheme(ThemeName: str) -> None: """Mutate Palette's attributes in place to the named theme. Must run before any control that reads Palette.X is constructed — Palette.X values are plain strings baked into controls at build time, not live references.""" EnsureSharedThemes() RoleValues = dict(BuiltinThemes.get(ThemeName, BuiltinThemes["CatppuccinMocha"])) ThemeFile = ThemesDir / f"{ThemeName}.toml" if ThemeFile.exists(): try: RoleValues.update(toml.loads(ThemeFile.read_text()).get("Palette", {})) except toml.TomlDecodeError: pass for RoleName, AttrName in _ThemeRoleMap.items(): if RoleName in RoleValues: setattr(Palette, AttrName, RoleValues[RoleName]) @dataclass class SaraTagIntegrationConfig: """Mirrors SaraView's Backend/SaraViewConf.toml [SaraTag] section, so both apps talk to SaraTag the same opt-in way. Disabled until pointed at a SaraTag checkout, e.g. CoreModulePath = "/home/sarah/Applications/SaraTag/.saratag".""" Enabled: bool = False CoreModulePath: str = "" HideInternalFiles: bool = True InternalPatterns: list[str] = field( default_factory=lambda: [".saratag", ".SaraTag*", "*.book.toml"] ) @dataclass class SaraSortdIntegrationConfig: """Points at a SaraSortd checkout's GlobalConf.toml (read-only — SaraFem never writes to it). Disabled until pointed at one, e.g. GlobalConfPath = "/home/sarah/Applications/SaraSortd/GlobalConf.toml".""" Enabled: bool = False GlobalConfPath: str = "" @dataclass class AppConfig: """Persisted user settings. Defaults match SaraFem's existing behaviour (nothing changes visually until the user opts in via Ctrl+B/P/H/T or edits Config.toml directly).""" # True: Theme, FontFamily and HideWindowTitleBar come from the shared # ~/.config/SaraSuite/Config.toml, like every other SaraSuite program. # False: SaraFem uses its own values below instead. UseSharedSettings: bool = True Theme: str = "CatppuccinMocha" # matches a file in ~/.config/SaraSuite/Themes/ FontFamily: str = BundledFontFamily HideWindowTitleBar: bool = False # the actual OS window title bar, not the toolbar below ShowDotfiles: bool = True ViewMode: str = "list" # "list" or "grid" # List view: which metadata columns show, beyond the always-present Name. ListShowDescription: bool = False ListShowSize: bool = True ListShowModified: bool = True ListShowOwner: bool = False ListShowCreated: bool = False ListShowContent: bool = False ListShowAccess: bool = False # List view column widths — drag the header dividers to set these, or # type a number in Options; both write here. ListWidthName: int = 220 ListWidthDescription: int = 90 ListWidthSize: int = 80 ListWidthModified: int = 130 ListWidthOwner: int = 90 ListWidthCreated: int = 130 ListWidthContent: int = 90 ListWidthAccess: int = 60 # Grid view. GridIconSize: int = 64 GridNameBelowIcon: bool = True ListIconSize: int = 32 FontSize: int = 14 # entry name text, list and grid alike; metadata/header text scales with it ToolbarHamburger: bool = False # collapse the toolbar's buttons into one overflow menu # Batch-rename pattern for multi-select rename, syntax modeled on # SaraSortd's own placeholder characters (see ~/Applications/SaraSortd's # GlobalConf.toml [Variables]): ~ = typed base name, ! = auto-incrementing # counter (blank for the first item, " 1", " 2", ... after), ^ = the # original file's extension. BatchRenamePattern: str = "~!^" DeleteZipAfterExtract: bool = False DefaultArchiveFormat: str = "zip" # "zip" or "targz" — Compress's default format # The in-app toolbar: breadcrumbs and the button row share one bar now # (each taking half the width), positioned top or bottom, hideable as a whole. ShowToolbar: bool = True ShowBreadcrumbs: bool = True BreadcrumbsPosition: str = "top" # "top" or "bottom" ShowNewDirectoryButton: bool = True ShowPasteButton: bool = True ShowViewToggleButton: bool = True ShowOptionsButton: bool = True # The left-hand panel, which holds three independently-toggleable sections: # Bookmarks (flat, user-curated, Bookmarks.py), Devices (mounted removable # media, Devices.py) and Filetree (a real expandable directory tree, # Filetree.py). ShowSidebar: bool = True SidebarSide: str = "left" # "left" or "right" SidebarWidth: int = 220 ShowBookmarks: bool = True ShowDevices: bool = True AutoMountDevices: bool = True # mount USB sticks etc. as soon as they're plugged in ShowInternalDrives: bool = False # also list internal disks that aren't system mounts ShowFiletree: bool = True FiletreeRoot: str = "/" BookmarksMinHeight: int = 0 # 0 = no constraint BookmarksMaxHeight: int = 0 DevicesMinHeight: int = 0 DevicesMaxHeight: int = 0 FiletreeMinHeight: int = 0 FiletreeMaxHeight: int = 0 # Same idea as SplitPreviewTagsEvenly: proportional 50/50, overrides the # heights above, adapts to window size instead of a fixed pixel guess. SplitBookmarksFiletreeEvenly: bool = False ShowPreview: bool = False PreviewSide: str = "right" # "left" or "right" PreviewWidth: int = 320 # Only applied when Tags shares Preview's column (TagsPosition == "under_preview") — # otherwise Preview always fills the full height, per Sarah's clarification. PreviewMinHeight: int = 0 PreviewMaxHeight: int = 0 ShowTags: bool = False TagsPosition: str = "under_preview" # "under_preview", "top", or "bottom" TagsLayout: str = "flex" # "flex" (wraps horizontally) or "vertical" (one per row) TagMinWidth: int = 0 # 0 = no minimum, chip hugs its content TagMaxWidth: int = 0 # 0 = no maximum # The tags box's own height (not per-chip). Always applied, regardless of # TagsPosition — matters most in vertical layout, where it can grow tall. TagPanelMinHeight: int = 0 TagPanelMaxHeight: int = 0 # Proportional 50/50 split for Preview+Tags when stacked (TagsPosition == # "under_preview"), instead of fixed pixel heights — adapts to window size # automatically. Overrides PreviewMinHeight/MaxHeight and TagPanelMinHeight/ # MaxHeight when on, since a fixed height and an even flex split conflict. SplitPreviewTagsEvenly: bool = False SaraTag: SaraTagIntegrationConfig = field(default_factory=SaraTagIntegrationConfig) SaraSortd: SaraSortdIntegrationConfig = field(default_factory=SaraSortdIntegrationConfig) def Save(self) -> None: ConfigDir.mkdir(parents=True, exist_ok=True) ConfigPath.write_text(toml.dumps(asdict(self))) def LoadConfig() -> AppConfig: _MigrateOldConfig() if not ConfigPath.exists(): Config = AppConfig() Config.Save() return Config try: Data = toml.loads(ConfigPath.read_text()) except toml.TomlDecodeError: return AppConfig() Defaults = AppConfig() KnownFields = {Field for Field in Defaults.__dataclass_fields__} Filtered = {Key: Value for Key, Value in Data.items() if Key in KnownFields} SaraTagData = Filtered.pop("SaraTag", {}) KnownSaraTagFields = {Field for Field in SaraTagIntegrationConfig.__dataclass_fields__} SaraTagFiltered = {Key: Value for Key, Value in SaraTagData.items() if Key in KnownSaraTagFields} Filtered["SaraTag"] = SaraTagIntegrationConfig(**SaraTagFiltered) SaraSortdData = Filtered.pop("SaraSortd", {}) KnownSaraSortdFields = {Field for Field in SaraSortdIntegrationConfig.__dataclass_fields__} SaraSortdFiltered = {Key: Value for Key, Value in SaraSortdData.items() if Key in KnownSaraSortdFields} Filtered["SaraSortd"] = SaraSortdIntegrationConfig(**SaraSortdFiltered) return AppConfig(**Filtered) @dataclass class SharedSettings: """~/.config/SaraSuite/Config.toml: the settings every SaraSuite program shares. A program whose own UseSharedSettings is off reads these same keys from its own Config.toml instead.""" Theme: str = "CatppuccinMocha" FontFamily: str = BundledFontFamily HideWindowTitleBar: bool = False def Save(self) -> None: SharedConfigDir.mkdir(parents=True, exist_ok=True) SharedConfigPath.write_text(toml.dumps(asdict(self))) def _OwnSettings(Config: AppConfig) -> SharedSettings: return SharedSettings(Config.Theme, Config.FontFamily, Config.HideWindowTitleBar) def LoadSharedSettings(Config: AppConfig) -> SharedSettings: """The shared file, created from SaraFem's own values the first time so switching to it doesn't change anything visible.""" if not SharedConfigPath.exists(): Shared = _OwnSettings(Config) Shared.Save() return Shared Result = _OwnSettings(Config) # fallback for keys the file doesn't have try: Data = toml.loads(SharedConfigPath.read_text()) except toml.TomlDecodeError: return Result for Key in SharedSettings.__dataclass_fields__: if Key in Data: setattr(Result, Key, Data[Key]) return Result def EffectiveSettings(Config: AppConfig) -> SharedSettings: """What SaraFem actually uses: the shared settings, or its own.""" return LoadSharedSettings(Config) if Config.UseSharedSettings else _OwnSettings(Config) def ExportSettings(Config: AppConfig, DestPath: Path) -> None: """One portable file bundling both the cross-program shared settings (Theme/Font/TitleBar) and SaraFem's own, under separate top-level sections (Features/Major: "Exporting and importing settings as one file... applies to every other SaraSuite program"). A future SaraView/ SaraTag/etc. import would read the same [SaraSuite] section plus its own top-level one, ignoring [SaraFem] — the same convention ImportSettings below uses to ignore sections that aren't its own.""" Data = {"SaraSuite": asdict(_OwnSettings(Config)), "SaraFem": asdict(Config)} DestPath.write_text(toml.dumps(Data)) def ImportSettings(Config: AppConfig, SrcPath: Path) -> None: """Applies a [SaraSuite] section (if present) to the shared settings file and a [SaraFem] section (if present) onto Config in place, field by field — a field the import doesn't mention is left as it was, rather than reset to default, and unknown sections/fields (another program's, or a newer SaraFem version's) are silently skipped: the "should stack fine with others if installed or not... one file works anywhere on the same version no matter what you have installed" compatibility the feature asked for. Raises toml.TomlDecodeError on an unreadable file — callers show that to the user rather than silently doing nothing.""" Data = toml.loads(SrcPath.read_text()) SharedData = Data.get("SaraSuite") if isinstance(SharedData, dict): KnownSharedFields = {F for F in SharedSettings.__dataclass_fields__} Filtered = {K: V for K, V in SharedData.items() if K in KnownSharedFields} Merged = asdict(_OwnSettings(Config)) Merged.update(Filtered) SharedSettings(**Merged).Save() OwnData = Data.get("SaraFem") if isinstance(OwnData, dict): KnownFields = {F for F in AppConfig.__dataclass_fields__} Filtered = {K: V for K, V in OwnData.items() if K in KnownFields} SaraTagData = Filtered.pop("SaraTag", None) if isinstance(SaraTagData, dict): KnownSaraTagFields = {F for F in SaraTagIntegrationConfig.__dataclass_fields__} SaraTagFiltered = {K: V for K, V in SaraTagData.items() if K in KnownSaraTagFields} for Key, Value in SaraTagFiltered.items(): setattr(Config.SaraTag, Key, Value) SaraSortdData = Filtered.pop("SaraSortd", None) if isinstance(SaraSortdData, dict): KnownSaraSortdFields = {F for F in SaraSortdIntegrationConfig.__dataclass_fields__} SaraSortdFiltered = {K: V for K, V in SaraSortdData.items() if K in KnownSaraSortdFields} for Key, Value in SaraSortdFiltered.items(): setattr(Config.SaraSortd, Key, Value) for Key, Value in Filtered.items(): setattr(Config, Key, Value) Config.Save() def ApplyAppearance(Settings: SharedSettings) -> None: """Theme colours and font. Must run before anything reads Palette.X or calls BuildTheme(). The window title bar is applied by Main.""" global ActiveFontFamily ApplyTheme(Settings.Theme) ActiveFontFamily = Settings.FontFamily or BundledFontFamily def InstalledFontFamilies() -> list[str]: """Font families fontconfig knows about, for the font picker. The bundled font is always first, since it isn't installed system-wide.""" try: Result = subprocess.run(["fc-list", ":", "family"], capture_output=True, text=True, timeout=5) Families = {Line.split(",")[0].strip() for Line in Result.stdout.splitlines() if Line.strip()} except (OSError, subprocess.SubprocessError): Families = set() Families.discard(BundledFontFamily) return [BundledFontFamily] + sorted(Families, key=str.lower) def BuildTheme() -> ft.Theme: ColorScheme = ft.ColorScheme( primary=Palette.Mauve, on_primary=Palette.Crust, primary_container=Palette.Surface0, on_primary_container=Palette.Mauve, secondary=Palette.Mauve, on_secondary=Palette.Crust, secondary_container=Palette.Surface0, on_secondary_container=Palette.Mauve, surface=Palette.Base, on_surface=Palette.Text, on_surface_variant=Palette.Overlay0, surface_container=Palette.Mantle, surface_container_low=Palette.Mantle, surface_container_high=Palette.Surface0, surface_container_highest=Palette.Surface1, outline=Palette.Surface1, outline_variant=Palette.Surface1, error=Palette.Red, on_error=Palette.Crust, ) return ft.Theme( font_family=ActiveFontFamily, color_scheme=ColorScheme, use_material3=True, scaffold_bgcolor=Palette.Base, canvas_color=Palette.Base, dialog_theme=ft.DialogTheme( bgcolor=Palette.Mantle, shape=ft.RoundedRectangleBorder(radius=8, side=ft.BorderSide(1, Palette.Mauve)), title_text_style=ft.TextStyle(color=Palette.Mauve, size=18, font_family=ActiveFontFamily), ), ) def ChipButtonStyle() -> ft.ButtonStyle: """SaraView's button look: SurfaceAlt fill, 1px Purple border, small radius.""" return ft.ButtonStyle( color=Palette.Text, bgcolor=Palette.Surface0, side=ft.BorderSide(1, Palette.Mauve), shape=ft.RoundedRectangleBorder(radius=4), ) def BlendHex(BaseHex: str, AccentHex: str, Ratio: float) -> str: """Solid colour mixing Base and Accent (0 = pure Base, 1 = pure Accent). Used for tinted-but-still-dark backgrounds derived from the theme's own roles (e.g. a success toast) instead of inventing colours outside it.""" BaseRGB = tuple(int(BaseHex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4)) AccentRGB = tuple(int(AccentHex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4)) Mixed = (round(B + (A - B) * Ratio) for B, A in zip(BaseRGB, AccentRGB)) return "#" + "".join(f"{C:02x}" for C in Mixed) def NotificationStyle(Kind: str) -> tuple[str, str]: """(Background, AccentColor) for a Kind of "success" or "error" toast, both derived from the theme's own Green/Red so it stays coherent.""" Accent = Palette.Green if Kind == "success" else Palette.Red Background = BlendHex(Palette.Mantle, Accent, 0.12) return Background, Accent def ApplyHeightBounds(Content: ft.Control, MinHeight: int, MaxHeight: int) -> ft.Control: """Wrap Content in a fixed-height box if a bound is set (Max wins if both are set, since Flet has no true min/max-range constraint — MaxHeight caps it with internal scrolling, MinHeight alone gives it a floor). 0/0 means natural sizing.""" if MaxHeight > 0: return ft.Container(content=Content, height=MaxHeight) if MinHeight > 0: return ft.Container(content=Content, height=MinHeight) return Content def ResizeHandle( Axis: str, Length: Optional[int], OnDrag: Callable[[float], None], OnDragEnd: Callable[[], None], ) -> ft.GestureDetector: """A thin draggable divider, shared by the file list's column headers, the sidebar/preview section splits, and the sidebar/preview *panel* widths. "horizontal" resizes side-by-side things (drag left/right, e.g. a column or a whole side panel) and needs Length = the row's height, not just 6px wide with an unset (collapses to zero) height; "vertical" resizes stacked things (drag up/down, e.g. Bookmarks vs Devices) and needs Length = the panel's width, for the same reason on the other axis. Pass Length=None instead when the immediate parent Row/Column already stretches its children on that cross axis (confirmed, not assumed — Flet doesn't do this by default) — then an explicit value would fight that stretch instead of matching it. OnDrag fires with the incremental delta on every move — cheap for a single header cell or a sidebar section, but callers with many redraws to do (the file list's rows) should treat it as a live *preview* and do the expensive rebuild in OnDragEnd instead, which fires once on release; that's also where Config gets saved, so a drag isn't disk I/O on every pixel of movement.""" IsHorizontal = Axis == "horizontal" Handle = ft.GestureDetector( content=ft.Container( width=6 if IsHorizontal else Length, height=Length if IsHorizontal else 6, bgcolor=Palette.Surface1, ), mouse_cursor=ft.MouseCursor.RESIZE_COLUMN if IsHorizontal else ft.MouseCursor.RESIZE_ROW, ) if IsHorizontal: Handle.on_horizontal_drag_update = lambda e: OnDrag(e.primary_delta or 0) Handle.on_horizontal_drag_end = lambda e: OnDragEnd() else: Handle.on_vertical_drag_update = lambda e: OnDrag(e.primary_delta or 0) Handle.on_vertical_drag_end = lambda e: OnDragEnd() return Handle # The group tag shared by every Draggable source and DragTarget in the app, # so bookmarks/filetree only accept drops that actually originate from # SaraFem's own file list (not, say, a Chip or some future draggable widget). DragGroup = "sarafem_entry" def UniqueDestination(Destination: Path) -> Path: if not Destination.exists(): return Destination Stem, Suffix = Destination.stem, Destination.suffix Counter = 2 while True: Candidate = Destination.with_name(f"{Stem} (copy {Counter}){Suffix}") if not Candidate.exists(): return Candidate Counter += 1 def FormatBatchName(Pattern: str, BaseName: str, Index: int, OriginalSuffix: str = "") -> str: """SaraSortd-style placeholder substitution for batch rename (see ~/Applications/SaraSortd's GlobalConf.toml [Variables] for the reference syntax this mirrors): ~ = typed base name, ! = auto-incrementing counter (blank for the first item, " 1", " 2", ... after), ^ = the original file's extension, preserved unless the pattern omits it.""" CounterText = "" if Index == 0 else f" {Index}" return Pattern.replace("~", BaseName).replace("!", CounterText).replace("^", OriginalSuffix) def MoveIntoDirectory(SourcePath: Path, DestinationDir: Path) -> Path: """Move SourcePath into DestinationDir (drag-and-drop uses this). Returns the final destination path. Raises OSError if the move is invalid.""" if SourcePath.parent == DestinationDir: raise OSError(f'"{SourcePath.name}" is already in this directory.') ResolvedSource = SourcePath.resolve() if SourcePath.is_dir() and ( DestinationDir.resolve() == ResolvedSource or ResolvedSource in DestinationDir.resolve().parents ): raise OSError(f"Can't move \"{SourcePath.name}\" into itself or one of its own subdirectories.") Destination = UniqueDestination(DestinationDir / SourcePath.name) shutil.move(str(SourcePath), str(Destination)) return Destination