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 Down Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions changes/3482.bugfix.md
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.md
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.
33 changes: 16 additions & 17 deletions cocoa/tests_backend/app.py
Original file line number Diff line number Diff line change
@@ -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
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
2 changes: 1 addition & 1 deletion docs/en/reference/api/data-representation/paths.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions docs/spelling_wordlist
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ CMD
codebase
codepoint
CommandSet
config
coroutine
cryptographically
CSS
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 @@ -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.")
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 @@ -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")
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