From f3819fd7ed2cea506e996f34e9dacad62ced2405 Mon Sep 17 00:00:00 2001 From: Sharif Naas Date: Fri, 12 Sep 2025 12:11:11 -0700 Subject: [PATCH 1/5] Combine comments. --- testbed/tests/test_paths.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/testbed/tests/test_paths.py b/testbed/tests/test_paths.py index e448411edd..1d282cd9d3 100644 --- a/testbed/tests/test_paths.py +++ b/testbed/tests/test_paths.py @@ -12,10 +12,8 @@ async def test_app_paths(app, app_probe, attr): assert path == getattr(app_probe, f"{attr}_path") try: - # We can create a file in the app path + # We can create and write to 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") From 0df3ee2b733584c292070f9499d37bf98d69d502 Mon Sep 17 00:00:00 2001 From: Sharif Naas Date: Fri, 12 Sep 2025 12:38:55 -0700 Subject: [PATCH 2/5] Add test of directory creation. Since Toga handles creating the root directories if they don't exist, we don't want to create the parent folders here, so that it fails if the directory wasn't created by Toga. --- testbed/tests/test_paths.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/testbed/tests/test_paths.py b/testbed/tests/test_paths.py index 1d282cd9d3..2f43301956 100644 --- a/testbed/tests/test_paths.py +++ b/testbed/tests/test_paths.py @@ -12,6 +12,10 @@ async def test_app_paths(app, app_probe, attr): assert path == getattr(app_probe, f"{attr}_path") try: + # 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: From 852d97d4ef8e094b3de67e00d6ec631e3562b437 Mon Sep 17 00:00:00 2001 From: Sharif Naas Date: Thu, 22 May 2025 13:26:53 -0700 Subject: [PATCH 3/5] Refactor path tests to use a context manager. This is in preparation for a future change. --- android/tests_backend/app.py | 24 ++++++--------- cocoa/tests_backend/app.py | 27 +++++++---------- gtk/tests_backend/app.py | 26 +++++++--------- iOS/tests_backend/app.py | 25 +++++++--------- testbed/tests/test_paths.py | 56 +++++++++++++++++------------------ winforms/tests_backend/app.py | 40 +++++++++++++++---------- 6 files changed, 92 insertions(+), 106 deletions(-) diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py index 6b7c50e6a7..81f0eeef40 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,14 @@ 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): + 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/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index 71a028c666..6ab16e9800 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -1,3 +1,4 @@ +from contextlib import contextmanager from pathlib import Path import PIL.Image @@ -34,28 +35,22 @@ 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): + 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/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index b5f71c831e..2cf41eeed5 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -1,4 +1,5 @@ import os +from contextlib import contextmanager from pathlib import Path import PIL.Image @@ -34,26 +35,19 @@ def __init__(self, app): assert isinstance(self.app._impl.native, Adw.Application) assert IS_WAYLAND is (os.environ.get("WAYLAND_DISPLAY", "") != "") - @property - def config_path(self): - return Path.home() / ".config/testbed" - - @property - def data_path(self): - return Path.home() / ".local/share/testbed" - - @property - def cache_path(self): - return Path.home() / ".cache/testbed" - - @property - def logs_path(self): - return Path.home() / ".local/state/testbed/log" - @property def is_cursor_visible(self): pytest.skip("Cursor visibility not implemented on GTK") + @contextmanager + def prepare_paths(self): + yield { + "config": Path.home() / ".config/testbed", + "data": Path.home() / ".local/share/testbed", + "cache": Path.home() / ".cache/testbed", + "logs": Path.home() / ".local/state/testbed/log", + } + 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..9f84588617 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,15 @@ 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): + 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 2f43301956..7d15716417 100644 --- a/testbed/tests/test_paths.py +++ b/testbed/tests/test_paths.py @@ -6,32 +6,32 @@ @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") - - try: - # 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: + with app_probe.prepare_paths() as expected_paths: + """Platform paths are as expected.""" + path = getattr(app.paths, attr) + assert path == expected_paths[attr] + 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..cbf0c7a68c 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,29 @@ class CURSORINFO(ctypes.Structure): else: return info.flags == 1 + @contextmanager + def prepare_paths(self): + 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.") From 25afaefc465af31754047fd7f8ead70ef80a63cf Mon Sep 17 00:00:00 2001 From: Sharif Naas Date: Thu, 22 May 2025 13:54:51 -0700 Subject: [PATCH 4/5] Add customized app path testing to testbed. No backends implement it yet. --- android/tests_backend/app.py | 5 ++++- cocoa/tests_backend/app.py | 8 ++++++-- gtk/tests_backend/app.py | 5 ++++- iOS/tests_backend/app.py | 5 ++++- testbed/tests/test_paths.py | 5 +++-- winforms/tests_backend/app.py | 5 ++++- 6 files changed, 25 insertions(+), 8 deletions(-) diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py index 81f0eeef40..ef2f0e8e7e 100644 --- a/android/tests_backend/app.py +++ b/android/tests_backend/app.py @@ -28,7 +28,10 @@ def get_app_context(self): return self.native.getApplicationContext() @contextmanager - def prepare_paths(self): + 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", diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index 6ab16e9800..c1fcdf516e 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -2,7 +2,8 @@ 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 @@ -42,7 +43,10 @@ def is_cursor_visible(self): return self.app._impl._cursor_visible @contextmanager - def prepare_paths(self): + 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() diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index 2cf41eeed5..5a80f7b34b 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -40,7 +40,10 @@ def is_cursor_visible(self): pytest.skip("Cursor visibility not implemented on GTK") @contextmanager - def prepare_paths(self): + def prepare_paths(self, *, custom): + if custom: + pytest.xfail("This backend doesn't implement app path customization.") + yield { "config": Path.home() / ".config/testbed", "data": Path.home() / ".local/share/testbed", diff --git a/iOS/tests_backend/app.py b/iOS/tests_backend/app.py index 9f84588617..63f512816b 100644 --- a/iOS/tests_backend/app.py +++ b/iOS/tests_backend/app.py @@ -34,7 +34,10 @@ def get_path(self, search_path): return Path(urls[0].path) @contextmanager - def prepare_paths(self): + 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", diff --git a/testbed/tests/test_paths.py b/testbed/tests/test_paths.py index 7d15716417..4408046e42 100644 --- a/testbed/tests/test_paths.py +++ b/testbed/tests/test_paths.py @@ -5,8 +5,9 @@ @pytest.mark.parametrize("attr", ["config", "data", "cache", "logs"]) -async def test_app_paths(app, app_probe, attr): - with app_probe.prepare_paths() as expected_paths: +@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] diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index cbf0c7a68c..abe6118b2f 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -83,7 +83,10 @@ class CURSORINFO(ctypes.Structure): return info.flags == 1 @contextmanager - def prepare_paths(self): + def prepare_paths(self, *, custom): + if custom: + pytest.xfail("This backend doesn't implement app path customization.") + yield { "config": ( Path.home() From 9c52fdd5b803ad1a0b3e60991ee7f288919aecba Mon Sep 17 00:00:00 2001 From: Sharif Naas Date: Thu, 22 May 2025 13:56:32 -0700 Subject: [PATCH 5/5] Make the GTK backend respect XDG app path environment variables. This commit also updates related documentation. --- changes/3482.bugfix.md | 1 + changes/3482.removal.md | 1 + .../api/data-representation/paths.md | 2 +- docs/spelling_wordlist | 1 + gtk/src/toga_gtk/paths.py | 23 ++++-- gtk/tests_backend/app.py | 70 ++++++++++++++++--- 6 files changed, 85 insertions(+), 13 deletions(-) create mode 100644 changes/3482.bugfix.md create mode 100644 changes/3482.removal.md 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/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 5a80f7b34b..6656cfef03 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -1,4 +1,5 @@ import os +import tempfile from contextlib import contextmanager from pathlib import Path @@ -41,16 +42,69 @@ def is_cursor_visible(self): @contextmanager def prepare_paths(self, *, custom): - if custom: - pytest.xfail("This backend doesn't implement app path customization.") - - yield { - "config": Path.home() / ".config/testbed", - "data": Path.home() / ".local/share/testbed", - "cache": Path.home() / ".cache/testbed", - "logs": Path.home() / ".local/state/testbed/log", + # 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"), } + # Creating this variable here so it can be checked during cleanup + temp_custom_dir = None + + 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] + + # 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.")