diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py index 24d9c82d49..04c2342b62 100644 --- a/android/tests_backend/app.py +++ b/android/tests_backend/app.py @@ -1,3 +1,4 @@ +from contextlib import contextmanager from pathlib import Path import pytest @@ -24,21 +25,17 @@ def __init__(self, app): def get_app_context(self): return self.native.getApplicationContext() - @property - def config_path(self): - return Path(self.get_app_context().getFilesDir().getPath()) / "config" - - @property - def data_path(self): - return Path(self.get_app_context().getFilesDir().getPath()) / "data" - - @property - def cache_path(self): - return Path(self.get_app_context().getCacheDir().getPath()) - - @property - def logs_path(self): - return Path(self.get_app_context().getFilesDir().getPath()) / "log" + @contextmanager + def prepare_paths(self, *, custom): + if custom: + pytest.xfail("This backend doesn't implement app path customization.") + + yield { + "config": Path(self.get_app_context().getFilesDir().getPath()) / "config", + "data": Path(self.get_app_context().getFilesDir().getPath()) / "data", + "cache": Path(self.get_app_context().getCacheDir().getPath()), + "logs": Path(self.get_app_context().getFilesDir().getPath()) / "log", + } def assert_app_icon(self, icon): pytest.xfail("Android apps don't have app icons at runtime") diff --git a/changes/3482.bugfix.rst b/changes/3482.bugfix.rst new file mode 100644 index 0000000000..3b50a73f3f --- /dev/null +++ b/changes/3482.bugfix.rst @@ -0,0 +1 @@ +The GTK backend now respects XDG app path environment variables. diff --git a/changes/3482.removal.rst b/changes/3482.removal.rst new file mode 100644 index 0000000000..52d03b16bc --- /dev/null +++ b/changes/3482.removal.rst @@ -0,0 +1 @@ +The GTK backend now respects XDG app path environment variables, which means the places it expects to find app data might change in environments where those variables were previously set and being ignored. You'll have to manage that transition; Toga will not move any data itself. diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index 74b3d9c311..539f84de00 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -1,6 +1,8 @@ +from contextlib import contextmanager from pathlib import Path import PIL.Image +import pytest from rubicon.objc import SEL, NSPoint, ObjCClass, objc_id, send_message import toga @@ -34,28 +36,25 @@ def __init__(self, app): NSWindow.allowsAutomaticWindowTabbing = False assert isinstance(self.app._impl.native, NSApplication) - @property - def config_path(self): - return Path.home() / "Library/Preferences/org.beeware.toga.testbed" - - @property - def data_path(self): - return Path.home() / "Library/Application Support/org.beeware.toga.testbed" - - @property - def cache_path(self): - return Path.home() / "Library/Caches/org.beeware.toga.testbed" - - @property - def logs_path(self): - return Path.home() / "Library/Logs/org.beeware.toga.testbed" - @property def is_cursor_visible(self): # There's no API level mechanism to detect cursor visibility; # fall back to the implementation's proxy variable. return self.app._impl._cursor_visible + @contextmanager + def prepare_paths(self, *, custom): + if custom: + pytest.xfail("This backend doesn't implement app path customization.") + + yield { + "config": Path.home() / "Library/Preferences/org.beeware.toga.testbed", + "data": Path.home() + / "Library/Application Support/org.beeware.toga.testbed", + "cache": Path.home() / "Library/Caches/org.beeware.toga.testbed", + "logs": Path.home() / "Library/Logs/org.beeware.toga.testbed", + } + def unhide(self): self.app._impl.native.unhide(self.app._impl.native) diff --git a/docs/reference/api/resources/app_paths.rst b/docs/reference/api/resources/app_paths.rst index 505ca3bc44..1572729d72 100644 --- a/docs/reference/api/resources/app_paths.rst +++ b/docs/reference/api/resources/app_paths.rst @@ -23,7 +23,8 @@ location to which they can be considered relative. Complicating matters further, operating systems have conventions (and in some cases, hard restrictions) over where certain file types should be stored. For example, macOS provides the ``~/Library/Application Support`` folder; Linux -encourages use of the ``~/.config`` folder (amongst others), and Windows +encourages use of the ``~/.config`` folder (amongst others), +while allowing users to override the defaults; and Windows provides the ``AppData/Local`` folder in the user's home directory. Application sandbox and security policies will sometimes prevent reading or writing files in any location other than these pre-approved locations. @@ -43,6 +44,16 @@ up you to create any desired subdirectory - if you want to create a ``apps.path.config`` will exist, but you must take responsibility for creating the ``credentials`` subdirectory before saving ``user.toml``. +Notes +----- + +* The GTK backend partially implements the + `XDG Base Directory Specification `__; + specifically, it uses the default XDG paths for data, config, state, and cache + directories, and respects the environment variables that allow overriding the + default paths. Per the specification, the override paths must be absolute; relative + paths will be ignored. + Reference --------- diff --git a/gtk/src/toga_gtk/paths.py b/gtk/src/toga_gtk/paths.py index 1c5c2b9a3a..45db3d7936 100644 --- a/gtk/src/toga_gtk/paths.py +++ b/gtk/src/toga_gtk/paths.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from toga import App @@ -7,14 +8,28 @@ class Paths: def __init__(self, interface): self.interface = interface + def _get_root(self, envvar, default): + custom_root_raw = os.getenv(envvar) + custom_root = Path(custom_root_raw) if custom_root_raw else None + + # The XDG Base Directory spec requires paths to be absolute + if custom_root and custom_root.is_absolute(): + return custom_root + + return Path.home() / default + def get_config_path(self): - return Path.home() / f".config/{App.app.app_name}" + root = self._get_root("XDG_CONFIG_HOME", ".config") + return root / App.app.app_name def get_data_path(self): - return Path.home() / f".local/share/{App.app.app_name}" + root = self._get_root("XDG_DATA_HOME", ".local/share") + return root / App.app.app_name def get_cache_path(self): - return Path.home() / f".cache/{App.app.app_name}" + root = self._get_root("XDG_CACHE_HOME", ".cache") + return root / App.app.app_name def get_logs_path(self): - return Path.home() / f".local/state/{App.app.app_name}/log" + root = self._get_root("XDG_STATE_HOME", ".local/state") + return root / App.app.app_name / "log" diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index a3bc35c8b6..7e9696d547 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -1,4 +1,6 @@ import os +import tempfile +from contextlib import contextmanager from pathlib import Path import PIL.Image @@ -26,24 +28,73 @@ def __init__(self, app): assert IS_WAYLAND is (os.environ.get("WAYLAND_DISPLAY", "") != "") @property - def config_path(self): - return Path.home() / ".config/testbed" + def is_cursor_visible(self): + pytest.skip("Cursor visibility not implemented on GTK") - @property - def data_path(self): - return Path.home() / ".local/share/testbed" + @contextmanager + def prepare_paths(self, *, custom): + # Backup environment variables for later restoration + backup = { + "XDG_CONFIG_HOME": os.getenv("XDG_CONFIG_HOME"), + "XDG_DATA_HOME": os.getenv("XDG_DATA_HOME"), + "XDG_CACHE_HOME": os.getenv("XDG_CACHE_HOME"), + "XDG_STATE_HOME": os.getenv("XDG_STATE_HOME"), + } - @property - def cache_path(self): - return Path.home() / ".cache/testbed" + # Creating this variable here so it can be checked during cleanup + temp_custom_dir = None - @property - def logs_path(self): - return Path.home() / ".local/state/testbed/log" + try: + if custom: + # This will be cleaned up later + temp_custom_dir = tempfile.TemporaryDirectory() + + custom_root = Path(temp_custom_dir.name) + app_paths = { + "config": custom_root / "config", + "data": custom_root / "data", + "cache": custom_root / "cache", + "state": custom_root / "state", + } + + # Set the custom paths + os.environ["XDG_CONFIG_HOME"] = str(app_paths["config"]) + os.environ["XDG_DATA_HOME"] = str(app_paths["data"]) + os.environ["XDG_CACHE_HOME"] = str(app_paths["cache"]) + os.environ["XDG_STATE_HOME"] = str(app_paths["state"]) + else: + # Delete existing environment variables to replicate the + # default state. + for envvar in backup: + if envvar in os.environ: + del os.environ[envvar] + + # The default paths + app_paths = { + "config": Path.home() / ".config", + "data": Path.home() / ".local/share", + "cache": Path.home() / ".cache", + "state": Path.home() / ".local/state", + } + + yield { + "config": app_paths["config"] / "testbed", + "data": app_paths["data"] / "testbed", + "cache": app_paths["cache"] / "testbed", + "logs": app_paths["state"] / "testbed" / "log", + } + finally: + # Restore environment variables + for envvar, value in backup.items(): + if value is not None: + os.environ[envvar] = value + else: + if envvar in os.environ: + del os.environ[envvar] - @property - def is_cursor_visible(self): - pytest.skip("Cursor visibility not implemented on GTK") + # Clean up temporary custom directory if it was created + if temp_custom_dir: + temp_custom_dir.cleanup() def unhide(self): pytest.xfail("This platform doesn't have an app level unhide.") diff --git a/iOS/tests_backend/app.py b/iOS/tests_backend/app.py index 68b49490f2..411fa7036f 100644 --- a/iOS/tests_backend/app.py +++ b/iOS/tests_backend/app.py @@ -1,3 +1,4 @@ +from contextlib import contextmanager from pathlib import Path import pytest @@ -30,21 +31,18 @@ def get_path(self, search_path): ) return Path(urls[0].path) - @property - def config_path(self): - return self.get_path(NSSearchPathDirectory.ApplicationSupport) / "Config" - - @property - def data_path(self): - return self.get_path(NSSearchPathDirectory.Documents) - - @property - def cache_path(self): - return self.get_path(NSSearchPathDirectory.Cache) - - @property - def logs_path(self): - return self.get_path(NSSearchPathDirectory.ApplicationSupport) / "Logs" + @contextmanager + def prepare_paths(self, *, custom): + if custom: + pytest.xfail("This backend doesn't implement app path customization.") + + yield { + "config": self.get_path(NSSearchPathDirectory.ApplicationSupport) + / "Config", + "data": self.get_path(NSSearchPathDirectory.Documents), + "cache": self.get_path(NSSearchPathDirectory.Cache), + "logs": self.get_path(NSSearchPathDirectory.ApplicationSupport) / "Logs", + } def assert_app_icon(self, icon): pytest.xfail("iOS apps don't have app icons at runtime") diff --git a/testbed/tests/test_paths.py b/testbed/tests/test_paths.py index e448411edd..4408046e42 100644 --- a/testbed/tests/test_paths.py +++ b/testbed/tests/test_paths.py @@ -5,31 +5,34 @@ @pytest.mark.parametrize("attr", ["config", "data", "cache", "logs"]) -async def test_app_paths(app, app_probe, attr): - """Platform paths are as expected.""" - # Create path and confirm it exists - path = getattr(app.paths, attr) - assert path == getattr(app_probe, f"{attr}_path") +@pytest.mark.parametrize("custom", [False, True]) +async def test_app_paths(app, app_probe, attr, custom): + with app_probe.prepare_paths(custom=custom) as expected_paths: + """Platform paths are as expected.""" + path = getattr(app.paths, attr) + assert path == expected_paths[attr] - try: - # We can create a file in the app path - tempfile = path / f"{attr}-{os.getpid()}.txt" - - # We can write to a file in the app path - with tempfile.open("w", encoding="utf-8") as f: - f.write(f"Hello {attr}\n") - - # We can read a file in the app path - with tempfile.open("r", encoding="utf-8") as f: - assert f.read() == f"Hello {attr}\n" - - # Attempt to create the path again to confirm it is the same - newpath = getattr(app.paths, attr) - assert newpath == path - - finally: try: - if path.exists(): - shutil.rmtree(path) - except PermissionError: - pass + # We can create a folder in the app path + tempdir = path / f"testbed-{os.getpid()}" + tempdir.mkdir() # Don't create parent, to confirm it already exists + + # We can create and write to a file in the app path + tempfile = path / f"{attr}-{os.getpid()}.txt" + with tempfile.open("w", encoding="utf-8") as f: + f.write(f"Hello {attr}\n") + + # We can read a file in the app path + with tempfile.open("r", encoding="utf-8") as f: + assert f.read() == f"Hello {attr}\n" + + # Attempt to create the path again to confirm it is the same + newpath = getattr(app.paths, attr) + assert newpath == path + + finally: + try: + if path.exists(): + shutil.rmtree(path) + except PermissionError: + pass diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index aaab18ea15..55f1e2ee59 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -1,4 +1,5 @@ import ctypes +from contextlib import contextmanager from pathlib import Path from time import sleep @@ -28,22 +29,6 @@ def __init__(self, app): # The Winforms Application class is a singleton instance assert self.app._impl.native == Application - @property - def config_path(self): - return Path.home() / "AppData/Local/Tiberius Yak/Toga Testbed/Config" - - @property - def data_path(self): - return Path.home() / "AppData/Local/Tiberius Yak/Toga Testbed/Data" - - @property - def cache_path(self): - return Path.home() / "AppData/Local/Tiberius Yak/Toga Testbed/Cache" - - @property - def logs_path(self): - return Path.home() / "AppData/Local/Tiberius Yak/Toga Testbed/Logs" - @property def is_cursor_visible(self): # Despite what the documentation says, Cursor.Current never returns null in @@ -87,6 +72,32 @@ class CURSORINFO(ctypes.Structure): # input through touch or pen instead of the mouse"). hCursor is more reliable. return info.hCursor is not None + @contextmanager + def prepare_paths(self, *, custom): + if custom: + pytest.xfail("This backend doesn't implement app path customization.") + + yield { + "config": ( + Path.home() + / "AppData" + / "Local" + / "Tiberius Yak" + / "Toga Testbed" + / "Config" + ), + "data": Path.home() / "AppData/Local/Tiberius Yak/Toga Testbed/Data", + "cache": ( + Path.home() + / "AppData" + / "Local" + / "Tiberius Yak" + / "Toga Testbed" + / "Cache" + ), + "logs": Path.home() / "AppData/Local/Tiberius Yak/Toga Testbed/Logs", + } + def unhide(self): pytest.xfail("This platform doesn't have an app level unhide.")