Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 12 additions & 15 deletions android/tests_backend/app.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from contextlib import contextmanager
from pathlib import Path

import pytest
Expand All @@ -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")
Expand Down
1 change: 1 addition & 0 deletions changes/3482.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The GTK backend now respects XDG app path environment variables.
1 change: 1 addition & 0 deletions changes/3482.removal.rst
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 15 additions & 16 deletions cocoa/tests_backend/app.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)

Expand Down
13 changes: 12 additions & 1 deletion docs/reference/api/resources/app_paths.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 <https://specifications.freedesktop.org/basedir-spec/latest/>`__;
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
---------

Expand Down
23 changes: 19 additions & 4 deletions gtk/src/toga_gtk/paths.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from pathlib import Path

from toga import App
Expand All @@ -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"
79 changes: 65 additions & 14 deletions gtk/tests_backend/app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import os
import tempfile
from contextlib import contextmanager
from pathlib import Path

import PIL.Image
Expand Down Expand Up @@ -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.")
Expand Down
28 changes: 13 additions & 15 deletions iOS/tests_backend/app.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from contextlib import contextmanager
from pathlib import Path

import pytest
Expand Down Expand Up @@ -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")
Expand Down
55 changes: 29 additions & 26 deletions testbed/tests/test_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading