diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py index 6b7c50e6a7..ef2f0e8e7e 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 @@ -26,21 +27,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.md b/changes/3482.bugfix.md new file mode 100644 index 0000000000..3b50a73f3f --- /dev/null +++ b/changes/3482.bugfix.md @@ -0,0 +1 @@ +The GTK backend now respects XDG app path environment variables. diff --git a/changes/3482.removal.md b/changes/3482.removal.md new file mode 100644 index 0000000000..52d03b16bc --- /dev/null +++ b/changes/3482.removal.md @@ -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 71a028c666..c1fcdf516e 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -1,7 +1,9 @@ +from contextlib import contextmanager from pathlib import Path import PIL.Image -from rubicon.objc import SEL, ObjCClass, objc_id, send_message +import pytest +from rubicon.objc import SEL, NSPoint, ObjCClass, objc_id, send_message import toga from toga_cocoa.keys import toga_key @@ -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/en/reference/api/data-representation/paths.md b/docs/en/reference/api/data-representation/paths.md index f34bacc141..018ec34df7 100644 --- a/docs/en/reference/api/data-representation/paths.md +++ b/docs/en/reference/api/data-representation/paths.md @@ -4,7 +4,7 @@ When Python code executes from the command line, the working directory is a known location - the location where the application was started. However, when executing GUI apps, the working directory varies between platforms. As a result, when specifying file paths, relative paths cannot be used, as there is no 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 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. +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), 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. To assist with finding an appropriate location to store application files, every Toga application instance has a [`paths`][toga.App.paths] attribute that returns an instance of [`Paths`][toga.paths.Paths]. This object provides known file system locations that are appropriate for storing files of given types, such as configuration files, log files, cache files, or user data. diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index f07131d3fd..f041069f7f 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -36,6 +36,7 @@ CMD codebase codepoint CommandSet +config coroutine cryptographically CSS 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 b5f71c831e..6656cfef03 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 @@ -35,24 +37,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 6c55e3bdb0..63f512816b 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 @@ -32,21 +33,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 eb0746ebbb..abe6118b2f 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 @@ -32,22 +33,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 @@ -97,6 +82,32 @@ class CURSORINFO(ctypes.Structure): else: return info.flags == 1 + @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.")